From 63fa499a192ebf2aad5ca4b7543c825ad8c18328 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Thu, 18 Sep 2025 14:31:43 -0400 Subject: [PATCH 01/83] Initial Symfony Migration changes These changes provide the initial migration of XDMoD's rest framework to Symfony --- .circleci/config.yml | 11 +- .env | 8 + bin/acl-config | 1 + bin/console | 17 + .../SAML/XDSamlAuthentication.php | 19 +- classes/CCR/CCRDBHandler.php | 21 +- classes/CCR/CCRLineFormatter.php | 5 + classes/CCR/Log.php | 40 +- classes/CCR/Logger.php | 71 - classes/Configuration/Configuration.php | 14 +- classes/DB/ArrayIngestor.php | 6 +- classes/DB/FilterListHelper.php | 2 +- classes/DataWarehouse/Access/Usage.php | 16 +- classes/DataWarehouse/Data/BatchDataset.php | 10 +- .../DataWarehouse/Data/TimeseriesDataset.php | 4 +- classes/DataWarehouse/Export/RealmManager.php | 7 +- classes/DataWarehouse/ExportBuilder.php | 25 + .../Query/TimeAggregationUnit.php | 11 + classes/DataWarehouse/Visualization.php | 8 +- .../Visualization/AggregateChart.php | 3 + .../Visualization/TimeseriesChart.php | 9 +- classes/ETL/Aggregator/JobsAggregator.php | 24 +- classes/ETL/Aggregator/pdoAggregator.php | 18 +- .../ETL/Configuration/EtlConfiguration.php | 14 +- classes/ETL/DataEndpoint/DirectoryScanner.php | 12 +- .../DataEndpoint/Filter/ExternalProcess.php | 10 +- classes/ETL/DataEndpoint/aStructuredFile.php | 12 +- classes/ETL/DbModel/Column.php | 14 +- classes/ETL/DbModel/Entity.php | 5 +- classes/ETL/Ingestor/RestIngestor.php | 2 +- classes/ETL/aOptions.php | 10 +- classes/Models/DBObject.php | 1 + classes/Models/Services/Tokens.php | 4 +- classes/OpenXdmod/Build/Packager.php | 18 + classes/Realm/Realm.php | 22 +- .../Controllers/AdminControllerProvider.php | 57 - .../AuthenticationControllerProvider.php | 155 - .../Controllers/BaseControllerProvider.php | 790 -- .../DashboardControllerProvider.php | 430 - .../Controllers/LegacyControllerProvider.php | 129 - .../MetricExplorerControllerProvider.php | 405 - .../Controllers/PersonControllerProvider.php | 55 - .../WarehouseExportControllerProvider.php | 416 - classes/Rest/RestFacade.php | 138 - classes/Rest/Utilities/Authentication.php | 275 - classes/Rest/Utilities/Conversions.php | 57 - classes/Rest/XdmodApplicationFactory.php | 266 - classes/UserStorage.php | 2 +- classes/XDChartPool.php | 75 +- classes/XDReportManager.php | 29 +- classes/XDSessionManager.php | 22 +- classes/XDUser.php | 220 +- classes/Xdmod/NodeSet.php | 10 +- composer.json | 116 +- composer.lock | 9229 +++++++++++++---- config/bundles.php | 12 + config/packages/cache.yaml | 19 + config/packages/dev/monolog.yaml | 17 + config/packages/doctrine.yaml | 50 + config/packages/doctrine_migrations.yaml | 6 + config/packages/framework.yaml | 26 + config/packages/google_recaptcha.yaml | 21 + config/packages/maker.yaml | 5 + config/packages/monolog.yaml | 61 + config/packages/nyholm_psr7.yaml | 11 + config/packages/property_info.yaml | 3 + config/packages/routing.yaml | 12 + config/packages/security.yaml | 60 + config/packages/twig.yaml | 7 + config/packages/twig_extensions.yaml | 11 + config/packages/web_profiler.yaml | 21 + config/preload.php | 5 + config/routes.yaml | 5 + config/routes/framework.yaml | 4 + config/routes/routes.yaml | 18 + config/routes/security.yaml | 3 + config/routes/web_profiler.yaml | 8 + config/services.yaml | 71 + configuration/constants.php | 4 +- configuration/linker.php | 2 +- configuration/portal_settings.ini | 2 +- html/about/federated.php | 113 - html/about/images/Case_Western_logo.png | Bin 81675 -> 0 bytes html/about/images/SDSC_logo.jpg | Bin 9464 -> 0 bytes html/about/images/Tufts_logo.png | Bin 6887 -> 0 bytes html/about/images/access_logo.png | Bin 10078 -> 0 bytes html/about/links.html | 20 - html/about/openxd.html | 39 - html/about/presentations.html | 148 - html/about/roadmap.php | 60 - html/about/supremm.html | 30 - html/about/team.html | 28 - html/about/xdmod.php | 35 - html/auth_error.php | 51 - html/controllers/chart_pool.php | 25 - html/controllers/chart_pool/add_to_queue.php | 41 - .../chart_pool/remove_from_queue.php | 40 - html/controllers/common_params.php | 206 - html/controllers/dashboard.php | 31 - html/controllers/dashboard_launch.php | 43 - html/controllers/mailer.php | 18 - html/controllers/mailer/contact.php | 119 - html/controllers/mailer/sign_up.php | 106 - html/controllers/metric_explorer.php | 35 - html/controllers/metric_explorer/common.php | 255 - html/controllers/metric_explorer/get_data.php | 33 - .../metric_explorer/get_dimension.php | 35 - .../metric_explorer/get_dw_descripter.php | 166 - .../metric_explorer/get_filters.php | 47 - .../metric_explorer/get_rawdata.php | 189 - .../metric_explorer/set_filters.php | 28 - html/controllers/public_interface.php | 24 - .../public_interface/get_public.php | 50 - html/controllers/report_builder.php | 49 - .../report_builder/build_from_template.php | 30 - .../report_builder/download_report.php | 96 - .../report_builder/enum_available_charts.php | 26 - .../report_builder/enum_reports.php | 25 - .../report_builder/enum_templates.php | 30 - .../report_builder/fetch_report_data.php | 57 - .../report_builder/get_new_report_name.php | 26 - .../report_builder/get_preview_data.php | 35 - .../report_builder/remove_chart_from_pool.php | 45 - .../report_builder/remove_report_by_id.php | 31 - .../report_builder/save_report.php | 159 - .../report_builder/send_report.php | 113 - html/controllers/role_manager.php | 27 - .../role_manager/downgrade_member.php | 38 - .../enum_center_staff_members.php | 27 - .../role_manager/get_member_status.php | 58 - .../role_manager/upgrade_member.php | 65 - html/controllers/sab_user.php | 27 - .../sab_user/assign_assumed_person.php | 38 - html/controllers/sab_user/enum_tg_users.php | 105 - html/controllers/sab_user/get_mapping.php | 36 - html/controllers/ui_data/summary3.php | 161 - html/controllers/user_admin.php | 36 - html/controllers/user_admin/create_user.php | 150 - html/controllers/user_admin/delete_user.php | 42 - .../user_admin/empty_report_image_cache.php | 35 - .../enum_exception_email_addresses.php | 15 - .../user_admin/enum_institutions.php | 25 - .../user_admin/enum_resource_providers.php | 29 - html/controllers/user_admin/enum_roles.php | 30 - .../user_admin/enum_user_types.php | 23 - .../user_admin/get_user_details.php | 81 - html/controllers/user_admin/list_users.php | 48 - html/controllers/user_admin/pass_reset.php | 65 - html/controllers/user_admin/search_users.php | 23 - html/controllers/user_admin/update_user.php | 229 - html/controllers/user_auth.php | 30 - html/controllers/user_auth/login.php | 31 - html/controllers/user_auth/logout.php | 9 - html/controllers/user_auth/pass_reset.php | 65 - html/controllers/user_auth/session_check.php | 21 - html/controllers/user_auth/update_pass.php | 52 - html/controllers/user_interface.php | 43 - .../controllers/user_interface/get_charts.php | 34 - html/controllers/user_interface/get_data.php | 4 - html/controllers/user_interface/get_menus.php | 260 - .../user_interface/get_param_descriptions.php | 41 - html/controllers/user_interface/get_tabs.php | 58 - html/gaq.php | 3 - html/gaq/xdmod.php | 10 - .../css => gui/css/dashboard}/AdminPanel.css | 14 +- .../css => gui/css/dashboard}/dashboard.css | 0 html/gui/css/dashboard/management.css | 72 + .../css => gui/css/dashboard}/menu.css | 0 .../css => gui/css/dashboard}/splash.css | 0 .../images/about}/Ccr_ub_logo.jpg | Bin .../images/about}/OpenXDMOD20.png | Bin .../images/about}/OpenXDMoDUsage.png | Bin .../images => gui/images/about}/SUPPReM.png | Bin .../images/about}/Supremm_drop_off.png | Bin .../images => gui/images/about}/TACC_logo.png | Bin .../images/about}/XDMoDsummary.png | Bin .../images/about}/federated-diagram-1.gif | Bin .../images/about/indianauniversity_logo.jpg | Bin 0 -> 24178 bytes .../images => gui/images/about}/nsf_logo.png | Bin .../images/about}/xdmod_logo.png | Bin html/gui/images/about/xsede_logo.jpg | Bin 0 -> 25306 bytes .../images/dashboard}/arrow_left.png | Bin .../images/dashboard}/center_edit.png | Bin .../images/dashboard}/icon_delete.png | Bin .../images/dashboard}/icon_dialog.png | Bin .../images/dashboard}/icon_edit.png | Bin .../images/dashboard}/icon_email.png | Bin .../images/dashboard}/icon_email_cancel.png | Bin .../images/dashboard}/icon_email_send.png | Bin .../images/dashboard}/icon_exception.png | Bin .../images/dashboard}/icon_group.png | Bin .../images/dashboard}/icon_ldif.png | Bin .../images/dashboard}/icon_login.png | Bin .../images/dashboard}/icon_refresh.png | Bin .../images/dashboard}/icon_reset.png | Bin .../images/dashboard}/icon_role.png | Bin .../images/dashboard}/icon_save.png | Bin .../images/dashboard}/masthead.png | Bin .../images/dashboard}/masthead_splash.png | Bin html/gui/js/CCR.js | 39 +- html/gui/js/Viewer.js | 3 +- .../js/dashboard}/AccountRequests.js | 8 +- .../js/dashboard}/BatchMailClient.js | 8 +- .../js => gui/js/dashboard}/CommentEditor.js | 170 +- .../js => gui/js/dashboard}/CurrentUsers.js | 8 +- .../js/dashboard}/Dashboard/Factory.js | 0 .../js/dashboard}/Dashboard/FramePanel.js | 0 .../js/dashboard}/Dashboard/MenuStore.js | 2 +- .../js/dashboard}/Dashboard/Viewport.js | 10 +- .../js => gui/js/dashboard}/DashboardStore.js | 0 .../js => gui/js/dashboard}/DashboardTools.js | 0 .../js/dashboard}/ExceptionLister.js | 0 .../js => gui/js/dashboard}/Log/GridPanel.js | 0 .../js/dashboard}/Log/LevelsStore.js | 2 +- .../js => gui/js/dashboard}/Log/Store.js | 2 +- .../js/dashboard}/Log/SummaryPortlet.js | 0 .../js/dashboard}/Log/SummaryStore.js | 2 +- .../js => gui/js/dashboard}/Log/TabPanel.js | 0 .../dashboard}/RecipientVerificationPrompt.js | 0 .../js/dashboard}/Summary/ConfigStore.js | 2 +- .../js => gui/js/dashboard}/Summary/Portal.js | 0 .../js/dashboard}/Summary/Portlet.js | 0 .../js/dashboard}/Summary/PortletsStore.js | 2 +- .../js/dashboard}/Summary/TabPanel.js | 0 .../js/dashboard}/UserManagement/Panel.js | 0 .../js => gui/js/dashboard}/UserStats.js | 6 +- .../js/dashboard}/UsersSummary/Portlet.js | 0 .../js/dashboard}/UsersSummary/Store.js | 2 +- .../js/dashboard}/admin_panel/AclGrid.js | 8 +- .../js/dashboard}/admin_panel/AdminPanel.js | 0 .../js/dashboard}/admin_panel/RoleGrid.js | 10 +- .../admin_panel/SectionExistingUsers.js | 16 +- .../dashboard}/admin_panel/SectionNewUser.js | 6 +- .../js => gui/js/dashboard}/common.js | 0 .../js => gui/js/dashboard}/dashboard.js | 4 +- .../js => gui/js/dashboard}/messaging.js | 0 html/gui/js/dashboard/test-failed-1.png | Bin 0 -> 108428 bytes html/img_placeholder.php | 11 - html/index.php | 660 +- html/internal_dashboard/analytics/index.php | 9 - .../controllers/controller.php | 213 - .../controllers/dashboard.php | 10 - .../controllers/dashboard/get_menu.php | 29 - html/internal_dashboard/controllers/log.php | 12 - .../controllers/log/get_levels.php | 34 - .../controllers/log/get_messages.php | 98 - .../controllers/log/get_summary.php | 26 - .../internal_dashboard/controllers/mailer.php | 106 - .../controllers/pseudo_login.php | 105 - .../controllers/summary.php | 12 - .../controllers/summary/get_config.php | 64 - .../controllers/summary/get_portlets.php | 62 - html/internal_dashboard/controllers/user.php | 10 - .../controllers/user/get_summary.php | 52 - html/internal_dashboard/css/management.css | 72 - html/internal_dashboard/index.php | 224 - html/internal_dashboard/splash.php | 75 - html/internal_dashboard/user_check.php | 63 - html/password_reset.php | 185 - html/report_image_renderer.php | 164 - html/rest/index.php | 25 - html/rest/maintenance.php | 9 - html/unit_tests/.eslintrc.json | 9 - html/unit_tests/Array.prototype.includes.js | 37 - html/unit_tests/coverage.html | 91 - html/unit_tests/index.html | 79 - html/unit_tests/phantom.js | 35 - html/unit_tests/spec/.eslintrc.json | 12 - html/unit_tests/spec/CCRTokenizeSpec.js | 71 - html/unit_tests/spec/ChangeStackSpec.js | 141 - html/unit_tests/spec/JobViewerSpec.js | 91 - html/unit_tests/spec/XDMoDFormatSpec.js | 89 - libraries/security.php | 236 +- libraries/utilities.php | 19 +- .../build_scripts/templates/install.template | 6 +- open_xdmod/modules/xdmod/build.json | 18 +- open_xdmod/modules/xdmod/xdmod.spec.in | 10 +- src/Controller/.gitignore | 0 src/Controller/AboutController.php | 178 + src/Controller/AccountController.php | 97 + src/Controller/AdminController.php | 78 + src/Controller/AuthenticationController.php | 248 + src/Controller/BaseController.php | 695 ++ src/Controller/ChartPoolController.php | 107 + src/Controller/DashboardController.php | 519 + src/Controller/HomeController.php | 363 + .../InternalDashboardController.php | 469 + .../InternalDashboard/LogController.php | 184 + .../InternalDashboard/MailerController.php | 114 + .../InternalDashboard/SABUserController.php | 112 + .../InternalDashboard/SummaryController.php | 307 + .../InternalDashboard/UserAdminController.php | 981 ++ .../InternalDashboard/UserVisitController.php | 83 + src/Controller/MailController.php | 225 + src/Controller/MetricExplorerController.php | 1017 ++ src/Controller/OrganizationController.php | 277 + src/Controller/PasswordResetController.php | 63 + src/Controller/PersonController.php | 37 + src/Controller/ReportBuilderController.php | 703 ++ src/Controller/ResourceController.php | 9 + .../Controller/UserController.php | 300 +- src/Controller/UserInterfaceController.php | 459 + .../Controller/WarehouseController.php | 2156 ++-- src/Controller/WarehouseExportController.php | 391 + src/Entity/.gitignore | 0 src/Entity/User.php | 183 + src/Errors/ErrorController.php | 51 + src/EventListeners/LogoutListener.php | 29 + src/Kernel.php | 26 + src/Repository/.gitignore | 0 src/Security/AccessDeniedHandler.php | 22 + .../Authenticators/FormLoginAuthenticator.php | 241 + .../SimpleSamlPhpAuthenticator.php | 174 + src/Security/Helpers/Tokens.php | 170 + .../PasswordHashers/DefaultPasswordHasher.php | 28 + src/Security/TokenUserProvider.php | 80 + src/Security/UsernameUserProvider.php | 146 + symfony.lock | 202 + templates/about/federated.html.twig | 65 + templates/about/links.html.twig | 40 + templates/about/open_xdmod.html.twig | 44 + templates/about/presentations.html.twig | 148 + .../about/publications.html.twig | 0 templates/about/roadmap.html.twig | 17 + templates/about/supremm.html.twig | 24 + templates/about/team.html.twig | 27 + templates/about/xdmod.html.twig | 47 + .../about/xdmod_release_notes.html.twig | 0 templates/apache.conf | 65 +- templates/base.html.twig | 18 + templates/emails/new_user.html.twig | 12 + templates/emails/password_reset.html.twig | 15 + templates/index.html.twig | 390 + templates/internal_dashboard.html.twig | 200 + templates/internal_dashboard_login.html.twig | 135 + templates/password_reset.html.twig | 108 + templates/password_reset_expired.html.twig | 35 + ...sses-update_enum_user_types_and_roles.json | 4 +- ...es__-update_enum_user_types_and_roles.json | 6 +- .../user_admin/input/get_user_visits.json | 62 +- tests/ci/samlSetup.sh | 501 +- tests/component/lib/BaseTest.php | 35 + tests/component/lib/ETL/IngestorTest.php | 9 +- .../component/lib/Export/FileManagerTest.php | 2 - .../component/lib/Export/RealmManagerTest.php | 12 +- tests/component/lib/XDUserTest.php | 2 +- tests/integration/lib/BaseTest.php | 10 + .../lib/Controllers/BaseUserAdminTest.php | 125 +- .../lib/Controllers/ControllerTest.php | 14 +- .../lib/Controllers/MetricExplorerTest.php | 16 +- .../lib/Controllers/ReportBuilderTest.php | 79 +- .../lib/Controllers/RoleDelegationTest.php | 12 +- .../lib/Controllers/SSOLoginTest.php | 4 +- .../lib/Controllers/UsageExplorerTest.php | 47 +- .../lib/Controllers/UserAdminTest.php | 30 +- .../lib/Logging/CCRDBHandlerTest.php | 45 +- tests/integration/lib/Rest/JobViewerTest.php | 8 +- .../Rest/WarehouseControllerProviderTest.php | 2 +- .../WarehouseExportControllerProviderTest.php | 32 +- .../lib/TestHarness/XdmodTestHelper.php | 11 +- .../lib/internal_dashboard.selectors.ts | 17 +- tests/playwright/lib/usageTab.page.ts | 13 +- .../internal_dashboard.spec.ts | 28 +- .../Controllers/MetricExplorerChartsTest.php | 22 +- .../lib/Controllers/UsageChartsTest.php | 4 +- .../lib/Controllers/UsageExplorerJobsTest.php | 2 +- .../lib/TestHarness/RegressionTestHelper.php | 2 +- .../lib/DataWarehouse/VisualizationTest.php | 10 +- .../JsonReferenceWithFallbackTest.php | 4 +- .../ETL/DataEndpoint/WebServerLogFileTest.php | 26 +- tests/unit/lib/LogTest.php | 2 +- 371 files changed, 21140 insertions(+), 15267 deletions(-) create mode 100644 .env create mode 100755 bin/console delete mode 100644 classes/Rest/Controllers/AdminControllerProvider.php delete mode 100644 classes/Rest/Controllers/AuthenticationControllerProvider.php delete mode 100644 classes/Rest/Controllers/DashboardControllerProvider.php delete mode 100644 classes/Rest/Controllers/LegacyControllerProvider.php delete mode 100644 classes/Rest/Controllers/MetricExplorerControllerProvider.php delete mode 100644 classes/Rest/Controllers/PersonControllerProvider.php delete mode 100644 classes/Rest/RestFacade.php delete mode 100644 classes/Rest/Utilities/Authentication.php delete mode 100644 classes/Rest/Utilities/Conversions.php create mode 100644 config/bundles.php create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/dev/monolog.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/google_recaptcha.yaml create mode 100644 config/packages/maker.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/packages/nyholm_psr7.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/packages/twig_extensions.yaml create mode 100644 config/packages/web_profiler.yaml create mode 100644 config/preload.php create mode 100644 config/routes.yaml create mode 100644 config/routes/framework.yaml create mode 100644 config/routes/routes.yaml create mode 100644 config/routes/security.yaml create mode 100644 config/routes/web_profiler.yaml create mode 100644 config/services.yaml delete mode 100644 html/about/federated.php delete mode 100644 html/about/images/Case_Western_logo.png delete mode 100644 html/about/images/SDSC_logo.jpg delete mode 100644 html/about/images/Tufts_logo.png delete mode 100644 html/about/images/access_logo.png delete mode 100644 html/about/links.html delete mode 100644 html/about/openxd.html delete mode 100644 html/about/presentations.html delete mode 100644 html/about/supremm.html delete mode 100644 html/about/team.html delete mode 100644 html/about/xdmod.php delete mode 100644 html/auth_error.php delete mode 100644 html/controllers/chart_pool.php delete mode 100644 html/controllers/chart_pool/add_to_queue.php delete mode 100644 html/controllers/chart_pool/remove_from_queue.php delete mode 100644 html/controllers/common_params.php delete mode 100644 html/controllers/dashboard.php delete mode 100644 html/controllers/dashboard_launch.php delete mode 100644 html/controllers/mailer.php delete mode 100644 html/controllers/mailer/contact.php delete mode 100644 html/controllers/mailer/sign_up.php delete mode 100644 html/controllers/metric_explorer.php delete mode 100644 html/controllers/metric_explorer/common.php delete mode 100644 html/controllers/metric_explorer/get_data.php delete mode 100644 html/controllers/metric_explorer/get_dimension.php delete mode 100644 html/controllers/metric_explorer/get_dw_descripter.php delete mode 100644 html/controllers/metric_explorer/get_filters.php delete mode 100644 html/controllers/metric_explorer/get_rawdata.php delete mode 100644 html/controllers/metric_explorer/set_filters.php delete mode 100644 html/controllers/public_interface.php delete mode 100644 html/controllers/public_interface/get_public.php delete mode 100644 html/controllers/report_builder.php delete mode 100644 html/controllers/report_builder/build_from_template.php delete mode 100644 html/controllers/report_builder/download_report.php delete mode 100644 html/controllers/report_builder/enum_available_charts.php delete mode 100644 html/controllers/report_builder/enum_reports.php delete mode 100644 html/controllers/report_builder/enum_templates.php delete mode 100644 html/controllers/report_builder/fetch_report_data.php delete mode 100644 html/controllers/report_builder/get_new_report_name.php delete mode 100644 html/controllers/report_builder/get_preview_data.php delete mode 100644 html/controllers/report_builder/remove_chart_from_pool.php delete mode 100644 html/controllers/report_builder/remove_report_by_id.php delete mode 100644 html/controllers/report_builder/save_report.php delete mode 100644 html/controllers/report_builder/send_report.php delete mode 100644 html/controllers/role_manager.php delete mode 100644 html/controllers/role_manager/downgrade_member.php delete mode 100644 html/controllers/role_manager/enum_center_staff_members.php delete mode 100644 html/controllers/role_manager/get_member_status.php delete mode 100644 html/controllers/role_manager/upgrade_member.php delete mode 100644 html/controllers/sab_user.php delete mode 100644 html/controllers/sab_user/assign_assumed_person.php delete mode 100644 html/controllers/sab_user/enum_tg_users.php delete mode 100644 html/controllers/sab_user/get_mapping.php delete mode 100644 html/controllers/ui_data/summary3.php delete mode 100644 html/controllers/user_admin.php delete mode 100644 html/controllers/user_admin/create_user.php delete mode 100644 html/controllers/user_admin/delete_user.php delete mode 100644 html/controllers/user_admin/empty_report_image_cache.php delete mode 100644 html/controllers/user_admin/enum_exception_email_addresses.php delete mode 100644 html/controllers/user_admin/enum_institutions.php delete mode 100644 html/controllers/user_admin/enum_resource_providers.php delete mode 100644 html/controllers/user_admin/enum_roles.php delete mode 100644 html/controllers/user_admin/enum_user_types.php delete mode 100644 html/controllers/user_admin/get_user_details.php delete mode 100644 html/controllers/user_admin/list_users.php delete mode 100644 html/controllers/user_admin/pass_reset.php delete mode 100644 html/controllers/user_admin/search_users.php delete mode 100644 html/controllers/user_admin/update_user.php delete mode 100644 html/controllers/user_auth.php delete mode 100644 html/controllers/user_auth/login.php delete mode 100644 html/controllers/user_auth/logout.php delete mode 100644 html/controllers/user_auth/pass_reset.php delete mode 100644 html/controllers/user_auth/session_check.php delete mode 100644 html/controllers/user_auth/update_pass.php delete mode 100644 html/controllers/user_interface.php delete mode 100644 html/controllers/user_interface/get_charts.php delete mode 100644 html/controllers/user_interface/get_data.php delete mode 100644 html/controllers/user_interface/get_menus.php delete mode 100644 html/controllers/user_interface/get_param_descriptions.php delete mode 100644 html/controllers/user_interface/get_tabs.php delete mode 100644 html/gaq.php delete mode 100644 html/gaq/xdmod.php rename html/{internal_dashboard/css => gui/css/dashboard}/AdminPanel.css (87%) rename html/{internal_dashboard/css => gui/css/dashboard}/dashboard.css (100%) create mode 100644 html/gui/css/dashboard/management.css rename html/{internal_dashboard/css => gui/css/dashboard}/menu.css (100%) rename html/{internal_dashboard/css => gui/css/dashboard}/splash.css (100%) rename html/{about/images => gui/images/about}/Ccr_ub_logo.jpg (100%) rename html/{about/images => gui/images/about}/OpenXDMOD20.png (100%) rename html/{about/images => gui/images/about}/OpenXDMoDUsage.png (100%) rename html/{about/images => gui/images/about}/SUPPReM.png (100%) rename html/{about/images => gui/images/about}/Supremm_drop_off.png (100%) rename html/{about/images => gui/images/about}/TACC_logo.png (100%) rename html/{about/images => gui/images/about}/XDMoDsummary.png (100%) rename html/{about/images => gui/images/about}/federated-diagram-1.gif (100%) create mode 100644 html/gui/images/about/indianauniversity_logo.jpg rename html/{about/images => gui/images/about}/nsf_logo.png (100%) rename html/{about/images => gui/images/about}/xdmod_logo.png (100%) create mode 100644 html/gui/images/about/xsede_logo.jpg rename html/{internal_dashboard/images => gui/images/dashboard}/arrow_left.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/center_edit.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_delete.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_dialog.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_edit.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_email.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_email_cancel.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_email_send.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_exception.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_group.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_ldif.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_login.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_refresh.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_reset.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_role.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/icon_save.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/masthead.png (100%) rename html/{internal_dashboard/images => gui/images/dashboard}/masthead_splash.png (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/AccountRequests.js (98%) rename html/{internal_dashboard/js => gui/js/dashboard}/BatchMailClient.js (97%) rename html/{internal_dashboard/js => gui/js/dashboard}/CommentEditor.js (76%) rename html/{internal_dashboard/js => gui/js/dashboard}/CurrentUsers.js (97%) rename html/{internal_dashboard/js => gui/js/dashboard}/Dashboard/Factory.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Dashboard/FramePanel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Dashboard/MenuStore.js (97%) rename html/{internal_dashboard/js => gui/js/dashboard}/Dashboard/Viewport.js (97%) rename html/{internal_dashboard/js => gui/js/dashboard}/DashboardStore.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/DashboardTools.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/ExceptionLister.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/GridPanel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/LevelsStore.js (96%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/Store.js (99%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/SummaryPortlet.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/SummaryStore.js (98%) rename html/{internal_dashboard/js => gui/js/dashboard}/Log/TabPanel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/RecipientVerificationPrompt.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Summary/ConfigStore.js (96%) rename html/{internal_dashboard/js => gui/js/dashboard}/Summary/Portal.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Summary/Portlet.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/Summary/PortletsStore.js (96%) rename html/{internal_dashboard/js => gui/js/dashboard}/Summary/TabPanel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/UserManagement/Panel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/UserStats.js (98%) rename html/{internal_dashboard/js => gui/js/dashboard}/UsersSummary/Portlet.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/UsersSummary/Store.js (95%) rename html/{internal_dashboard/js => gui/js/dashboard}/admin_panel/AclGrid.js (98%) rename html/{internal_dashboard/js => gui/js/dashboard}/admin_panel/AdminPanel.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/admin_panel/RoleGrid.js (98%) rename html/{internal_dashboard/js => gui/js/dashboard}/admin_panel/SectionExistingUsers.js (99%) rename html/{internal_dashboard/js => gui/js/dashboard}/admin_panel/SectionNewUser.js (99%) rename html/{internal_dashboard/js => gui/js/dashboard}/common.js (100%) rename html/{internal_dashboard/js => gui/js/dashboard}/dashboard.js (93%) rename html/{internal_dashboard/js => gui/js/dashboard}/messaging.js (100%) create mode 100644 html/gui/js/dashboard/test-failed-1.png delete mode 100644 html/img_placeholder.php delete mode 100644 html/internal_dashboard/analytics/index.php delete mode 100644 html/internal_dashboard/controllers/controller.php delete mode 100644 html/internal_dashboard/controllers/dashboard.php delete mode 100644 html/internal_dashboard/controllers/dashboard/get_menu.php delete mode 100644 html/internal_dashboard/controllers/log.php delete mode 100644 html/internal_dashboard/controllers/log/get_levels.php delete mode 100644 html/internal_dashboard/controllers/log/get_messages.php delete mode 100644 html/internal_dashboard/controllers/log/get_summary.php delete mode 100644 html/internal_dashboard/controllers/mailer.php delete mode 100644 html/internal_dashboard/controllers/pseudo_login.php delete mode 100644 html/internal_dashboard/controllers/summary.php delete mode 100644 html/internal_dashboard/controllers/summary/get_config.php delete mode 100644 html/internal_dashboard/controllers/summary/get_portlets.php delete mode 100644 html/internal_dashboard/controllers/user.php delete mode 100644 html/internal_dashboard/controllers/user/get_summary.php delete mode 100644 html/internal_dashboard/css/management.css delete mode 100644 html/internal_dashboard/index.php delete mode 100644 html/internal_dashboard/splash.php delete mode 100644 html/internal_dashboard/user_check.php delete mode 100644 html/password_reset.php delete mode 100644 html/report_image_renderer.php delete mode 100644 html/rest/index.php delete mode 100644 html/rest/maintenance.php delete mode 100644 html/unit_tests/.eslintrc.json delete mode 100644 html/unit_tests/Array.prototype.includes.js delete mode 100644 html/unit_tests/coverage.html delete mode 100644 html/unit_tests/index.html delete mode 100644 html/unit_tests/phantom.js delete mode 100644 html/unit_tests/spec/.eslintrc.json delete mode 100644 html/unit_tests/spec/CCRTokenizeSpec.js delete mode 100644 html/unit_tests/spec/ChangeStackSpec.js delete mode 100644 html/unit_tests/spec/JobViewerSpec.js delete mode 100644 html/unit_tests/spec/XDMoDFormatSpec.js create mode 100644 src/Controller/.gitignore create mode 100644 src/Controller/AboutController.php create mode 100644 src/Controller/AccountController.php create mode 100644 src/Controller/AdminController.php create mode 100644 src/Controller/AuthenticationController.php create mode 100644 src/Controller/BaseController.php create mode 100644 src/Controller/ChartPoolController.php create mode 100644 src/Controller/DashboardController.php create mode 100644 src/Controller/HomeController.php create mode 100644 src/Controller/InternalDashboard/InternalDashboardController.php create mode 100644 src/Controller/InternalDashboard/LogController.php create mode 100644 src/Controller/InternalDashboard/MailerController.php create mode 100644 src/Controller/InternalDashboard/SABUserController.php create mode 100644 src/Controller/InternalDashboard/SummaryController.php create mode 100644 src/Controller/InternalDashboard/UserAdminController.php create mode 100644 src/Controller/InternalDashboard/UserVisitController.php create mode 100644 src/Controller/MailController.php create mode 100644 src/Controller/MetricExplorerController.php create mode 100644 src/Controller/OrganizationController.php create mode 100644 src/Controller/PasswordResetController.php create mode 100644 src/Controller/PersonController.php create mode 100644 src/Controller/ReportBuilderController.php create mode 100644 src/Controller/ResourceController.php rename classes/Rest/Controllers/UserControllerProvider.php => src/Controller/UserController.php (57%) create mode 100644 src/Controller/UserInterfaceController.php rename classes/Rest/Controllers/WarehouseControllerProvider.php => src/Controller/WarehouseController.php (55%) create mode 100644 src/Controller/WarehouseExportController.php create mode 100644 src/Entity/.gitignore create mode 100644 src/Entity/User.php create mode 100644 src/Errors/ErrorController.php create mode 100644 src/EventListeners/LogoutListener.php create mode 100644 src/Kernel.php create mode 100644 src/Repository/.gitignore create mode 100644 src/Security/AccessDeniedHandler.php create mode 100644 src/Security/Authenticators/FormLoginAuthenticator.php create mode 100644 src/Security/Authenticators/SimpleSamlPhpAuthenticator.php create mode 100644 src/Security/Helpers/Tokens.php create mode 100644 src/Security/PasswordHashers/DefaultPasswordHasher.php create mode 100644 src/Security/TokenUserProvider.php create mode 100644 src/Security/UsernameUserProvider.php create mode 100644 symfony.lock create mode 100644 templates/about/federated.html.twig create mode 100644 templates/about/links.html.twig create mode 100644 templates/about/open_xdmod.html.twig create mode 100644 templates/about/presentations.html.twig rename html/about/publications.html => templates/about/publications.html.twig (100%) create mode 100644 templates/about/roadmap.html.twig create mode 100644 templates/about/supremm.html.twig create mode 100644 templates/about/team.html.twig create mode 100644 templates/about/xdmod.html.twig rename html/about/release_notes/xdmod.html => templates/about/xdmod_release_notes.html.twig (100%) create mode 100644 templates/base.html.twig create mode 100644 templates/emails/new_user.html.twig create mode 100644 templates/emails/password_reset.html.twig create mode 100644 templates/index.html.twig create mode 100644 templates/internal_dashboard.html.twig create mode 100644 templates/internal_dashboard_login.html.twig create mode 100644 templates/password_reset.html.twig create mode 100644 templates/password_reset_expired.html.twig diff --git a/.circleci/config.yml b/.circleci/config.yml index efa6c98678..e5a5c100d2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,7 +27,7 @@ jobs: - setup_remote_docker - run: name: Docker Compose corresponding OS file - command: docker compose -f ~/project/tests/playwright/Docker/docker-compose.yml up -d + command: pushd ~/project/tests/playwright/Docker && docker compose up -d; popd - run: name: Generate Key for XDMoD command: docker exec xdmod openssl genrsa -out /etc/pki/tls/private/localhost.key -rand /proc/cpuinfo:/proc/filesystems:/proc/interrupts:/proc/ioports:/proc/uptime 2048 @@ -56,6 +56,13 @@ jobs: - run: name: Install XDMoD Composer Dependencies command: docker exec -w /root/xdmod xdmod composer install + - run: + name: Fixup php.ini for debugging + command: | + docker exec xdmod bash -c "sed -i 's|error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT|error_reporting = E_ALL|g' /etc/php.ini" + docker exec xdmod bash -c "sed -i 's|display_errors = Off|display_errors = On|g' /etc/php.ini" + docker exec xdmod bash -c "sed -i 's|display_startup_errors = Off|display_startup_errors = On|g' /etc/php.ini" + docker exec xdmod bash -c "sed -i 's|;error_log = php_errors.log|error_log = php_errors.log|g' /etc/php.ini" - run: name: Build XDMoD RPM command: docker exec -w /root/xdmod xdmod /root/bin/buildrpm xdmod @@ -78,7 +85,7 @@ jobs: command: docker exec -w /root/xdmod xdmod composer install - run: name: Setup the SimpleSAML server etc. so we can test SSO - command: docker exec xdmod /root/xdmod/tests/ci/samlSetup.sh + command: docker exec xdmod /root/xdmod/tests/ci/samlSetup.sh -t local -h xdmod - run: name: Make sure that the Test Dependencies are installed command: docker exec -w /root/xdmod xdmod composer install --no-progress diff --git a/.env b/.env new file mode 100644 index 0000000000..50e3ef8974 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +# Default ENV file +DATABASE_URL= +###> google/recaptcha ### +# To use Google Recaptcha, you must register a site on Recaptcha's admin panel: +# https://www.google.com/recaptcha/admin +GOOGLE_RECAPTCHA_SITE_KEY= +GOOGLE_RECAPTCHA_SECRET= +###< google/recaptcha ### diff --git a/bin/acl-config b/bin/acl-config index 644f832775..c7987f850d 100755 --- a/bin/acl-config +++ b/bin/acl-config @@ -1625,6 +1625,7 @@ SQL; $log->debug($query); $log->debug('', $params); + $log->debug('Params', $params); if ($dryRun) { $log->info($successMsg); diff --git a/bin/console b/bin/console new file mode 100755 index 0000000000..30269605c7 --- /dev/null +++ b/bin/console @@ -0,0 +1,17 @@ +#!/usr/bin/env php +_sources = \SimpleSAML_Auth_Source::getSources(); + $this->_sources = Source::getSources(); if ($this->isSamlConfigured()) { try { $authSource = \xd_utilities\getConfiguration('authentication', 'source'); @@ -97,7 +102,7 @@ public function isSamlConfigured() */ public function logout(){ if ($this->isSamlConfigured()) { - \SimpleSAML_Session::getSessionFromRequest()->doLogout($this->authSourceName); + Session::getSessionFromRequest()->doLogout($this->authSourceName); } } /** @@ -112,7 +117,7 @@ public function getXdmodAccount() /* * SimpleSAMLphp uses its own session, this sets it back. */ - \SimpleSAML_Session::getSessionFromRequest()->cleanup(); + Session::getSessionFromRequest()->cleanup(); if ($this->_as->isAuthenticated()) { $userName = $samlAttrs['username'][0]; @@ -205,7 +210,7 @@ public function getOrganizationId($samlAttrs, $personId) * * @param string $returnTo the URI to redirect to after auth. * - * @return the login URL or false if no provider is configured + * @return string|bool login URL or false if no provider is configured */ public function getLoginURL($returnTo) { @@ -226,8 +231,8 @@ public function getLoginLink() if (!$this->isSamlConfigured()) { return false; } - $idp = \SimpleSAML_Metadata_MetaDataStorageHandler::getMetadataHandler()->getMetadata( - \SimpleSAML_Auth_Source::getById($this->authSourceName)->getMetadata()->toArray()['idp'], + $idp = MetaDataStorageHandler::getMetadataHandler()->getMetaData( + Source::getById($this->authSourceName)->getMetadata()->toArray()['idp'], 'saml20-idp-remote' ); if (!empty($idp['OrganizationDisplayName'])) { diff --git a/classes/CCR/CCRDBHandler.php b/classes/CCR/CCRDBHandler.php index b56bbc5adf..88c2c366cf 100644 --- a/classes/CCR/CCRDBHandler.php +++ b/classes/CCR/CCRDBHandler.php @@ -5,6 +5,8 @@ use CCR\DB\iDatabase; use Exception; use Monolog\Handler\AbstractProcessingHandler; +use Monolog\Level; +use Monolog\LogRecord; /** * This class is meant to provide a means of writing log entries to a database within the Monolog framework. @@ -49,7 +51,7 @@ class CCRDBHandler extends AbstractProcessingHandler */ public function __construct(iDatabase $db = null, $schema = null, $table = null, $level = Log::DEBUG, $bubble = true) { - parent::__construct($level, $bubble); + parent::__construct(Level::fromValue(Log::convertToMonologLevel($level)), $bubble); if (!isset($db)) { $db = DB::factory('logger'); @@ -71,16 +73,23 @@ public function __construct(iDatabase $db = null, $schema = null, $table = null, /** * @see AbstractProcessingHandler::write() */ - protected function write(array $record) + protected function write(LogRecord $record): void { - $sql = sprintf("INSERT INTO %s.%s (id, logtime, ident, priority, message) VALUES(:id, NOW(), :ident, :priority, :message)", $this->schema, $this->table); + $message = array_merge( + [ + 'message' => $record->message + ], + $record->context + ); - $this->db->execute($sql, array( + $sql = sprintf("INSERT INTO %s.%s (id, logtime, ident, priority, message) VALUES(:id, NOW(), :ident, :priority, :message)", $this->schema, $this->table); + $params = [ ':id' => $this->getNextId(), ':ident' => $record['channel'], ':priority' => Log::convertToCCRLevel($record['level']), - ':message' => $record['formatted'] - )); + ':message' => json_encode($message, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ]; + $this->db->execute($sql, $params); } /** diff --git a/classes/CCR/CCRLineFormatter.php b/classes/CCR/CCRLineFormatter.php index 897f7d2941..ff9dcc162e 100644 --- a/classes/CCR/CCRLineFormatter.php +++ b/classes/CCR/CCRLineFormatter.php @@ -98,6 +98,11 @@ public function format(array $record) // remove leftover %extra.xxx% and %context.xxx% if any if (false !== strpos($output, '%')) { $output = preg_replace('/%(?:extra|context)\..+?%/', '', $output); + if (null === $output) { + $pcreErrorCode = preg_last_error(); + + throw new \RuntimeException('Failed to run preg_replace: ' . $pcreErrorCode . ' / ' . preg_last_error_msg()); + } } return $output; diff --git a/classes/CCR/Log.php b/classes/CCR/Log.php index 8f56dd3c28..d30a9d8065 100644 --- a/classes/CCR/Log.php +++ b/classes/CCR/Log.php @@ -8,7 +8,9 @@ use Monolog\Handler\NativeMailerHandler; use Monolog\Handler\NullHandler; use Monolog\Handler\StreamHandler; +use Monolog\Level; use Psr\Log\LoggerInterface; + use xd_utilities; /** @@ -32,25 +34,25 @@ class Log const DEBUG = 7; private static $logLevels = array( - self::EMERG => \Monolog\Logger::EMERGENCY, - self::ALERT => \Monolog\Logger::ALERT, - self::CRIT => \Monolog\Logger::CRITICAL, - self::ERR => \Monolog\Logger::ERROR, - self::WARNING => \Monolog\Logger::WARNING, - self::NOTICE => \Monolog\Logger::NOTICE, - self::INFO => \Monolog\Logger::INFO, - self::DEBUG => \Monolog\Logger::DEBUG + self::EMERG => \Monolog\Level::Emergency->value, + self::ALERT => \Monolog\Level::Alert->value, + self::CRIT => \Monolog\Level::Critical->value, + self::ERR => \Monolog\Level::Error->value, + self::WARNING => \Monolog\Level::Warning->value, + self::NOTICE => \Monolog\Level::Notice->value, + self::INFO => \Monolog\Level::Info->value, + self::DEBUG => \Monolog\Level::Debug->value ); private static $flippedLogLevels = array( - \Monolog\Logger::EMERGENCY => self::EMERG, - \Monolog\Logger::ALERT => self::ALERT, - \Monolog\Logger::CRITICAL => self::CRIT, - \Monolog\Logger::ERROR => self::ERR, - \Monolog\Logger::WARNING => self::WARNING, - \Monolog\Logger::NOTICE => self::NOTICE, - \Monolog\Logger::INFO => self::INFO, - \Monolog\Logger::DEBUG => self::DEBUG + \Monolog\Level::Emergency->value => self::EMERG, + \Monolog\Level::Alert->value => self::ALERT, + \Monolog\Level::Critical->value => self::CRIT, + \Monolog\Level::Error->value => self::ERR, + \Monolog\Level::Warning->value => self::WARNING, + \Monolog\Level::Notice->value => self::NOTICE, + \Monolog\Level::Info->value => self::INFO, + \Monolog\Level::Debug->value => self::DEBUG ); /** @@ -165,7 +167,7 @@ protected static function getLogger($ident, array $conf) 'mail' ); - $logger = new Logger($ident); + $logger = new \Monolog\Logger($ident); // Short circuit the function if 'null' was asked for since this will be the only handler for the logger. if ($ident === 'null') { @@ -341,7 +343,7 @@ public static function convertToCCRLevel($monologLevel) if (array_key_exists($monologLevel, self::$flippedLogLevels)) { return self::$flippedLogLevels[$monologLevel]; } - throw new Exception('Unknown Log Level'); + throw new Exception(sprintf('Unknown Monolog Log Level %s', $monologLevel)); } /** @@ -356,7 +358,7 @@ public static function convertToMonologLevel($ccrLevel) if (array_key_exists($ccrLevel, self::$logLevels)) { return self::$logLevels[$ccrLevel]; } - throw new Exception('Unknown Log Level'); + throw new Exception(sprintf('Unknown CCR Log Level %s', $ccrLevel)); } /** diff --git a/classes/CCR/Logger.php b/classes/CCR/Logger.php index a936141499..4d56db0d54 100644 --- a/classes/CCR/Logger.php +++ b/classes/CCR/Logger.php @@ -19,75 +19,4 @@ */ class Logger extends MLogger implements LoggerInterface { - /** - * @param $level - * @param $message - * @param array $context - * @return bool - * @throws \DateInvalidTimeZoneException - */ - public function addRecord($level, $message, array $context = array()) - { - if (!$this->handlers) { - $this->pushHandler(new StreamHandler('php://stderr', static::DEBUG)); - } - - $levelName = static::getLevelName($level); - - // check if any handler will handle this message so we can return early and save cycles - $handlerKey = null; - reset($this->handlers); - while ($handler = current($this->handlers)) { - if ($handler->isHandling(array('level' => $level))) { - $handlerKey = key($this->handlers); - break; - } - - next($this->handlers); - } - - if (null === $handlerKey) { - return false; - } - - if (!static::$timezone) { - static::$timezone = new \DateTimeZone(date_default_timezone_get() ?: 'UTC'); - } - - // php7.1+ always has microseconds enabled, so we do not need this hack - if ($this->microsecondTimestamps && PHP_VERSION_ID < 70100) { - $ts = \DateTime::createFromFormat('U.u', sprintf('%.6F', microtime(true)), static::$timezone); - } else { - $ts = new \DateTime('now', static::$timezone); - } - $ts->setTimezone(static::$timezone); - - $record = array( - 'message' => (string) $message, - 'context' => $context, - 'level' => $level, - 'level_name' => strtolower($levelName), - 'channel' => $this->name, - 'datetime' => $ts, - 'extra' => array('message' => $message), - ); - - try { - foreach ($this->processors as $processor) { - $record = call_user_func($processor, $record); - } - - while ($handler = current($this->handlers)) { - if (true === $handler->handle($record)) { - break; - } - - next($this->handlers); - } - } catch (Exception $e) { - $this->handleException($e, $record); - } - - return true; - } } diff --git a/classes/Configuration/Configuration.php b/classes/Configuration/Configuration.php index 9422da03f9..d551ca847c 100644 --- a/classes/Configuration/Configuration.php +++ b/classes/Configuration/Configuration.php @@ -1144,27 +1144,27 @@ protected function deleteSection($name) * ========================================================================================== */ - public function current() + public function current(): mixed { return current($this->sectionData); } - public function key() + public function key(): mixed { return key($this->sectionData); } - public function next() + public function next(): void { - return next($this->sectionData); + next($this->sectionData); } - public function rewind() + public function rewind(): void { - return reset($this->sectionData); + reset($this->sectionData); } - public function valid() + public function valid(): bool { return false !== current($this->sectionData); } diff --git a/classes/DB/ArrayIngestor.php b/classes/DB/ArrayIngestor.php index 50350c515a..214fe824e8 100644 --- a/classes/DB/ArrayIngestor.php +++ b/classes/DB/ArrayIngestor.php @@ -23,12 +23,12 @@ class ArrayIngestor implements Ingestor function __construct( iDatabase $dest_db, + $insert_table, array $source_data = array(), - $insert_table, array $insert_fields = array(), array $post_ingest_update_statements = array(), - $delete_statement = null, - $count_statement = null + $delete_statement = null, + $count_statement = null ) { $this->_dest_db = $dest_db; diff --git a/classes/DB/FilterListHelper.php b/classes/DB/FilterListHelper.php index a9ca288f78..4a6e46cebb 100644 --- a/classes/DB/FilterListHelper.php +++ b/classes/DB/FilterListHelper.php @@ -65,7 +65,7 @@ public static function getTableName(Query $realmQuery, GroupBy $groupBy1, GroupB $firstId = $groupBy2Id; $secondId = $groupBy1Id; } - $tableName .= "${firstId}___{$secondId}"; + $tableName .= "{$firstId}___{$secondId}"; } return $tableName; diff --git a/classes/DataWarehouse/Access/Usage.php b/classes/DataWarehouse/Access/Usage.php index 1c95a6c317..0d12dbdd8c 100644 --- a/classes/DataWarehouse/Access/Usage.php +++ b/classes/DataWarehouse/Access/Usage.php @@ -115,7 +115,7 @@ private function getSummaryCharts(XDUser $user) { $usageChart = array( 'hc_jsonstore' => array('title' => array('text' => '')), - 'id' => "node=statistic&realm=${usageRealm}&group_by=${usageGroupBy}&statistic=${userStatistic}", + 'id' => "node=statistic&realm={$usageRealm}&group_by={$usageGroupBy}&statistic={$userStatistic}", 'short_title' => $statsClass->getName(), 'random_id' => 'chart_' . mt_rand(), 'subnotes' => $usageSubnotes, @@ -468,7 +468,7 @@ public function getCharts(XDUser $user, $chartsKey = 'data') { $nextFieldNameIndex++; $timeseriesColumn = $timeseriesTemplateColumn; - $timeseriesColumn['header'] = "[${resultRecordDimension}] " . $timeseriesColumn['header']; + $timeseriesColumn['header'] = "[{$resultRecordDimension}] " . $timeseriesColumn['header']; $timeseriesColumn['dataIndex'] = $timeseriesDimensionColumnName; $timeseriesColumns[$resultRecordDimension] = $timeseriesColumn; @@ -616,7 +616,7 @@ public function getCharts(XDUser $user, $chartsKey = 'data') { $usageTitleFontSizeInPixels = 16 + $usageFontSize; $usageTitleStyle = array( 'color' => '#000000', - 'size' => "${usageTitleFontSizeInPixels}", + 'size' => "{$usageTitleFontSizeInPixels}", ); // Get the user's report generator chart pool. @@ -714,8 +714,8 @@ public function getCharts(XDUser $user, $chartsKey = 'data') { // Generate the expected IDs for the chart. $usageMetric = $meRequest['data_series_unencoded'][0]['metric']; - $usageChartId = "node=statistic&realm=${usageRealm}&group_by=${usageGroupBy}&statistic=${usageMetric}"; - $usageChartMenuId = "node=group_by&realm=${usageRealm}&group_by=${usageGroupBy}"; + $usageChartId = "node=statistic&realm={$usageRealm}&group_by={$usageGroupBy}&statistic={$usageMetric}"; + $usageChartMenuId = "node=group_by&realm={$usageRealm}&group_by={$usageGroupBy}"; // Remove extraneous x-axis properties. if ($meRequestIsTimeseries) { @@ -768,7 +768,7 @@ public function getCharts(XDUser $user, $chartsKey = 'data') { $currentCategoryRank = $usageOffset + 1; foreach ($meChartCategories as $meChartCategory) { if (!empty($meChartCategory)) { - $usageChartCategories[] = "${currentCategoryRank}. ${meChartCategory}"; + $usageChartCategories[] = "{$currentCategoryRank}. {$meChartCategory}"; } else { $usageChartCategories[] = ''; @@ -847,7 +847,7 @@ function ($drillTarget) { && $usageGroupBy !== 'none' ) { $rank = $meDataSeries['legendrank'] / 3; - $meDataSeries['name'] = "${rank}. " . $meDataSeries['name']; + $meDataSeries['name'] = "{$rank}. " . $meDataSeries['name']; } } @@ -1166,7 +1166,7 @@ private function convertChartRequest(array $usageRequest, $useGivenFormat) { $unencodedMeRequestParams[$meRequestKey] = $meRequestValue; } foreach ($unencodedMeRequestParams as $meRequestKey => $meRequestValue) { - $meRequest["${meRequestKey}_unencoded"] = $meRequestValue; + $meRequest["{$meRequestKey}_unencoded"] = $meRequestValue; $meRequest[$meRequestKey] = urlencode(json_encode($meRequestValue)); } diff --git a/classes/DataWarehouse/Data/BatchDataset.php b/classes/DataWarehouse/Data/BatchDataset.php index 94bc0df45d..62b6a6ce67 100644 --- a/classes/DataWarehouse/Data/BatchDataset.php +++ b/classes/DataWarehouse/Data/BatchDataset.php @@ -173,7 +173,7 @@ function ($field) { * * @return mixed[] */ - public function current() + public function current(): mixed { return $this->currentRow; } @@ -183,7 +183,7 @@ public function current() * * @return int */ - public function key() + public function key(): mixed { return $this->currentRowIndex; } @@ -193,7 +193,7 @@ public function key() * * Fetches the next row. */ - public function next() + public function next(): void { $this->currentRowIndex++; $this->currentRow = $this->getNextRow(); @@ -204,7 +204,7 @@ public function next() * * Executes the underlying raw query. */ - public function rewind() + public function rewind(): void { $this->originalBufferedQuerySetting = $this->dbh->handle()->getAttribute( PDO::MYSQL_ATTR_USE_BUFFERED_QUERY @@ -225,7 +225,7 @@ public function rewind() * * @return bool */ - public function valid() + public function valid(): bool { return $this->currentRow !== false; } diff --git a/classes/DataWarehouse/Data/TimeseriesDataset.php b/classes/DataWarehouse/Data/TimeseriesDataset.php index b507a0ff73..170e6b8832 100644 --- a/classes/DataWarehouse/Data/TimeseriesDataset.php +++ b/classes/DataWarehouse/Data/TimeseriesDataset.php @@ -72,7 +72,7 @@ protected function getSeriesIds($limit, $offset) $seriesIds = array(); while($row = $statement->fetch(\PDO::FETCH_ASSOC, \PDO::FETCH_ORI_NEXT)) { - $seriesIds[] = "${row[$groupIdColumn]}"; + $seriesIds[] = "{$row[$groupIdColumn]}"; } return $seriesIds; @@ -205,7 +205,7 @@ public function getDatasets($limit, $offset, $summarize) * @param integer $normalizeBy The total number of series to be summarized. * @return array the sql fragment, series name and summariation algorthm type. */ - protected function getSummaryOp($column_name, $normalizeBy) + protected function getSummaryOp(string $column_name, $normalizeBy) { $series_name = "All $normalizeBy Others"; $sql = "SUM(t.$column_name)"; diff --git a/classes/DataWarehouse/Export/RealmManager.php b/classes/DataWarehouse/Export/RealmManager.php index 29a9c0c220..1e044f671d 100644 --- a/classes/DataWarehouse/Export/RealmManager.php +++ b/classes/DataWarehouse/Export/RealmManager.php @@ -50,7 +50,12 @@ function ($realm) use ($exportable) { // Use array_values to remove gaps in keys that may have been // introduced by the use of array_filter. - return array_values($realms); + $values = array_values($realms); + + // We force sorting in descending order due to the differences in sorting from PHP7.2 to PHP8.0 + usort($values, fn($left, $right) => strcmp($left->getName(), $right->getName()) * -1); + + return $values; } /** diff --git a/classes/DataWarehouse/ExportBuilder.php b/classes/DataWarehouse/ExportBuilder.php index b3b97bc7d3..ff21c45412 100644 --- a/classes/DataWarehouse/ExportBuilder.php +++ b/classes/DataWarehouse/ExportBuilder.php @@ -262,6 +262,31 @@ public static function getFormat( return $format; } + /** + * Validates that the format requested by the user is located in the set of formats that are supported and either + * all formats are allowed ( signified by there being no $allowedFormats ) or the requested format was found in the + * set of allowed formats. If valid the requested format is returned. If no requested format is provided then the + * default value will be returned. + * + * @param string $requestedFormat + * @param string $default + * @param array $allowedFormats + * @return string + */ + public static function validateFormat(string $requestedFormat, string $default = 'jsonstore', array $allowedFormats = []): string + { + if (!isset($requestedFormat)) { + return $default; + } + $requestedFormat = strtolower($requestedFormat); + $formatSupported = isset(self::$supported_formats[$requestedFormat]); + $noFormatSubset = count($allowedFormats) === 0; + $requestedFormatInSubset = in_array($requestedFormat, $allowedFormats); + + + return $formatSupported && ($noFormatSubset || $requestedFormatInSubset) ? $requestedFormat : $default; + } + /** * Export data. * diff --git a/classes/DataWarehouse/Query/TimeAggregationUnit.php b/classes/DataWarehouse/Query/TimeAggregationUnit.php index f373088e2d..c273a7a472 100644 --- a/classes/DataWarehouse/Query/TimeAggregationUnit.php +++ b/classes/DataWarehouse/Query/TimeAggregationUnit.php @@ -219,6 +219,11 @@ public static function getRegsiteredAggregationUnits() */ public static function deriveAggregationUnitName($time_period, $start_date, $end_date, $min_aggregation_unit = null) { + // This has been added because `strtolower` no longer supports null values. + if (empty($time_period)) { + $time_period = 'auto'; + } + $time_period = strtolower($time_period); if ($time_period === 'auto') { @@ -264,6 +269,12 @@ public static function deriveAggregationUnitName($time_period, $start_date, $end */ public static function getMaxUnit($unit_1, $unit_2) { + if (is_null($unit_1)) { + $unit_1 = 'null'; + } + if (is_null($unit_2)) { + $unit_2 = 'null'; + } // Convert input units to the expected unit name format. $unit_1_name = strtolower($unit_1); $unit_2_name = strtolower($unit_2); diff --git a/classes/DataWarehouse/Visualization.php b/classes/DataWarehouse/Visualization.php index 61d511f874..e3cdf5ae2c 100644 --- a/classes/DataWarehouse/Visualization.php +++ b/classes/DataWarehouse/Visualization.php @@ -23,7 +23,7 @@ public static function alterBrightness($color, $steps) return ($a << 24) + ($r << 16) + ($g << 8) + $b; } //http://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/ - public static function getColors($count = NULL, $palleteIndex = 0, $includeWhite = true) + public static function getColors($count = null, $palleteIndex = 0, $includeWhite = true) { $ret = array(); $colors = json_decode(COLORS); @@ -39,7 +39,11 @@ public static function getColors($count = NULL, $palleteIndex = 0, $includeWhite } } $ret_count = count($ret); - srand($count); + if ($count === null) { + srand(); + } else { + srand($count); + } if ($count != NULL && $ret_count < $count) { $value = 15; diff --git a/classes/DataWarehouse/Visualization/AggregateChart.php b/classes/DataWarehouse/Visualization/AggregateChart.php index 15215ea334..6b3ac30d4c 100644 --- a/classes/DataWarehouse/Visualization/AggregateChart.php +++ b/classes/DataWarehouse/Visualization/AggregateChart.php @@ -1017,6 +1017,9 @@ public function configure( $labelsAllocated = 0; $pieSum = array_sum($yValues); for ($i = 0; $i < count($xValues); $i++) { + if (is_null($yValues[$i])) { + $yValues[$i] = 0.0; + } if ($isThumbnail || ($labelsAllocated < $labelLimit && (($yValues[$i] / $pieSum) * 100) >= 2.0)) { $label = $xValues[$i]; // Truncate long data labels to improve visibility. diff --git a/classes/DataWarehouse/Visualization/TimeseriesChart.php b/classes/DataWarehouse/Visualization/TimeseriesChart.php index 67a7b982c0..f241524e2a 100644 --- a/classes/DataWarehouse/Visualization/TimeseriesChart.php +++ b/classes/DataWarehouse/Visualization/TimeseriesChart.php @@ -508,7 +508,14 @@ public function configure( $xValues[] = $start_ts_array[$i]*1000; $dates[] = $start_ts_array[$i]*1000; $yValues[] = $v; - $text[] = number_format($v, $decimals, '.', ','); + + // This bit has been added due to `number_format` no longer supporting passing nulls. + if (is_null($v)) { + $formatted = number_format(0.0, $decimals, '.', ','); + } else { + $formatted = number_format($v, $decimals, '.', ','); + } + $text[] = $formatted; $seriesValue = array( 'x' => $start_ts_array[$i]*1000, 'y' => $v, diff --git a/classes/ETL/Aggregator/JobsAggregator.php b/classes/ETL/Aggregator/JobsAggregator.php index c6357173af..88dd6b40c7 100644 --- a/classes/ETL/Aggregator/JobsAggregator.php +++ b/classes/ETL/Aggregator/JobsAggregator.php @@ -252,12 +252,12 @@ protected function getDirtyAggregationPeriods($aggregationUnit) if ( null !== $this->currentStartDate ) { $startDate = $this->sourceHandle->quote($this->currentStartDate); - $ranges[] = "d.${aggregationUnit}_end_ts >= UNIX_TIMESTAMP($startDate)"; + $ranges[] = "d.{$aggregationUnit}_end_ts >= UNIX_TIMESTAMP($startDate)"; } if ( null !== $this->currentEndDate ) { $endDate = $this->sourceHandle->quote($this->currentEndDate); - $ranges[] = "d.${aggregationUnit}_start_ts <= UNIX_TIMESTAMP($endDate)"; + $ranges[] = "d.{$aggregationUnit}_start_ts <= UNIX_TIMESTAMP($endDate)"; } $dateRangeSql = implode(" AND ", $ranges); @@ -306,7 +306,7 @@ protected function getDirtyAggregationPeriods($aggregationUnit) * -------------------------------------------------------------------------------- */ - $whereClauses = array("aggregated_${aggregationUnit} = 0"); + $whereClauses = array("aggregated_{$aggregationUnit} = 0"); if ( null !== $this->resourceIdListString ) { $whereClauses[] = "resource_id IN (" . $this->resourceIdListString . ")"; } @@ -317,8 +317,8 @@ protected function getDirtyAggregationPeriods($aggregationUnit) $minMaxJoin = "(\n $minMaxSql\n) js_limits"; - $dateRangeSql = "d.${aggregationUnit}_end_ts >= js_limits.min_start " . - "AND d.${aggregationUnit}_start_ts <= js_limits.max_end"; + $dateRangeSql = "d.{$aggregationUnit}_end_ts >= js_limits.min_start " . + "AND d.{$aggregationUnit}_start_ts <= js_limits.max_end"; } // else ( $this->getEtlOverseerOptions()->isForce() ) @@ -331,16 +331,16 @@ protected function getDirtyAggregationPeriods($aggregationUnit) "SELECT distinct d.id as period_id, d.`year` as year_value, - d.`${aggregationUnit}` as period_value, - d.${aggregationUnit}_start as period_start, - d.${aggregationUnit}_end as period_end, - d.${aggregationUnit}_start_ts as period_start_ts, - d.${aggregationUnit}_end_ts as period_end_ts, + d.`{$aggregationUnit}` as period_value, + d.{$aggregationUnit}_start as period_start, + d.{$aggregationUnit}_end as period_end, + d.{$aggregationUnit}_start_ts as period_start_ts, + d.{$aggregationUnit}_end_ts as period_end_ts, d.hours as period_hours, d.seconds as period_seconds, 0 as period_start_day_id, 0 as period_end_day_id - FROM {$utilitySchema}.${aggregationUnit}s d" . (null !== $minMaxJoin ? ",\n$minMaxJoin" : "" ) . " + FROM {$utilitySchema}.{$aggregationUnit}s d" . (null !== $minMaxJoin ? ",\n$minMaxJoin" : "" ) . " WHERE $dateRangeSql ORDER BY 2 DESC, 3 DESC"; @@ -391,7 +391,7 @@ protected function checkResourceSpecs() from {$sourceSchema}.jobfact where start_time_ts between unix_timestamp(:startDate) and unix_timestamp(:endDate) - and resource_id not in (select distinct resource_id from ${utilitySchema}.resourcespecs where processors is not null)" . + and resource_id not in (select distinct resource_id from {$utilitySchema}.resourcespecs where processors is not null)" . ( null !== $this->resourceIdListString ? " and resource_id IN (" . $this->resourceIdListString . ")" : ""); $params = array( diff --git a/classes/ETL/Aggregator/pdoAggregator.php b/classes/ETL/Aggregator/pdoAggregator.php index bfba88f09a..db2113d718 100644 --- a/classes/ETL/Aggregator/pdoAggregator.php +++ b/classes/ETL/Aggregator/pdoAggregator.php @@ -603,12 +603,12 @@ protected function getDirtyAggregationPeriods($aggregationUnit) if ( null !== $this->currentStartDate ) { $startDate = $this->sourceHandle->quote($this->currentStartDate); - $ranges[] = "$startDate <= d.${aggregationUnit}_end"; + $ranges[] = "$startDate <= d.{$aggregationUnit}_end"; } if ( null !== $this->currentEndDate ) { $endDate = $this->sourceHandle->quote($this->currentEndDate); - $ranges[] = "$endDate >= d.${aggregationUnit}_start"; + $ranges[] = "$endDate >= d.{$aggregationUnit}_start"; } if ( 0 != count($ranges) ) { @@ -667,16 +667,16 @@ protected function getDirtyAggregationPeriods($aggregationUnit) "SELECT distinct d.id as period_id, d.`year` as year_value, - d.`${aggregationUnit}` as period_value, - d.${aggregationUnit}_start as period_start, - d.${aggregationUnit}_end as period_end, - d.${aggregationUnit}_start_ts as period_start_ts, - d.${aggregationUnit}_end_ts as period_end_ts, + d.`{$aggregationUnit}` as period_value, + d.{$aggregationUnit}_start as period_start, + d.{$aggregationUnit}_end as period_end, + d.{$aggregationUnit}_start_ts as period_start_ts, + d.{$aggregationUnit}_end_ts as period_end_ts, d.hours as period_hours, d.seconds as period_seconds, $unitIdToStartDayId as period_start_day_id, $unitIdToEndDayId as period_end_day_id - FROM {$utilitySchema}.${aggregationUnit}s d" + FROM {$utilitySchema}.{$aggregationUnit}s d" . (null !== $minMaxJoin ? ",\n$minMaxJoin" : "" ) . (null !== $dateRangeRestrictionSql ? "\nWHERE $dateRangeRestrictionSql" : "" ) . " ORDER BY 2 DESC, 3 DESC"; @@ -883,7 +883,7 @@ protected function _execute($aggregationUnit) // // NOTE: The ETL date range is supported when querying for dirty aggregation periods - $this->logger->info("Aggregate over $numAggregationPeriods ${aggregationUnit}s"); + $this->logger->info("Aggregate over $numAggregationPeriods {$aggregationUnit}s"); if ( ! $enableBatchAggregation ) { diff --git a/classes/ETL/Configuration/EtlConfiguration.php b/classes/ETL/Configuration/EtlConfiguration.php index b909affb21..c82604e8c3 100644 --- a/classes/ETL/Configuration/EtlConfiguration.php +++ b/classes/ETL/Configuration/EtlConfiguration.php @@ -596,27 +596,27 @@ protected function addBaseDirToPaths() * ========================================================================================== */ - public function current() + public function current(): mixed { return current($this->actionOptions); } // current() - public function key() + public function key(): mixed { return key($this->actionOptions); } // key() - public function next() + public function next(): void { - return next($this->actionOptions); + next($this->actionOptions); } // next() - public function rewind() + public function rewind(): void { - return reset($this->actionOptions); + reset($this->actionOptions); } // rewind() - public function valid() + public function valid(): bool { return false !== current($this->actionOptions); } // valid() diff --git a/classes/ETL/DataEndpoint/DirectoryScanner.php b/classes/ETL/DataEndpoint/DirectoryScanner.php index 779342e0a3..b42f62211f 100644 --- a/classes/ETL/DataEndpoint/DirectoryScanner.php +++ b/classes/ETL/DataEndpoint/DirectoryScanner.php @@ -914,7 +914,7 @@ public function verify($dryrun = false, $leaveConnected = false) * @see current() */ - public function current() + public function current(): mixed { if ( null === $this->currentFileIterator ) { return false; @@ -931,7 +931,7 @@ public function current() * @see key() */ - public function key() + public function key(): mixed { if ( null === $this->currentFileIterator ) { return null; @@ -947,7 +947,7 @@ public function key() * @see Iterator::next() */ - public function next() + public function next(): void { if ( null !== $this->currentFileIterator ) { $this->currentFileIterator->next(); @@ -963,7 +963,7 @@ public function next() * @see Iterator::rewind() */ - public function rewind() + public function rewind(): void { $this->handle->rewind(); $this->numFilesScanned = 0; @@ -1004,7 +1004,7 @@ public function rewind() * @see Iterator::valid() */ - public function valid() + public function valid(): bool { // Ensure the handle is valid since there may be no files matching the specified criteria or // we could be at the end of the file list. @@ -1062,7 +1062,7 @@ public function valid() * @see Countable::count() */ - public function count() + public function count(): int { return $this->numRecordsParsed; } diff --git a/classes/ETL/DataEndpoint/Filter/ExternalProcess.php b/classes/ETL/DataEndpoint/Filter/ExternalProcess.php index c9be2ebc24..fa88fc5e69 100644 --- a/classes/ETL/DataEndpoint/Filter/ExternalProcess.php +++ b/classes/ETL/DataEndpoint/Filter/ExternalProcess.php @@ -36,7 +36,7 @@ class ExternalProcess extends \php_user_filter * @var string The name of the filter, populated by PHP */ - public $filtername = null; + public string $filtername = ''; /** * @var object The parameters passed to this filter by stream_filter_prepend() or @@ -49,7 +49,7 @@ class ExternalProcess extends \php_user_filter * logger: Optional logger for displying error messages */ - public $params = null; + public mixed $params; /** * @var array An array containing file descriptors connected to the application. The following @@ -98,7 +98,7 @@ class ExternalProcess extends \php_user_filter * @return PSFS_ERR_FATAL On error. */ - public function filter($in, $out, &$consumed, $closing) + public function filter($in, $out, &$consumed, $closing): int { $retval = PSFS_FEED_ME; @@ -146,7 +146,7 @@ public function filter($in, $out, &$consumed, $closing) * application and opening read and write pipes to the application. */ - public function onCreate() + public function onCreate(): bool { // Verify parameters @@ -219,7 +219,7 @@ public function onCreate() * Cleanup after the filter is closed. */ - public function onClose() + public function onClose(): void { if ($this->pipes[0]) { fclose($this->pipes[0]); diff --git a/classes/ETL/DataEndpoint/aStructuredFile.php b/classes/ETL/DataEndpoint/aStructuredFile.php index f9a4bb3368..0f84567a56 100644 --- a/classes/ETL/DataEndpoint/aStructuredFile.php +++ b/classes/ETL/DataEndpoint/aStructuredFile.php @@ -490,7 +490,7 @@ public function supportsComplexDataRecords() * @see Iterator::current() */ - public function current() + public function current(): mixed { if ( ! $this->valid() ) { return false; @@ -508,7 +508,7 @@ public function current() * @see Iterator::key() */ - public function key() + public function key(): mixed { return key($this->recordList); } @@ -517,7 +517,7 @@ public function key() * @see Iterator::next() */ - public function next() + public function next(): void { next($this->recordList); } @@ -526,7 +526,7 @@ public function next() * @see Iterator::rewind() */ - public function rewind() + public function rewind(): void { reset($this->recordList); } @@ -535,7 +535,7 @@ public function rewind() * @see Iterator::valid() */ - public function valid() + public function valid(): bool { // return isset($this->recordList[$this->recordListPosition]); // Note that we can't check for values that are FALSE because that is a valid @@ -547,7 +547,7 @@ public function valid() * @see Countable::count() */ - public function count() + public function count(): int { return count($this->recordList); } diff --git a/classes/ETL/DbModel/Column.php b/classes/ETL/DbModel/Column.php index 7d44748bb4..fbb548ec99 100644 --- a/classes/ETL/DbModel/Column.php +++ b/classes/ETL/DbModel/Column.php @@ -201,10 +201,18 @@ public function compare(iEntity $cmp) if ( ( - (null === $srcDefault && null === $srcExtra) - || ('current_timestamp' === strtolower($srcDefault) && 'on update current_timestamp' === strtolower($srcExtra)) + ( + null === $srcDefault && + null === $srcExtra + ) + || + ( + !is_null($srcDefault) && !is_null($srcExtra) && + 'current_timestamp' === strtolower($srcDefault) && + 'on update current_timestamp' === strtolower($srcExtra) + ) ) - && ('current_timestamp' != strtolower($destDefault) || null === $destExtra) + && ((!is_null($destDefault) && 'current_timestamp' != strtolower($destDefault)) || null === $destExtra) ) { $this->logCompareFailure('timestamp', "$srcDefault $srcExtra", "$destDefault $destExtra", $this->name); return -1; diff --git a/classes/ETL/DbModel/Entity.php b/classes/ETL/DbModel/Entity.php index dc2212cd08..a7d57dda48 100644 --- a/classes/ETL/DbModel/Entity.php +++ b/classes/ETL/DbModel/Entity.php @@ -82,9 +82,12 @@ class Entity extends Loggable * ------------------------------------------------------------------------------------------ */ - public function __construct($config, $systemQuoteChar = null, LoggerInterface $logger = null) + public function __construct($config, $systemQuoteChar = '`', LoggerInterface $logger = null) { parent::__construct($logger); + if ($systemQuoteChar === null) { + $systemQuoteChar = ''; + } $this->setSystemQuoteChar($systemQuoteChar); // The configuration can be NULL (nothing is initialized), a string assumed to be diff --git a/classes/ETL/Ingestor/RestIngestor.php b/classes/ETL/Ingestor/RestIngestor.php index 367f2ff9eb..9543d912d9 100644 --- a/classes/ETL/Ingestor/RestIngestor.php +++ b/classes/ETL/Ingestor/RestIngestor.php @@ -342,7 +342,7 @@ function ($value) { while ( false !== ( $retval = curl_exec($this->sourceHandle) ) ) { if ( 0 !== curl_errno($this->sourceHandle) ) { - $this->logger->error("${this} Error during REST call: " . curl_error($this->sourceHandle)); + $this->logger->error("{$this} Error during REST call: " . curl_error($this->sourceHandle)); break; } diff --git a/classes/ETL/aOptions.php b/classes/ETL/aOptions.php index f50543bb7d..4822981b8b 100644 --- a/classes/ETL/aOptions.php +++ b/classes/ETL/aOptions.php @@ -268,7 +268,7 @@ public function __isset($property) * ------------------------------------------------------------------------------------------ */ - public function current() + public function current(): mixed { if ( ! $this->valid() ) { return false; @@ -281,7 +281,7 @@ public function current() * ------------------------------------------------------------------------------------------ */ - public function key() + public function key(): mixed { return key($this->options); } // key() @@ -291,7 +291,7 @@ public function key() * ------------------------------------------------------------------------------------------ */ - public function next() + public function next(): void { next($this->options); } // next() @@ -301,7 +301,7 @@ public function next() * ------------------------------------------------------------------------------------------ */ - public function rewind() + public function rewind(): void { reset($this->options); } // rewind() @@ -311,7 +311,7 @@ public function rewind() * ------------------------------------------------------------------------------------------ */ - public function valid() + public function valid(): bool { // Note that we can't check for values that are FALSE because that is a valid // data value. diff --git a/classes/Models/DBObject.php b/classes/Models/DBObject.php index a515089a7d..65dc4c5cb2 100644 --- a/classes/Models/DBObject.php +++ b/classes/Models/DBObject.php @@ -27,6 +27,7 @@ * * @author Ryan Rathsam */ +#[\AllowDynamicProperties] class DBObject { diff --git a/classes/Models/Services/Tokens.php b/classes/Models/Services/Tokens.php index 290d456680..b3c2d97e83 100644 --- a/classes/Models/Services/Tokens.php +++ b/classes/Models/Services/Tokens.php @@ -96,7 +96,7 @@ public static function authenticateController() * @throws \Exception if unable to retrieve a database connection. * @throws UnauthorizedHttpException if the token is missing, malformed, invalid, or expired. */ - private static function authenticateToken($rawToken, $endpoint = null) + private static function authenticateToken(string $rawToken, string $endpoint = null) { // Determine token type $tokenParts = explode('.', $rawToken); @@ -227,7 +227,7 @@ private static function authenticateJSONWebToken($jwt) * @param string $header * @return string | null the token if the header has the 'Bearer' key, null otherwise. */ - public static function getTokenFromHeader($header) + public static function getTokenFromHeader(string $header) { if (0 !== strpos($header, 'Bearer ')) { return null; diff --git a/classes/OpenXdmod/Build/Packager.php b/classes/OpenXdmod/Build/Packager.php index f758e900c4..f86b2e9626 100644 --- a/classes/OpenXdmod/Build/Packager.php +++ b/classes/OpenXdmod/Build/Packager.php @@ -296,10 +296,28 @@ public function createPackage() $this->copyModuleFiles(); $this->createModuleFile(); $this->createInstallScript(); + $this->addEnvFile(); $this->createTarFile(); $this->cleanUp(); } + /** + * Since we're using Symfony we need a .env file now. This function copies it into place. + * + * @return void + * @throws Exception + */ + private function addEnvFile() + { + $fileName = '.env'; + $srcFile = implode(DIRECTORY_SEPARATOR, array($this->srcDir, $fileName)); + $destFile = implode(DIRECTORY_SEPARATOR, array($this->getPackageDir(),$fileName)); + + $this->logger->info(sprintf('Copying %s to %s', $srcFile, $destFile)); + + $this->copyFile($srcFile, $destFile); + } + /** * Create a clone of the source repository. * diff --git a/classes/Realm/Realm.php b/classes/Realm/Realm.php index 40d9001887..aba378d976 100644 --- a/classes/Realm/Realm.php +++ b/classes/Realm/Realm.php @@ -366,7 +366,7 @@ private static function getSortedObjectList( // Skip disabled configs - if ( isset($config->disabled) && $config->disabled ) { + if (isset($config->disabled) && $config->disabled) { continue; } @@ -374,29 +374,29 @@ private static function getSortedObjectList( // use late static binding. For other classes use the class name specified unless the // configuration explicitly provides a class name. - $factoryClassName = ('Realm' == $className ? 'static' : $className); - if ( 'Realm' != $className && isset($configObj->class) ) { - if ( ! class_exists($configObj->class) ) { + $factoryClassName = ('Realm' == $className ? Realm::class : $className); + if ('Realm' != $className && isset($configObj->class)) { + if (!class_exists($configObj->class)) { $msg = sprintf("Attempt to instantiate undefined %s class %s", $className, $configObj->class); - if ( null !== $logger ) { + if (null !== $logger) { $logger->error($msg); } throw new \Exception($msg); } $factoryClassName = $configObj->class; - } elseif ( false === strpos($factoryClassName, '\\') && 'static' != $factoryClassName ) { + } elseif (false === strpos($factoryClassName, '\\') && 'static' != $factoryClassName) { $factoryClassName = sprintf('\\%s\\%s', __NAMESPACE__, $factoryClassName); } - $factory = sprintf('%s::factory', $factoryClassName); - - if ( 'Realm' == $className ) { + $factoryCallable = [$factoryClassName, 'factory']; + if ('Realm' == $className) { // The Realm class already has the configuration and does not need it to be passed // to factory(). - $list[$shortName] = forward_static_call($factory, $shortName, $logger); + $list[$shortName] = forward_static_call($factoryCallable, $shortName, null, null, $logger); } else { + // Entities encapsulated by the realm need their config objects - $list[$shortName] = forward_static_call($factory, $shortName, $config, $realmObj, $logger); + $list[$shortName] = forward_static_call($factoryCallable, $shortName, $config, $realmObj, $logger); } } diff --git a/classes/Rest/Controllers/AdminControllerProvider.php b/classes/Rest/Controllers/AdminControllerProvider.php deleted file mode 100644 index f0f562b0c6..0000000000 --- a/classes/Rest/Controllers/AdminControllerProvider.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -class AdminControllerProvider extends BaseControllerProvider -{ - public function setupRoutes(Application $app, ControllerCollection $controller) - { - $root = $this->prefix; - $class = get_class($this); - - $controller->post("$root/reset_user_tour_viewed", "$class::resetUserTourViewed"); - } - - /** - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Exception - */ - public function resetUserTourViewed(Request $request, Application $app) - { - $this->authorize($request, array('mgr')); - $viewedTour = $this->getIntParam($request, 'viewedTour', true); - $selected_user = XDUser::getUserByID($this->getIntParam($request, 'uid', true)); - - if ($selected_user === null) { - throw new BadRequestHttpException('User not found'); - } - - if (!in_array($viewedTour, [0,1])) { - throw new BadRequestHttpException('Invalid data parameter'); - } - - $storage = new \UserStorage($selected_user, 'viewed_user_tour'); - $storage->upsert(0, ['viewedTour' => $viewedTour]); - - return $app->json( - array( - 'success' => true, - 'total' => 1, - 'message' => 'This user will be now be prompted to view the New User Tour the next time they visit XDMoD' - ) - ); - } -} diff --git a/classes/Rest/Controllers/AuthenticationControllerProvider.php b/classes/Rest/Controllers/AuthenticationControllerProvider.php deleted file mode 100644 index 99db7693dc..0000000000 --- a/classes/Rest/Controllers/AuthenticationControllerProvider.php +++ /dev/null @@ -1,155 +0,0 @@ - - */ -class AuthenticationControllerProvider extends BaseControllerProvider -{ - - /** - * AuthenticationControllerProvider constructor. - * - * @param array $params - * - * @throws \Exception if there is a problem retrieving email addresses from configuration files. - */ - public function __construct(array $params = array()) - { - parent::__construct($params); - } - - - /** - * @see aBaseControllerProvider::setupRoutes - */ - public function setupRoutes(Application $app, \Silex\ControllerCollection $controller) - { - $root = $this->prefix; - $controller->post("$root/login", '\Rest\Controllers\AuthenticationControllerProvider::login'); - $controller->post("$root/logout", '\Rest\Controllers\AuthenticationControllerProvider::logout'); - $controller->get("$root/idpredirect", '\Rest\Controllers\AuthenticationControllerProvider::getIdpRedirect'); - $controller->get("$root/jwt-redirect", '\Rest\Controllers\AuthenticationControllerProvider::redirectWithJwt'); - } - - /** - * Provide the user with an authentication token. - * - * The authentication check has already occurred in middleware when this - * function is called, so it does not perform any authentication work. - * - * @param Request $request that will be used to retrieve the user - * @param Application $app used to facilitate json encoding the response. - * @return \Symfony\Component\HttpFoundation\JsonResponse which contains a - * token and the users full name if the login - * attempt is successful. - * @throws \Exception if the user could not be found or if their account - * is disabled. - */ - public function login(Request $request, Application $app) - { - $user = $this->authorize($request); - - $user->postLogin(); - - return $app->json(array( - 'success' => true, - 'results' => array('token' => $user->getSessionToken(), 'name' => $user->getFormalName()) - )); - } - - /** - * Attempt to log out the user identified by the provided token. - * - * @param Request $request that will be used to retrieve the token. - * @param Application $app that will be used to facilitate the json - * encoding of the response. - * @return \Symfony\Component\HttpFoundation\JsonResponse indicating - * that the user has been successfully logged - * out. - */ - public function logout(Request $request, Application $app) - { - $authInfo = Authentication::getAuthenticationInfo($request); - \XDSessionManager::logoutUser($authInfo['token']); - - return $app->json(array( - 'success' => true, - 'message' => 'User logged out successfully' - )); - } - - /** - * Return an IDP redirect URL for SSO login - */ - public function getIdpRedirect(Request $request, Application $app) - { - $auth = new \Authentication\SAML\XDSamlAuthentication(); - - $redirectUrl = $auth->getLoginURL($this->getStringParam($request, 'returnTo', true)); - - if ($redirectUrl === false ) { - throw new \Exception('SSO not configured.'); - } - - return $app->json($redirectUrl); - } - - /** - * If a JupyterHub is configured, redirect to it with a new JSON Web Token in a cookie. - * - * @param Request $request - * @param Application $app - * @return RedirectResponse to the configured JupyterHub root if the user is - * authenticated, otherwise to the sign-in - * screen. - * @throws HttpException if a JupyterHub is not configured. - */ - public function redirectWithJwt(Request $request, Application $app) - { - try { - $jupyterhub_url = xd_utilities\getConfiguration('jupyterhub', 'url'); - } catch (Exception $e) { - throw new HttpException(501, 'JupyterHub not configured.'); - } - try { - $user = $this->authorize($request); - } catch (UnauthorizedHttpException $e) { - return new RedirectResponse('/#jwt-redirect'); - } - list($jwt, $expiration) = JsonWebToken::encode($user->getUsername()); - $cookie = new Cookie( - 'xdmod_jwt', - $jwt, - $expiration, - '/', // path - null, // domain - true, // secure - true // httpOnly - ); - $response = new RedirectResponse($jupyterhub_url); - $response->headers->setCookie($cookie); - return $response; - } -} diff --git a/classes/Rest/Controllers/BaseControllerProvider.php b/classes/Rest/Controllers/BaseControllerProvider.php index 338cf5837a..e69de29bb2 100644 --- a/classes/Rest/Controllers/BaseControllerProvider.php +++ b/classes/Rest/Controllers/BaseControllerProvider.php @@ -1,790 +0,0 @@ - - */ -abstract class BaseControllerProvider implements ControllerProviderInterface -{ - - const _USER = '_request_user'; - const _REQUIREMENTS = 'requirements'; - const _URL_GENERATOR = 'url_generator'; - - const KEY_PREFIX = 'prefix'; - - const EXCEPTION_MESSAGE = 'An error was encountered while attempting to process the requested authorization procedure.'; - - protected $prefix; - - /** - * BaseControllerProvider constructor. - * @param array $params - */ - public function __construct(array $params = array()) - { - if (isset($params[self::KEY_PREFIX])) { - $this->prefix = $params[self::KEY_PREFIX]; - } - } - - - /** - * This function is called when the ControllerProvider is 'mount'ed. - * It is also the main entry point for a ControllerProvider and is - * where the 'setupXXX' functions are called from. All of these methods - * default to a no-op except for 'setupRoutes' which must be implemented - * by all child classes. As this is what is at the heart of a - * ControllerProviders' functionality. - * - * @param Application $app - * @return mixed an instance of the controller collection for this application. - */ - public function connect(Application $app) - { - $controller = $app['controllers_factory']; - - $this->setupDefaultValues($app, $controller); - $this->setupConversions($app, $controller); - $this->setupMiddleware($app, $controller); - $this->setupAssertions($app, $controller); - $this->setupRoutes($app, $controller); - - return $controller; - } // connect - - /** - * This function is responsible for the setting up of any routes that this - * ControllerProvider is going to be managing. It *must* be overridden by - * a child class. - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - abstract public function setupRoutes(Application $app, ControllerCollection $controller); - - /** - * This function is responsible for setting any global default values that this - * ControllerProvider may require or provide. It defaults to a no-op - * function if not overridden by a child class. - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - public function setupDefaultValues(Application $app, ControllerCollection $controller) - { - // NO-OP UNLESS OVERRIDDEN - } // setupDefaultValues - - /** - * This function is responsible for setting up any global conversions that may be - * required by this ControllerProvider to function. A conversion - * takes in a user provided value and emits a value of a different type. - * - * For example: - * $app->get('/users/{id}', function($id) { - * // do something with int $id here.... - * })->convert('id', function($id) { return (int) $id; }); - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - public function setupConversions(Application $app, ControllerCollection $controller) - { - // NO-OP UNLESS OVERRIDDEN - } //setupConversions - - /** - * This function is responsible for setting up any global middleware that is particular - * to this ControllerProvider. Middleware can be thought of as functions that - * execute either before, after, or weighted before or weighted after ( dependant - * on how they are set up ). They can be used to provide such functionality as - * logging, authentication or authorization. Middleware can also "short circuit" the - * normal execution of a route by returning a 'Response' object. In this case, the - * next Middleware will not be run nor will the route callback. - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - public function setupMiddleware(Application $app, ControllerCollection $controller) - { - // NO-OP UNLESS OVERRIDDEN - } // setupMiddleware - - /** - * This function is responsible for setting up any global assertions that - * this ControllerProvider will need during it's lifecycle. An assertion - * allows for the use of regex expressions to restrict the matching of - * specific route parameters. - * - * Example: - * $app->get('/blog/{id}', function ($id) { - * // ... - * })->assert('id', '\d+'); - * - * Here we see that the 'id' route parameter must be one or more digits - * ( 0-9 ). If the route does not conform to this regex then it does not - * match. - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - public function setupAssertions(Application $app, ControllerCollection $controller) - { - // NO-OP UNLESS OVERRIDDEN - } // setupAssertions - - /** - * A simple piece of Middleware that ensures that the user making the current - * request is both authenticated and authorized to do so. - * - * @param Request $request that will be used to identify and authorize - * the current user. - * @param Application $app that will be used to facilitate returning a - * json response if information is found to be - * missing. - * @return \Symfony\Component\HttpFoundation\JsonResponse if and only if - * the user is missing a token or an ip. - * - * @throws Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - */ - public static function authenticate(Request $request, Application $app) - { - // If the user has already been found, skip this search. - if ($request->attributes->has(BaseControllerProvider::_USER)) { - return; - } - - $user = Authentication::authenticateUser($request); - if ($user === null) { - throw new UnauthorizedHttpException('xdmod', 'You must be logged in to access this endpoint.'); // 401 from framework - } else { - $request->attributes->set(BaseControllerProvider::_USER, $user); - } - } - - /** - * Will attempt to authorize the provided users' roles against the - * provided array of role requirements. - * - * If the user is not authorized, an exception will be thrown. - * Otherwise, the function will simply return the authorized user. - * - * @param Request $request A request containing user information - * that is to be considered for authorization. - * @param array $requirements that a users' roles must satisfy to be - * 'authorized'. If not specified, then only - * whether or not the user is logged in will - * be checked. - * @return \XDUser The user that was checked and is authorized according to - * the given parameters. - * - * @throws Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - * Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - public function authorize(Request $request, array $requirements = array()) - { - - $user = $this->getUserFromRequest($request); - - // If role requirements were not given, then the only check to perform - // is that the user is not a public user. - $isPublicUser = $user->isPublicUser(); - if (empty($requirements) && $isPublicUser) { - throw new UnauthorizedHttpException('xdmod', self::EXCEPTION_MESSAGE); - } - - $authorized = $user->hasAcls($requirements); - if (!$authorized && !$isPublicUser) { - throw new AccessDeniedHttpException(self::EXCEPTION_MESSAGE); - } elseif (!$authorized && $isPublicUser) { - throw new UnauthorizedHttpException('xdmod', self::EXCEPTION_MESSAGE); - } - - // Return the successfully-authorized user. - return $user; - } - - /** - * Retrieve the XDMoD user from a request object. - * - * @param Request $request The request to retrieve a user from. - * @return \XDUser The user who made the request. - */ - protected function getUserFromRequest(Request $request) - { - return $request->attributes->get(BaseControllerProvider::_USER); - } - - /** - * Attempt to get a parameter value from a request and filter it. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory If true, an exception will be thrown if - * the parameter is missing from the request. - * @param mixed $default The value to return if the parameter was not - * specified and the parameter is not mandatory. - * @param int $filterId The ID of the filter to use. See filter_var. - * @param mixed $filterOptions The options to use with the filter. - * The filter should be configured so that - * it returns null if conversion is not - * successful. See filter_var. - * @param string $expectedValueType The expected type for the value. - * This is used purely for errors thrown - * when the parameter value is invalid. - * @return mixed If available and valid, the parameter value. - * Otherwise, if it is missing and not mandatory, - * the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value is not valid - * according to the given filter. - */ - private function getParam(Request $request, $name, $mandatory, $default, $filterId, $filterOptions, $expectedValueType) - { - // Attempt to extract the parameter value from the request. - $value = $request->get($name, null); - - // If the parameter was not present, throw an exception if it was - // mandatory and return the default if it was not. - if ($value === null) { - if ($mandatory) { - throw new BadRequestHttpException("$name is a required parameter."); - } else { - return $default; - } - } - - // If the parameter is an array, throw an exception. - $invalidMessage = ( - "Invalid value for $name. Must be a(n) $expectedValueType." - ); - if (is_array($value)) { - throw new BadRequestHttpException($invalidMessage); - } - - // Run the found parameter value through the given filter. - if (array_key_exists('flags', $filterOptions)) { - $filterOptions['flags'] |= FILTER_NULL_ON_FAILURE; - } else { - $filterOptions['flags'] = FILTER_NULL_ON_FAILURE; - } - $value = filter_var($value, $filterId, $filterOptions); - - // If the value is invalid, throw an exception. - if ($value === null) { - throw new BadRequestHttpException($invalidMessage); - } - - // Return the filtered value. - return $value; - } - - /** - * Attempt to get an integer parameter value from a request. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as an integer. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value could not be - * converted to an integer. - */ - protected function getIntParam(Request $request, $name, $mandatory = false, $default = null) - { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_VALIDATE_INT, - array( - "options" => array( - "default" => null, - ), - ), - "integer" - ); - } - - /** - * Attempt to get a float parameter value from a request. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as a float. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value could not be - * converted to a float. - */ - protected function getFloatParam(Request $request, $name, $mandatory = false, $default = null) - { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_VALIDATE_FLOAT, - array( - "options" => array( - "default" => null, - ), - ), - "float" - ); - } - - /** - * Attempt to get a string parameter value from a request. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as a string. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory. - */ - protected function getStringParam(Request $request, $name, $mandatory = false, $default = null) - { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_DEFAULT, - array(), - "string" - ); - } - - /** - * Attempt to get a boolean parameter value from a request. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as a boolean. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value could not be - * converted to a boolean. - */ - protected function getBooleanParam(Request $request, $name, $mandatory = false, $default = null) - { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_CALLBACK, - array( - "options" => function ($value) { - // Run the found parameter value through a boolean filter. - $filteredValue = filter_var( - $value, - FILTER_VALIDATE_BOOLEAN, - array( - "flags" => FILTER_NULL_ON_FAILURE, - ) - ); - - // If the filter converted the string, return the boolean. - if ($filteredValue !== null) { - return $filteredValue; - } - - // Check the value against 'y' for true and 'n' for false. - $lowercaseValue = strtolower($value); - if ($lowercaseValue === 'y') { - return true; - } - if ($lowercaseValue === 'n') { - return false; - } - - // Return null if all conversion attempts failed. - return null; - }, - ), - "boolean" - ); - } - - /** - * Attempt to get a date parameter value from a request where it is - * submitted as a Unix timestamp. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as a DateTime. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value could not be - * converted to a DateTime. - */ - protected function getDateTimeFromUnixParam(Request $request, $name, $mandatory = false, $default = null) - { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_CALLBACK, - array( - "options" => function ($value) { - return self::filterDate($value, 'U'); - }, - ), - "Unix timestamp" - ); - } - - /** - * Attempt to get a date parameter value from a request where it is - * submitted as a ISO 8601 (YYYY-MM-DD) date. - * - * @param Request $request The request to extract the parameter from. - * @param string $name The name of the parameter. - * @param boolean $mandatory (Optional) If true, an exception will be - * thrown if the parameter is missing from the - * request. (Defaults to false.) - * @param mixed $default (Optional) The value to return if the - * parameter was not specified and the parameter - * is not mandatory. (Defaults to null.) - * @return mixed If available and valid, the parameter value - * as a DateTime. Otherwise, if it is missing - * and not mandatory, the given default. - * - * @throws BadRequestHttpException If the parameter was not available - * and the parameter was deemed mandatory, - * or if the parameter value could not be - * converted to a DateTime. - */ - protected function getDateFromISO8601Param( - Request $request, - $name, - $mandatory = false, - $default = null - ) { - return $this->getParam( - $request, - $name, - $mandatory, - $default, - FILTER_CALLBACK, - [ - 'options' => function ($value) { - return self::filterDate($value); - }, - ], - 'ISO 8601 Date' - ); - } - - /** - * Get the best match for the acceptable content type for the request, given a - * list of supported content types. - * - * @param Request $request The request from which to extract the data - * @param array $supportedTypes A list of supported MIME types. - * @param string $paramname (Optional) A parameter that will also be - * checked for the accept type, in addition to the Accept header - * contents. This parameter is checked first. - * @return mixed the best matching entry from the $supportedTypes list or null if no supported types - * were allowable. - */ - protected function getAcceptContentType(Request $request, $supportedTypes, $paramname = null) - { - $acceptTypes = $request->getAcceptableContentTypes(); - - if ($paramname !== null) { - $acceptType = $this->getStringParam($request, $paramname); - if ($acceptType !== null) { - array_unshift($acceptTypes, $acceptType); - } - } - - $selectedType = null; - - foreach ($acceptTypes as $type) { - if (in_array($type, $supportedTypes)) { - $selectedType = $type; - break; - } - } - - return $selectedType; - } - - /** - * Helper function that creates a Response object that will result in - * a file download on the client. - * - * @param $content The content of the file that will be sent - * @param $filename The name of the file to send - * @param $mimetype (Optional) The mimetype to set for the file. If omitted - * then the mime type will be guessed using the finfo() fn. - */ - protected function sendAttachment($content, $filename, $mimetype = null) - { - if ($mimetype === null) { - $finfo = new \finfo(FILEINFO_MIME_TYPE); - $mimetype = $finfo->buffer($content); - } - - $response = new Response( - $content, - Response::HTTP_OK, - array('Content-Type' => $mimetype) - ); - $response->headers->set( - 'Content-Disposition', - $response->headers->makeDisposition( - ResponseHeaderBag::DISPOSITION_ATTACHMENT, - $filename - ) - ); - - return $response; - } - - /** - * Retrieve the 'id' property from the supplied array of values. The 'id' - * property is defined by the provided 'selector'. If the 'id' does not - * exist than a default can be supplied, otherwise null will be returned. - * - * @param array $values - * @param string $selector - * @param null $default - * @return null - */ - protected function getId(array $values, $selector = 'dtype', $default = null) - { - if (!isset($values) || !isset($selector) || !is_string($selector)) { - return null; - } - - $idSelector = isset($values[$selector]) ? $values[$selector] : null; - - return isset($idSelector) && isset($values[$idSelector]) ? $values[$idSelector] : $default; - } - - /** ------------------------------------------------------------------------------------------ - * Format a data structure suitable for logging. The logger will convert an array into a JSON - * blob for storage in the database. - * - * @param string $message A general message - * @param \Symfony\Component\HttpFoundation\Request $request - * @param boolean $includeParams if set to - * TRUE include the GET and POST parameters in the log message. - * - * @return array An associative array containing the message, request path, and a block of - * supplemental data including host, port, method, ip address, get & post parameters, etc. - * - * array('message' => , - * 'path' => - * 'data' => array(...) - * ); - * - * Note: We need to define a standard log message with optional additional information. To - * facilitate parsing/display, I suggest that all log entries have: - * message - human readable message - * internal - optional internal-only message describing the error - * path - the rest path or file/method that the exception was thrown - * data - an associative array of optional data specific to the section - * - * ------------------------------------------------------------------------------------------ - */ - - public function formatLogMesssage($message, Request $request, $includeParams = false) - { - $retval = array('message' => $message); - - $authInfo = Authentication::getAuthenticationInfo($request); - $method = $request->getMethod(); - $host = $request->getHost(); - $port = $request->getPort(); - $retval['path'] = $request->getPathInfo(); - - $retval['data'] = array( - 'host' => $host, - 'port' => $port, - 'method' => $method, - 'username' => $authInfo['username'], - 'ip' => $authInfo['ip'], - 'token' => $authInfo['token'], - 'timestamp' => date("Y-m-d H:i:s", $_SERVER['REQUEST_TIME']) - ); - - if ($includeParams) { - $retval['data']['get'] = $request->query->all(); - $retval['data']['post'] = $request->request->all(); - } - - return $retval; - - } - - /** - * Checks that the `$[start|end]Date` values are valid ( `Y-m-d` ) dates and that `$startDate` - * is before `$endDate`. - * - * @param string $startDate the beginning of the date range. - * @param string $endDate the end of the date range. - * @throws BadRequestHttpException if either start or end dates are not provided in the format - * `Y-m-d`, or if the start date is after the end date. - */ - protected function checkDateRange($startDate, $endDate) - { - $startTimestamp = $this->getTimestamp($startDate, 'start_date'); - $endTimestamp = $this->getTimestamp($endDate, 'end_date'); - - if ($startTimestamp > $endTimestamp) { - throw new BadRequestHttpException('Start Date must not be after End Date'); - } - } - - /** - * Attempt to convert the provided string $date value into an equivalent unix timestamp (int). - * - * @param string $date The value to be converted into a DateTime. - * @param string $paramName 'date', The name of the parameter to be included in the exception - * message if validation fails. - * @param string $format 'Y-m-d', The format that `$date` should be in. - * @return int created from the provided `$date` value. - * @throws BadRequestHttpException if the date is not in the form `Y-m-d`. - */ - protected function getTimestamp($date, $paramName = 'date', $format = 'Y-m-d') - { - $parsed = date_parse_from_format($format, $date); - $date = mktime( - $parsed['hour'], - $parsed['minute'], - $parsed['second'], - $parsed['month'], - $parsed['day'], - $parsed['year'] - ); - - if ($date === false || $parsed['error_count'] > 0) { - throw new BadRequestHttpException("Unable to parse $paramName"); - } - - return $date; - } - - /** - * Attempts to convert the provided $value into an instance of DateTime by using the provided $format. If $value is - * unable to be converted into a valid DateTime or if warnings are generated during the process it will be filtered - * and null returned. - * - * @param string $value the date to be validated against the provided $format. Ex: 2027-08-15 - * @param string $format the format to be used when converting the string $value to an instance of DateTime - * - * @return DateTime|null If the creation of a DateTime was successful without warning then an instance of DateTime - * will be returned, else null; - */ - private static function filterDate(string $value, string $format = 'Y-m-d'): ?DateTime - { - $dateTime = DateTime::createFromFormat($format, $value); - - $lastErrors = DateTime::getLastErrors(); - - /* For PHP versions less than 8.2.0 $lastErrors will always be an array w/ the properties: - * warning_count, warnings, error_count, and errors. For versions >= 8.2.0, it will return false if - * there are no errors else it will return as it did pre-8.2.0. - * - * The below `if` statement takes this into account by ensuring that we specifically check for when - * $value_dt is not false ( i.e. is a DateTime object ) but we do have 1 or more warnings which - * indicates that the value of $value_dt is most likely not what it's expected to be. - * - * Example: parsing the date `2024-01-99` results in a $value_dt of: - * DateTime('2024-04-08') - * and a $lastError of: - * [ - * 'warning_count' => 1, - * 'warnings' => [ - * 10 => 'The parsed date was invalid' - * ], - * 'error_count' => 0, - * 'errors' => [] - * ] - */ - if ($dateTime === false || (is_array($lastErrors) && $lastErrors['warning_count'] > 0)) { - return null; - } - return $dateTime; - } -} diff --git a/classes/Rest/Controllers/DashboardControllerProvider.php b/classes/Rest/Controllers/DashboardControllerProvider.php deleted file mode 100644 index ffef4bda29..0000000000 --- a/classes/Rest/Controllers/DashboardControllerProvider.php +++ /dev/null @@ -1,430 +0,0 @@ -prefix; - $class = get_class($this); - - $controller->get("$root/components", "$class::getComponents"); - - $controller->post("$root/layout", "$class::setLayout"); - $controller->delete("$root/layout", "$class::resetLayout"); - - $controller->get("$root/rolereport", "$class::getRoleReport"); - $controller->get("$root/savedchartsreports", "$class::getSavedChartsReports"); - - $controller->post("$root/viewedUserTour", "$class::setViewedUserTour"); - $controller->get("$root/viewedUserTour", "$class::getViewedUserTour"); - - $controller->get("$root/statistics", "$class::getStatistics"); - - } - - /* - * Get the column layout manager for the user - * - * @return \CCR\ColumnLayout - */ - private function getLayout($user) - { - $defaultLayout = null; - $defaultColumnCount = 2; - - if ($user->isPublicUser() === false) { - $layoutStore = new \UserStorage($user, 'summary_layout'); - $record = $layoutStore->getById(0); - if ($record) { - $defaultLayout = $record['layout']; - $defaultColumnCount = $record['columns']; - } - } - - return new \CCR\ColumnLayout($defaultColumnCount, $defaultLayout); - } - - private function getConfigVariables($user) - { - $person_id = $user->getPersonID(true); - $obj_warehouse = new \XDWarehouse(); - - return array( - 'PERSON_ID' => $person_id, - 'PERSON_NAME' => $obj_warehouse->resolveName($person_id) - ); - } - - /** - * The individual dashboard components have a namespace prefix to simplify - * the implementation of the algorithm that determines which - * components to display. There are two sources of configuration data for - * the components. The roles configuration file and the user configuration - * (in the database). The user configuration only contains chart components. - * The user configuration is handled via the "Show in Summary tab" checkbox - * in the metric explorer. - * - * Non-chart components and the full-width components are defined in the roles - * configuration file and are not overrideable. - * - * Chart components are handled as follows: - * - All user charts with "show in summary tab" checked will be displayed - * - If a user chart has the same name as a chart in the role configuration - * then its settings will be used in place of the role chart. - */ - const TOP_COMPONENT = 't.'; - const CHART_COMPONENT = 'c.'; - const NON_CHART_COMPONENT = 'p.'; - - public function getComponents(Request $request, Application $app) - { - $user = $this->getUserFromRequest($request); - - $dashboardComponents = array(); - - $mostPrivilegedAcl = Acls::getMostPrivilegedAcl($user)->getName(); - - $layout = $this->getLayout($user); - - $roleConfig = \Configuration\XdmodConfiguration::assocArrayFactory( - 'roles.json', - CONFIG_DIR, - null, - array('config_variables' => $this->getConfigVariables($user)) - ); - - $presets = $roleConfig['roles'][$mostPrivilegedAcl]; - - if (isset($presets['dashboard_components'])) { - - foreach($presets['dashboard_components'] as $component) { - - $componentType = self::NON_CHART_COMPONENT; - - if (isset($component['region']) && $component['region'] === 'top') { - $componentType = self::TOP_COMPONENT; - $chartLocation = $componentType . $component['name']; - $column = -1; - } else { - if ($component['type'] === 'xdmod-dash-chart-cmp') { - $componentType = self::CHART_COMPONENT; - $component['config']['name'] = $component['name']; - $component['config']['chart']['featured'] = true; - } - - $defaultLayout = null; - if (isset($component['location']) && isset($component['location']['row']) && isset($component['location']['column'])) { - $defaultLayout = array($component['location']['row'], $component['location']['column']); - } - - list($chartLocation, $column) = $layout->getLocation($componentType . $component['name'], $defaultLayout); - } - - $dashboardComponents[$chartLocation] = array( - 'name' => $componentType . $component['name'], - 'type' => $component['type'], - 'config' => isset($component['config']) ? $component['config'] : array(), - 'column' => $column - ); - } - } - - if ($user->isPublicUser() === false) - { - $queryStore = new \UserStorage($user, 'queries_store'); - $queries = $queryStore->get(); - - if ($queries != null) { - foreach ($queries as $query) { - if (!isset($query['config']) || !isset($query['name'])) { - continue; - } - - $queryConfig = json_decode($query['config']); - - if (!isset($queryConfig->featured) || !$queryConfig->featured) { - continue; - } - - $name = self::CHART_COMPONENT . $query['name']; - - list($chartLocation, $column) = $layout->getLocation($name); - - $dashboardComponents[$chartLocation] = array( - 'name' => $name, - 'type' => 'xdmod-dash-chart-cmp', - 'config' => array( - 'name' => $query['name'], - 'chart' => $queryConfig - ), - 'column' => $column - ); - } - } - } - - ksort($dashboardComponents); - - return $app->json(array( - 'success' => true, - 'total' => count($dashboardComponents), - 'portalConfig' => array('columns' => $layout->getColumnCount()), - 'data' => array_values($dashboardComponents) - )); - } - - /** - * set the layout metadata - * - */ - public function setLayout(Request $request, Application $app) - { - $user = $this->authorize($request); - - $content = json_decode($this->getStringParam($request, 'data', true), true); - - if ($content === null || !isset($content['layout']) || !isset($content['columns'])) { - throw new BadRequestHttpException('Invalid data parameter'); - } - - $storage = new \UserStorage($user, 'summary_layout'); - - return $app->json(array( - 'success' => true, - 'total' => 1, - 'data' => $storage->upsert(0, $content) - )); - } - - /** - * clear the layout metadata - * - */ - public function resetLayout(Request $request, Application $app) - { - $user = $this->authorize($request); - - $storage = new \UserStorage($user, 'summary_layout'); - - $storage->del(); - - return $app->json(array( - 'success' => true, - 'total' => 1 - )); - } - - /* - * Set value for if a user should view the help tour or not - */ - public function setViewedUserTour(Request $request, Application $app) - { - $user = $this->authorize($request); - $viewedTour = $this->getIntParam($request, 'viewedTour', true); - - if (!in_array($viewedTour, [0,1])) { - throw new BadRequestHttpException('Invalid data parameter'); - } - - $storage = new \UserStorage($user, 'viewed_user_tour'); - - return $app->json(array( - 'success' => true, - 'total' => 1, - 'msg' => $storage->upsert(0, ['viewedTour' => $viewedTour]) - )); - } - - /** - * Get charts based on role. - **/ - public function getRoleReport(Request $request, Application $app) - { - $user = $this->authorize($request); - $role = $user->getMostPrivilegedRole()->getName(); - $report_id_suffix = 'autogenerated-' . $role; - $report_id = $user->getUserID() . '-' . $report_id_suffix; - if (isset($user)) { - $userReport = null; - $rm = new \XDReportManager($user); - $reports = $rm->fetchReportTable(); - foreach ($reports as &$report) { - if ($report['report_id'] === $report_id) { - $userReport = $report; - } - } - if (is_null($userReport)){ - $availTemplates = $rm->enumerateReportTemplates(array($role), 'Dashboard Tab Report'); - if (empty($availTemplates)) { - throw new NotFoundHttpException("No dashboard tab report template available for $role"); - } - - $template = $rm->retrieveReportTemplate($user, $availTemplates[0]['id']); - $template->buildReportFromTemplate($_REQUEST, $report_id_suffix); - $reports = $rm->fetchReportTable(); - foreach ($reports as &$report) { - if ($report['report_id'] === $report_id) { - $userReport = $report; - } - } - } - $data = $rm->loadReportData($userReport['report_id']); - $count = 0; - foreach($data['queue'] as $queue) { - $chart_id = explode("&", $queue['chart_id']); - $chart_id_parsed = array(); - foreach($chart_id as $value) { - list($key, $value) = explode("=", $value); - $key = urldecode($key); - $value = urldecode($value); - $json = json_decode($value, true); - - if ($key === 'timeseries') { - $value = $value === 'y' || $value === 'true'; - } elseif ($json !== null) { - $value = $json; - } - $chart_id_parsed[$key] = $value; - } - $data['queue'][$count]['chart_id'] = $chart_id_parsed; - $count++; - } - return $app->json(array( - 'success' => true, - 'total' => count($data), - 'data' => $data - )); - } - } - /* - * Get stored value for if a user should view the help tour or not - */ - public function getViewedUserTour(Request $request, Application $app) - { - $user = $this->authorize($request); - $storage = new \UserStorage($user, 'viewed_user_tour'); - return $app->json(array( - 'success' => true, - 'total' => 1, - 'data' => $storage->get() - )); - } - /** - * Get saved charts and reports. - **/ - public function getSavedChartsReports(Request $request, Application $app) - { - $user = $this->authorize($request); - if (isset($user)) { - // fetch charts - $queries = new \UserStorage($user, 'queries_store'); - $data = $queries->get(); - foreach ($data as &$query) { - $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); - $query['type'] = 'Chart'; - } - // fetch reports - $rm = new \XDReportManager($user); - $reports = $rm->fetchReportTable(); - foreach ($reports as &$report) { - $tmp = array(); - $tmp['type'] = 'Report'; - $tmp['name'] = $report['report_name']; - $tmp['chart_count'] = $report['chart_count']; - $tmp['charts_per_page'] = $report['charts_per_page']; - $tmp['creation_method'] = $report['creation_method']; - $tmp['report_delivery'] = $report['report_delivery']; - $tmp['report_format'] = $report['report_format']; - $tmp['report_id'] = $report['report_id']; - $tmp['report_name'] = $report['report_name']; - $tmp['report_schedule'] = $report['report_schedule']; - $tmp['report_title'] = $report['report_title']; - $tmp['ts'] = $report['last_modified']; - $tmp['config'] = $report['report_id']; - $data[] = $tmp; - } - return $app->json(array( - 'success' => true, - 'total' => count($data), - 'data' => $data - )); - } - } - - /* - * Retrieve summary statistics - * - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws Exception - */ - public function getStatistics(Request $request, Application $app) - { - $user = $this->getUserFromRequest($request); - - $aggregationUnit = $request->get('aggregation_unit', 'auto'); - - $startDate = $this->getStringParam($request, 'start_date', true); - $endDate = $this->getStringParam($request, 'end_date', true); - - $this->checkDateRange($startDate, $endDate); - - // This try/catch block is intended to replace the "Base table or - // view not found: 1146 Table 'modw_aggregates.jobfact_by_day' - // doesn't exist" error message with something more informative for - // Open XDMoD users. - try { - $query = new \DataWarehouse\Query\AggregateQuery( - 'Jobs', - $aggregationUnit, - $startDate, - $endDate, - 'none', - 'all' - ); - - $result = $query->execute(); - } catch (PDOException $e) { - if ($e->getCode() === '42S02' && strpos($e->getMessage(), 'modw_aggregates.jobfact_by_') !== false) { - $msg = 'Aggregate table not found, have you ingested your data?'; - throw new Exception($msg); - } else { - throw $e; - } - } catch (Exception $e) { - throw new BadRequestHttpException($e->getMessage()); - } - - $rawRoles = XdmodConfiguration::assocArrayFactory('roles.json', CONFIG_DIR); - - $mostPrivileged = $user->getMostPrivilegedRole()->getName(); - $formats = $rawRoles['roles'][$mostPrivileged]['statistics_formats']; - - return $app->json( - array( - 'totalCount' => 1, - 'success' => true, - 'message' => '', - 'formats' => $formats, - 'data' => array($result) - ) - ); - } -} diff --git a/classes/Rest/Controllers/LegacyControllerProvider.php b/classes/Rest/Controllers/LegacyControllerProvider.php deleted file mode 100644 index efc53ffeba..0000000000 --- a/classes/Rest/Controllers/LegacyControllerProvider.php +++ /dev/null @@ -1,129 +0,0 @@ - array( - 'route' => '/versions/current', - 'method' => 'GET', - ), - ); - - /** - * Convert a URL arguments string from the old REST stack - * into an associative array. - * - * The arguments string must not be decoded for this to work properly. - * This means the string cannot be passed in from Silex's route helper - * functions, as they will automatically decode the string. - * - * Based on the old REST stack's URL parser. - * - * @param string $urlArgumentsString A string of URL arguments, as defined - * by the old REST stack. - * @return array A mapping of URL argument keys to - * their values. - */ - private function parseUrlArguments($urlArgumentsString) - { - // Replace any blocks of slashes with a single slash. - $urlArgumentsString = preg_replace('/\/{2,}/', '/', $urlArgumentsString); - - // Break up the string by key-value pairs. - $urlArgumentPairs = explode('/', $urlArgumentsString); - - // Create an associative array from the pairs. - $urlArguments = array(); - foreach ($urlArgumentPairs as $urlArgumentPair) { - $urlArgumentPairComponents = explode('=', $urlArgumentPair, 2); - - if (count($urlArgumentPairComponents) < 2) { - continue; - } - - $urlArgumentPairComponents = array_map('urldecode', $urlArgumentPairComponents); - $urlArguments[$urlArgumentPairComponents[0]] = $urlArgumentPairComponents[1]; - } - - // Return the associative array. - return $urlArguments; - } - - /** - * @see BaseControllerProvider::setupRoutes - */ - public function setupRoutes(Application $app, \Silex\ControllerCollection $controller) - { - foreach (self::$legacyRouteMapping as $legacyRoute => $legacyRouteOptions) { - $controller->match($legacyRoute, '\Rest\Controllers\LegacyControllerProvider::redirectLegacyRoute') - ->value('legacyRoute', $legacyRoute) - ->value('options', $legacyRouteOptions); - - $controller->match("$legacyRoute/{urlArguments}", '\Rest\Controllers\LegacyControllerProvider::redirectLegacyRoute') - ->assert('urlArguments', '.*') - ->value('legacyRoute', $legacyRoute) - ->value('options', $legacyRouteOptions); - } - } - - /** - * Internally redirect a legacy route to its current equivalent. - * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @param string $legacyRoute The route that invoked this function. - * @param array $options A set of options for redirecting the call. - * @return Response The response from the call this route - * was redirected to. - */ - public function redirectLegacyRoute(Request $request, Application $app, $legacyRoute, $options) - { - // Extract the URL arguments from the URL. - // - // This cannot be passed in from the route definition, - // as Silex will apply a different method of URL decoding than the - // old REST stack did. - list($routeMountPoint, $urlArgumentsAndParamsString) = explode($legacyRoute, $request->getRequestUri(), 2); - list($urlArgumentsString, $urlParamsString) = explode('?', $urlArgumentsAndParamsString, 2); - - $urlArguments = $this->parseUrlArguments($urlArgumentsString); - - // Create a sub-request which points to the new route. - $subrequestParams = new ParameterBag(); - $subrequestParams->add($request->query->all()); - $subrequestParams->add($request->request->all()); - $subrequestParams->add($urlArguments); - - $subrequest = Request::create( - '/' . \xd_utilities\getConfiguration('rest', 'version') . $options['route'], - $options['method'], - $subrequestParams->all(), - $request->cookies->all(), - $request->files->all(), - $request->server->all(), - $request->getContent() - ); - - // Launch the sub-request and return the response. - return $app->handle($subrequest, HttpKernelInterface::SUB_REQUEST, false); - } -} diff --git a/classes/Rest/Controllers/MetricExplorerControllerProvider.php b/classes/Rest/Controllers/MetricExplorerControllerProvider.php deleted file mode 100644 index c1fe0dfcc1..0000000000 --- a/classes/Rest/Controllers/MetricExplorerControllerProvider.php +++ /dev/null @@ -1,405 +0,0 @@ -prefix; - $base = '\Rest\Controllers\MetricExplorerControllerProvider'; - - $idConverter = function ($id) { - return (int)$id; - }; - - // QUERY ROUTES ======================================================== - $controller - ->get("$root/queries", "$base::getQueries"); - - $controller - ->get("$root/queries/{id}", "$base::getQueryById") - ->convert('id', $idConverter); - - $controller - ->post("$root/queries", "$base::createQuery"); - - $controller - ->post("$root/queries/{id}", "$base::updateQueryById") - ->convert('id', $idConverter); - - $controller - ->delete("$root/queries/{id}", "$base::deleteQueryById") - ->convert('id', $idConverter); - // QUERY ROUTES ======================================================== - - } - - /** - * Retrieve all of the queries that the requesting user has currently saved. - * - * @param Request $request - * @param Application $app - * @return JsonResponse - */ - public function getQueries(Request $request, Application $app) - { - $action = 'getQueries'; - $payload = array( - 'success' => false, - 'action' => $action, - ); - $statusCode = 401; - - try { - - $user = $this->authorize($request); - if (isset($user)) { - $queries = new \UserStorage($user, self::_QUERIES_STORE); - $data = $queries->get(); - - foreach ($data as &$query) { - $this->removeRoleFromQuery($user, $query); - $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); - } - - $payload['data'] = $data; - $payload['success'] = true; - $statusCode = 200; - } else { - $payload['message'] = self::EXCEPTION_MESSAGE; - } - } catch (BadRequestHttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (HttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (\Exception $e) { - $payload['message'] = $e->getMessage(); - $statusCode = 500; - } - - return $app->json( - $payload, - $statusCode - ); - } - - /** - * Retrieve a query's information by unique id for the requesting user. - * - * @param Request $request - * @param Application $app - * @param $id - * @return JsonResponse - */ - public function getQueryById(Request $request, Application $app, $id) - { - $action = 'getQueryById'; - $payload = array( - 'success' => false, - 'action' => $action, - ); - $statusCode = 401; - - try { - $user = $this->authorize($request); - if (isset($user)) { - $queries = new \UserStorage($user, self::_QUERIES_STORE); - - $query = $queries->getById($id); - - if (isset($query)) { - $payload['data'] = $query; - $payload['data']['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); - $payload['success'] = true; - $statusCode = 200; - } else { - $payload['message'] = 'Unable to find the query identified by the provided id: ' . $id; - $statusCode = 404; - } - } else { - $payload['message'] = self::EXCEPTION_MESSAGE; - } - } catch (BadRequestHttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (HttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (\Exception $e) { - $payload['message'] = $e->getMessage(); - $statusCode = 500; - } - - return $app->json( - $payload, - $statusCode - ); - } - - /** - * Create a new query to be stored in the requesting users User Profile. - * - * @param Request $request - * @param Application $app - * @return JsonResponse - */ - public function createQuery(Request $request, Application $app) - { - $action = 'creatQuery'; - $payload = array( - 'success' => false, - 'action' => $action, - ); - $statusCode = 401; - try { - $user = $this->authorize($request); - if (isset($user)) { - $queries = new \UserStorage($user, self::_QUERIES_STORE); - $data = json_decode( - $this->getStringParam($request, 'data', true), - true - ); - $success = $queries->insert($data) != null; - $payload['success'] = $success; - if ($success) { - $payload['success'] = true; - $payload['data'] = $data; - $statusCode = 200; - } else { - $payload['message'] = 'Error creating chart. User is over the chart limit.'; - $statusCode = 500; - } - } else { - $payload['message'] = self::EXCEPTION_MESSAGE; - } - } catch (BadRequestHttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (HttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (\Exception $e) { - $payload['message'] = $e->getMessage(); - $statusCode = 500; - } - - return $app->json( - $payload, - $statusCode - ); - - } - - /** - * Update the query identified by the provided 'id' parameter with the - * values of the following form params ( if provided ): - * - name - * - config - * - timestamp - * - * @param Request $request - * @param Application $app - * @param $id - * @return JsonResponse - */ - public function updateQueryById(Request $request, Application $app, $id) - { - $action = 'updateQuery'; - $payload = array( - 'success' => false, - 'action' => $action, - 'message' => 'success' - ); - $statusCode = 401; - - try { - $user = $this->authorize($request); - if (isset($user)) { - $queries = new \UserStorage($user, self::_QUERIES_STORE); - - $query = $queries->getById($id); - if (isset($query)) { - - - $data = $this->getStringParam($request, 'data'); - if (isset($data)) { - $jsonData = json_decode($data, true); - $name = isset($jsonData['name']) ? $jsonData['name'] : null; - $config = isset($jsonData['config']) ? $jsonData['config'] : null; - $ts = isset($jsonData['ts']) ? $jsonData['ts'] : microtime(true); - } else { - $name = $this->getStringParam($request, 'name'); - $config = $this->getStringParam($request, 'config'); - $ts = $this->getDateTimeFromUnixParam($request, 'ts'); - } - - if (isset($name)) { - $query['name'] = $name; - } - if (isset($config)) { - $query['config'] = $config; - } - if (isset($ts)) { - $query['ts'] = $ts; - } - - $queries->upsert($id, $query); - - // required for the UI to do it's thing. - $total = count($queries->get()); - - // make sure everything is in place for returning to the - // front end. - $payload['total'] = $total; - $payload['success'] = true; - $statusCode = 200; - } else { - $payload['message'] = 'There was no query found for the given id'; - $statusCode = 404; - } - } else { - $payload['message'] = self::EXCEPTION_MESSAGE; - } - } catch (BadRequestHttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (HttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (\Exception $e) { - $payload['message'] = $e->getMessage(); - $statusCode = 500; - } - - return $app->json( - $payload, - $statusCode - ); - } - - /** - * Delete the query identified by the provided form-param 'id'. - * - * @param Request $request - * @param Application $app - * @param $id of the query to be deleted. - * @return JsonResponse - */ - public function deleteQueryById(Request $request, Application $app, $id) - { - $action = 'deleteQueryById'; - $payload = array( - 'success' => false, - 'action' => $action, - 'message' => 'success' - ); - $statusCode = 401; - - try { - $user = $this->authorize($request); - if (isset($user)) { - $queries = new \UserStorage($user, self::_QUERIES_STORE); - $query = $queries->getById($id); - - - if (isset($query)) { - - $before = count($queries->get()); - $after = $queries->delById($id); - $success = $before > $after; - - // make sure everything is in place for returning to the - // front end. - $payload['success'] = $success; - $payload['message'] = $success ? $payload['message'] : 'There was an error removing the query identified by: ' . $id; - - $statusCode = $success ? 200 : 500; - } else { - $payload['message'] = 'There was no query found for the given id'; - $statusCode = 404; - } - } else { - $payload['message'] = self::EXCEPTION_MESSAGE; - } - } catch (BadRequestHttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (HttpException $e) { - $payload['message'] = $e->getMessage(); - $statusCode = $e->getStatusCode(); - } catch (\Exception $e) { - $payload['message'] = $e->getMessage(); - $statusCode = 500; - } - - return $app->json( - $payload, - $statusCode - ); - } - - private function removeRoleFromQuery(XDUser $user, array &$query) - { - // If the query doesn't have a config, stop. - if (!array_key_exists('config', $query)) { - return; - } - - // If the query config doesn't have an active role, stop. - $queryConfig = json_decode($query['config']); - if (!property_exists($queryConfig, 'active_role')) { - return; - } - - // Remove the active role from the query config. - $activeRoleId = $queryConfig->active_role; - unset($queryConfig->active_role); - - // Check whether or not $activeRoleId is an acl name or acl display value. - // ( Old queries may utilize the `display` property). - $activeRole = Acls::getAclByName($activeRoleId); - if ($activeRole === null) { - $activeRole = Acls::getAclByDisplay($activeRoleId); - if ($activeRole !== null) { - $activeRoleId = $activeRole->getName(); - } - } - // Convert the active role into global filters. - MetricExplorer::convertActiveRoleToGlobalFilters($user, $activeRoleId, $queryConfig->global_filters); - - // Store the updated config in the query. - $query['config'] = json_encode($queryConfig); - } -} diff --git a/classes/Rest/Controllers/PersonControllerProvider.php b/classes/Rest/Controllers/PersonControllerProvider.php deleted file mode 100644 index 6ea4ee3b84..0000000000 --- a/classes/Rest/Controllers/PersonControllerProvider.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ -class PersonControllerProvider extends BaseControllerProvider -{ - public function setupRoutes(Application $app, ControllerCollection $controller) - { - $root = $this->prefix; - $class = get_class($this); - $conversions = '\Rest\Utilities\Conversions'; - - $controller - ->get("$root/{id}/organization", "$class::getOrganizationForPerson") - ->assert('id', '(-)?\d+') - ->convert('id', "$conversions::toInt"); - } - - /** - * @param Request $request - * @param Application $app - * @param $id - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Exception - */ - public function getOrganizationForPerson(Request $request, Application $app, $id) - { - // Ensure that this route is only authorized for users with the 'mgr' role. - $this->authorize($request, array('mgr')); - - return $app->json( - array( - 'success' => true, - 'results' => array( - 'id' => Organizations::getOrganizationIdForPerson($id) - ) - ) - ); - } -} diff --git a/classes/Rest/Controllers/WarehouseExportControllerProvider.php b/classes/Rest/Controllers/WarehouseExportControllerProvider.php index d93f2510ed..e69de29bb2 100644 --- a/classes/Rest/Controllers/WarehouseExportControllerProvider.php +++ b/classes/Rest/Controllers/WarehouseExportControllerProvider.php @@ -1,416 +0,0 @@ -logger = Log::factory( - 'data-warehouse-export-rest', - [ - 'console' => false, - 'file' => false, - 'mail' => false - ] - ); - $this->realmManager = new RealmManager(); - $this->queryHandler = new QueryHandler($this->logger); - } - - /** - * Set up data warehouse export routes. - * - * @param Application $app - * @param ControllerCollection $controller - */ - public function setupRoutes( - Application $app, - ControllerCollection $controller - ) { - $root = $this->prefix; - $current = get_class($this); - $conversions = '\Rest\Utilities\Conversions'; - - $controller->get("$root/realms", "$current::getRealms"); - $controller->post("$root/request", "$current::createRequest"); - $controller->get("$root/requests", "$current::getRequests"); - $controller->delete("$root/requests", "$current::deleteRequests"); - - $controller->get("$root/download/{id}", "$current::getExportedDataFile") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - - $controller->delete("$root/request/{id}", "$current::deleteRequest") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - } - - /** - * Get all the realms available for exporting for the current user. - * - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - */ - public function getRealms(Request $request, Application $app) - { - $user = null; - - // We need to wrap the token authentication because we want the token authentication to be optional, proceeding - // to the normal session authentication if a token is not provided. - try { - $user = Tokens::authenticate($request); - } catch (Exception $e) { - // NOOP - } - - if ($user === null) { - $user = $this->authorize($request); - } - - - $config = RawStatisticsConfiguration::factory(); - - $realms = array_map( - function ($realm) use ($config) { - $name = $realm->getName(); - return [ - 'id' => $name, - 'name' => $realm->getDisplay(), - 'fields' => $config->getBatchExportFieldDefinitions($name) - ]; - }, - $this->realmManager->getRealmsForUser($user) - ); - - return $app->json( - [ - 'success' => true, - 'data' => array_values($realms), - 'total' => count($realms) - ] - ); - } - - /** - * Get all the existing export requests for the current user. - * - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - */ - public function getRequests(Request $request, Application $app) - { - $user = $this->authorize($request); - $results = $this->queryHandler->listUserRequestsByState($user->getUserId()); - return $app->json( - [ - 'success' => true, - 'data' => $results, - 'total' => count($results) - ] - ); - } - - /** - * Create a new export request for the current user. - * - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - * @throws BadRequestHttpException - */ - public function createRequest(Request $request, Application $app) - { - $user = $this->authorize($request); - $realm = $this->getStringParam($request, 'realm', true); - - $realms = array_map( - function ($realm) { - return $realm->getName(); - }, - $this->realmManager->getRealmsForUser($user) - ); - if (!in_array($realm, $realms)) { - throw new BadRequestHttpException('Invalid realm'); - } - - $startDate = $this->getDateFromISO8601Param($request, 'start_date', true); - $endDate = $this->getDateFromISO8601Param($request, 'end_date', true); - $now = new DateTime(); - - if ($startDate > $now) { - throw new BadRequestHttpException('Start date cannot be in the future'); - } - - if ($endDate > $now) { - throw new BadRequestHttpException('End date cannot be in the future'); - } - - $interval = $startDate->diff($endDate); - - if ($interval === false) { - throw new BadRequestHttpException('Failed to calculate date interval'); - } - - if ($interval->invert === 1) { - throw new BadRequestHttpException('Start date must be before end date'); - } - - $format = strtoupper($this->getStringParam($request, 'format', true)); - - if (!in_array($format, ['CSV', 'JSON'])) { - throw new BadRequestHttpException('format must be CSV or JSON'); - } - - try { - $id = $this->queryHandler->createRequestRecord( - $user->getUserId(), - $realm, - $startDate->format('Y-m-d'), - $endDate->format('Y-m-d'), - $format - ); - } catch (Exception $e) { - throw new BadRequestHttpException('Failed to create export request: ' . $e->getMessage()); - } - - return $app->json([ - 'success' => true, - 'message' => 'Created export request', - 'data' => [['id' => $id]], - 'total' => 1 - ]); - } - - /** - * Get the requested data. - * - * @param Request $request - * @param Application $app - * @param int $id - * @return \Symfony\Component\HttpFoundation\BinaryFileResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - * @throws AccessDeniedHttpException - * @throws NotFoundHttpException - * @throws BadRequestHttpException - */ - public function getExportedDataFile(Request $request, Application $app, $id) - { - $user = $this->authorize($request); - - $requests = array_filter( - $this->queryHandler->listUserRequestsByState($user->getUserId()), - function ($request) use ($id) { - return $request['id'] == $id; - } - ); - - if (count($requests) === 0) { - throw new NotFoundHttpException('Export request not found'); - } - - // Using `array_shift` because `array_filter` preserves keys so the - // request may not be at index 0. - $request = array_shift($requests); - - if ($request['state'] !== 'Available') { - throw new BadRequestHttpException('Requested data is not available'); - } - - $fileManager = new FileManager(); - $file = $fileManager->getExportDataFilePath($id); - - if (!is_file($file)) { - throw new NotFoundHttpException('Exported data not found'); - } - - if (!is_readable($file)) { - throw new AccessDeniedHttpException('Exported data is not readable'); - } - - $this->logger->info( - '', - [ - 'module' => self::LOG_MODULE, - 'message' => 'Sending data warehouse export file', - 'event' => 'DOWNLOAD', - 'id' => $id, - 'Users.id' => $user->getUserId() - ] - ); - - if ($request['downloaded_datetime'] === null) { - $this->queryHandler->updateDownloadedDatetime($request['id']); - } - - return $app->sendFile( - $file, - 200, - [ - 'Content-type' => 'application/zip', - 'Content-Disposition' => sprintf( - 'attachment; filename="%s"', - $fileManager->getZipFileName($request) - ) - ] - ); - } - - /** - * Delete a single request. - * - * @param Request $request - * @param Application $app - * @param int $id - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - * @throws NotFoundHttpException - */ - public function deleteRequest(Request $request, Application $app, $id) - { - $user = $this->authorize($request); - $count = $this->queryHandler->deleteRequest($id, $user->getUserId()); - - if ($count === 0) { - throw new NotFoundHttpException('Export request not found'); - } - - $this->logger->info('', [ - 'module' => self::LOG_MODULE, - 'message' => 'Deleted data warehouse export request', - 'event' => 'DELETE_BY_USER', - 'id' => $id, - 'Users.id' => $user->getUserId() - ]); - - return $app->json([ - 'success' => true, - 'message' => 'Deleted export request', - 'data' => [['id' => $id]], - 'total' => 1 - ]); - } - - /** - * Delete multiple requests. - * - * The request body content must be a JSON encoded array of request IDs. - * - * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException - * @throws NotFoundHttpException - */ - public function deleteRequests(Request $request, Application $app) - { - $user = $this->authorize($request); - - $requestIds = []; - - try { - $requestIds = @json_decode($request->getContent()); - - if ($requestIds === null) { - throw new Exception('Failed to decode JSON'); - } - - if (!is_array($requestIds)) { - throw new Exception('Export request IDs must be in an array'); - } - - foreach ($requestIds as $id) { - if (!is_int($id)) { - throw new Exception('Export request IDs must integers'); - } - } - } catch (Exception $e) { - throw new BadRequestHttpException( - 'Malformed HTTP request content: ' . $e->getMessage() - ); - } - - try { - $dbh = DB::factory('database'); - $dbh->beginTransaction(); - - foreach ($requestIds as $id) { - $count = $this->queryHandler->deleteRequest($id, $user->getUserId()); - if ($count === 0) { - throw new NotFoundHttpException('Export request not found'); - } - $this->logger->info( - '', - [ - 'module' => self::LOG_MODULE, - 'message' => 'Deleted data warehouse export request', - 'event' => 'DELETE_BY_USER', - 'id' => $id, - 'Users.id' => $user->getUserId() - ] - ); - } - - $dbh->commit(); - } catch (NotFoundHttpException $e) { - $dbh->rollBack(); - throw $e; - } catch (Exception $e) { - $dbh->rollBack(); - throw new BadRequestHttpException('Failed to delete export requests'); - } - - return $app->json([ - 'success' => true, - 'message' => 'Deleted export requests', - 'data' => array_map( - function ($id) { - return ['id' => $id]; - }, - $requestIds - ), - 'total' => count($requestIds) - ]); - } -} diff --git a/classes/Rest/RestFacade.php b/classes/Rest/RestFacade.php deleted file mode 100644 index 82a0e90a29..0000000000 --- a/classes/Rest/RestFacade.php +++ /dev/null @@ -1,138 +0,0 @@ -attributes->set(BaseControllerProvider::_USER, $options['user']); - - // Determine the type of request by checking if an existing request - // is accessible. If it is, the type of request to launch is a sub-request. - // Otherwise, a master request needs to be launched. - $request_level = HttpKernelInterface::MASTER_REQUEST; - try { - $existing_request = $app['request']; - $request_level = HttpKernelInterface::SUB_REQUEST; - } catch (\Exception $e) { - - } - - // Launch the request. - $response = $app->handle($request, $request_level, $catch); - - // If the response object was requested, return it. - if ($returnResponse) { - return $response; - } - - // Retrieve the encoded content from the response object. - $encodedContent = $response->getContent(); - - // If decoding was not requested, simply return the encoded contents. - if (!$decodeResponse) { - return $encodedContent; - } - - // Get and return the decoded content of the response. - // Use the encoded content as the return value, if all else fails. - $decodedContent = $encodedContent; - - // If the original content is provided in the response, use that as - // the decoded content to return. Otherwise, attempt to decode the - // response contents. - if (property_exists($response, 'originalContent')) { - $decodedContent = $response->originalContent; - } else { - $contentType = $response->headers->get('Content-Type'); - if ($contentType === 'application/json') { - $decodedContent = json_decode($encodedContent); - } - } - - return $decodedContent; - } -} diff --git a/classes/Rest/Utilities/Authentication.php b/classes/Rest/Utilities/Authentication.php deleted file mode 100644 index 2e3aa15f38..0000000000 --- a/classes/Rest/Utilities/Authentication.php +++ /dev/null @@ -1,275 +0,0 @@ -getAccountStatus() == false) { - throw new HttpException(403, 'This account is disabled.'); - } - } elseif (!isset($authInfo['token']) || \xd_utilities\string_begins_with($authInfo['token'], 'public-')) { - $user = XDUser::getPublicUser(); - } else { - $user = self::resolveUserFromToken( - $authInfo['token'], - $authInfo['ip'] - ); - } - - return $user; - - }//authenticateUser - - /** - * This function will attempt to retrieve the currently logged in users' - * authentication information from the provided Request object. If a - * Request object is not provided than an empty array is returned. - * - * @param Request $request - * @return array of the form array( - * 'username' => , - * 'password' => , - * 'token' => , - * 'ip' => ) - */ - public static function getAuthenticationInfo(Request $request) - { - if (!isset($request)) { - return array(); - } - - try { - $useBasicAuth = \xd_utilities\getConfiguration('rest', 'basic_auth') == 'on'; - } catch (Exception $e) { - $useBasicAuth = false; - } - - if ($useBasicAuth) { - $username = $request->headers->get(Authentication::_DEFAULT_AUTH_USER); - $password = $request->headers->get(Authentication::_DEFAULT_AUTH_PASSWORD); - } - - if (!isset($username)) { - $username = $request->get(Authentication::_DEFAULT_USER); - } - if (!isset($password)) { - $password = $request->get(Authentication::_DEFAULT_PASSWORD); - } - - $token = $request->get(Authentication::_DEFAULT_TOKEN); - if (!isset($token)) { - $token = $request->headers->get(Authentication::_DEFAULT_AUTH_TOKEN); - } - if (!isset($token)) { - $token = $request->cookies->get(Authentication::_DEFAULT_COOKIE_TOKEN); - } - - return array( - 'username' => $username, - 'password' => $password, - 'token' => $token, - 'ip' => $request->getClientIp() - ); - } // _getAuthenticationInfo - - /** - * This function will attempt to retrieve an instance of XDUser for the provided token, and ip_address. - * - * @param $token the session token that will be used to retrieve - * the currently logged in user. - * @param $ip_address the ip_address that is associated with this - * authentication attempt. - * @return XDUser - * @throws Exception - * @throws SessionExpiredException - */ - private static function resolveUserFromToken( - $token, - $ip_address - ) { - \xd_security\start_session(); - - // TODO: A REST API should not depend on the consumer - // sending a session cookie. The below block is for - // handling session expiration in the browser. This - // function and the client code should be refactored - // to not depend on session-related code to detect - // expired REST tokens. - - if (!isset($_SESSION['xdInit'])) { - - // Session died (token no longer valid); - $msg = 'Token invalid or expired. ' - . 'You must authenticate before using this call.'; - throw new \SessionExpiredException($msg); - } - - $session_id = session_id(); - - // Without IP restriction ... relaxed, especially for - // very mobile users (in which network hopping is - // frequent) - - $resolver_query = " - SELECT user_id - FROM SessionManager - WHERE session_token = :session_token - AND session_id = :session_id - AND init_time = :init_time - "; - $resolver_query_params = array( - ':session_token' => $token, - ':session_id' => $session_id, - ':init_time' => $_SESSION['xdInit'], - ); - - $pdo = DB::factory('database'); - - $user_check = $pdo->query( - $resolver_query, - $resolver_query_params - ); - - if (count($user_check) === 1) { - $last_active_time = self::getMicrotime(); - - $last_active_query = " - UPDATE SessionManager - SET last_active = :last_active - WHERE session_token = :session_token - AND session_id = :session_id - AND ip_address = :ip_address - AND init_time = :init_time - "; - $pdo->execute($last_active_query, array( - ':last_active' => $last_active_time, - ':session_token' => $token, - ':session_id' => $session_id, - ':ip_address' => $ip_address, - ':init_time' => $_SESSION['xdInit'], - )); - - $user = XDUser::getUserByID($user_check[0]['user_id']); - - if ($user == null) { - throw new \Exception('Invalid token specified'); - } - - return $user; - } else { - - // An error occurred (session is intact, yet a - // corresponding record pertaining to that session - // does not exist in the DB) - throw new \Exception('Invalid token specified'); - } - } - - /** - * Get the current epoch time in micro seconds. - * - * @return int - */ - private static function getMicrotime() - { - list($usec, $sec) = explode(' ', microtime()); - return $usec + $sec; - } -} diff --git a/classes/Rest/Utilities/Conversions.php b/classes/Rest/Utilities/Conversions.php deleted file mode 100644 index b945478571..0000000000 --- a/classes/Rest/Utilities/Conversions.php +++ /dev/null @@ -1,57 +0,0 @@ - $value) { - $result .= "$key: $value, "; - } - $result .= " )"; - } elseif ($isArray && !$isAssociativeArray) { - $result .= "( "; - $result .= implode(", ", $value); - $result .= " )"; - } else { - $result = strval($value); - } - - return $result; - } - - private static function isAssoc($values) - { - if (!is_array($values)) { - return false; - } - return (bool)count(array_filter(array_keys($values), 'is_string')); - } -} diff --git a/classes/Rest/XdmodApplicationFactory.php b/classes/Rest/XdmodApplicationFactory.php index 59b43a1386..e69de29bb2 100644 --- a/classes/Rest/XdmodApplicationFactory.php +++ b/classes/Rest/XdmodApplicationFactory.php @@ -1,266 +0,0 @@ -register(new \Silex\Provider\RoutingServiceProvider()); - - // SET: the regex that will be used to filter the API_SYMBOL in a route. - // in this case we're using it as our base url. - $app['controllers']->assert(self::API_SYMBOL, self::API_REGEX); - - // Set the default value for the REST API version to a string - // representing the latest version. - $app['controllers']->value(self::API_SYMBOL, 'latest'); - - $app['logger.db'] = function () { - return \CCR\Log::factory('rest.logger.db', array( - 'console' => false, - 'file' => false, - 'mail' => false, - 'dbLogLevel' => \CCR\Log::INFO - )); - }; - - $app->before(function (Request $request, Application $app) { - $request->attributes->set('timing.start', microtime(true)); - return $app; - }, Application::EARLY_EVENT); - - // SETUP: a before middleware that detects / starts the query debug mode for a request. - $app->before(function (Request $request, Application $app) { - if ($request->query->getBoolean('debug')) { - PDODB::debugOn(); - } - }); - - // SETUP: the authentication Middleware to be run before the route is. - $app->before("\Rest\Controllers\BaseControllerProvider::authenticate", Application::EARLY_EVENT); - - $app->after(function (Request $request, Response $response, Application $app) { - $logger = $app['logger.db']; - - $retval = array('message' => "Route called"); - - $authInfo = Authentication::getAuthenticationInfo($request); - if (!isset($authInfo['username']) && $request->attributes->has(BaseControllerProvider::_USER)) { - $authInfo['username'] = $request->attributes->get(BaseControllerProvider::_USER)->getUsername(); - } - $method = $request->getMethod(); - $host = $request->getHost(); - $port = $request->getPort(); - - // Extracting any POST variables provided in the Request. - $post = array(); - foreach($request->request->getIterator() as $key => $value) { - if (!in_array($key, self::$loggingBlacklist)) { - $post[$key] = ( - is_string($value) - ? json_decode($value, true) - : null - ); - } - } - - // Calculate the amount of time that has elapsed serving this request. - $start = $request->attributes->get('timing.start'); - $end = microtime(true); - $elapsed = $end - $start; - - $referer = null; - if (isset($_SERVER['HTTP_REFERER'])) { - $referer = $_SERVER['HTTP_REFERER']; - } - - // Begin constructing the value to be logged / "returned". - $retval['path'] = $request->getPathInfo(); - $retval['query'] = $request->getQueryString(); - $retval['referer'] = $referer; - $retval['elapsed'] = $elapsed; - $retval['post'] = $post; - $retval['data'] = array( - 'host' => $host, - 'port' => $port, - 'method' => $method, - 'username' => $authInfo['username'], - 'ip' => $authInfo['ip'], - 'token' => $authInfo['token'], - 'timestamp' => date("Y-m-d H:i:s", $_SERVER['REQUEST_TIME']) - ); - - $logger->info('', $retval); - - }, Application::EARLY_EVENT); - - // SETUP: an after middleware that detects the query debug mode and, if true, retrieves - // and returns the collected sql queries / params. - $app->after(function (Request $request, Response $response, Application $app) { - $origin = $request->headers->get('Origin'); - if ($origin !== null) { - try { - $corsDomains = \xd_utilities\getConfiguration('cors', 'domains'); - if (!empty($corsDomains)){ - $allowedCorsDomains = explode(',', $corsDomains); - if (in_array($origin, $allowedCorsDomains)) { - // If these headers change similar updates will need to be made to the `error` section below - $response->headers->set('Access-Control-Allow-Origin', $origin); - $response->headers->set('Access-Control-Allow-Headers', 'x-requested-with, content-type'); - $response->headers->set('Access-Control-Allow-Credentials', 'true'); - $response->headers->set('Vary', 'Origin'); - } - } - } catch (\Exception $e) { - // this catches if the section or config item does not exist - // in that case we just carry on - } - } - if (PDODB::debugging()) { - $debugInfo = PDODB::debugInfo(); - - $contentType = $response->headers->get('content-type', null); - if ('application/json' === strtolower($contentType)) { - $content = $response->getContent(); - $jsonContent = json_decode($content); - - if (is_array($jsonContent)) { - foreach ($jsonContent as $entry) { - if (is_object($entry)) { - $entry->debug = $debugInfo; - break; - } - } - } elseif (is_object($jsonContent)) { - $jsonContent->debug = $debugInfo; - } - - - $response->setContent(json_encode($jsonContent)); - } - } - }); - - // MOUNT: our Controllers ( note: this calls the BaseControllerProvider::connect method ) - // which calls each of the abstract methods in turn. - $versionedPathMountPoint = "/{" . self::API_SYMBOL . "}"; - $unversionedPathMountPoint = ''; - - // Retrieve the rest end point configuration - $restControllers = XdmodConfiguration::assocArrayFactory('rest.json', CONFIG_DIR); - - foreach ($restControllers as $key => $config) { - if (!array_key_exists('prefix', $config) || !array_key_exists('controller', $config)) { - throw new \Exception("Required REST endpoint information (prefix or controller) missing for $key."); - } - - $prefix = $config['prefix']; - $ControllerClass = $config['controller']; - $controller = new $ControllerClass( - array( - 'prefix' => $prefix - ) - ); - - $app->mount($versionedPathMountPoint, $controller); - $app->mount($unversionedPathMountPoint, $controller); - } - - // SETUP: error handler - $app->error(function (\Exception $e, Request $request, $code) { - if($code == 405 && strtoupper($_SERVER['REQUEST_METHOD']) === 'OPTIONS' && array_key_exists('HTTP_ORIGIN', $_SERVER)){ - try { - $corsDomains = \xd_utilities\getConfiguration('cors', 'domains'); - } catch (\Exception $cors) { - $corsDomains = null; - } - if (!empty($corsDomains)){ - $allowedCorsDomains = explode(',', $corsDomains); - $origin = $_SERVER['HTTP_ORIGIN']; - if (in_array($origin, $allowedCorsDomains)) { - // if these headers change we will need to update the `after` above - return new Response( - '', - 204, /* in `$app->error` this value is ignored use header `X-Status-Code` to force a different status code */ - [ - 'X-Status-Code' => 204, - 'Vary' => 'Origin', - 'Access-Control-Allow-Origin' => $origin, - 'Access-Control-Allow-Headers' => 'x-requested-with, content-type', - 'Access-Control-Allow-Credentials' => 'true' - ] - ); - } - } - } - $exceptionOutput = \handle_uncaught_exception($e); - return new Response( - $exceptionOutput['content'], - $exceptionOutput['httpCode'], - $exceptionOutput['headers'] - ); - }); - - // Set the application instance as the global instance and return it. - self::$instance = $app; - return $app; - } // getInstance() -} diff --git a/classes/UserStorage.php b/classes/UserStorage.php index ae5e2cb0bd..95a4cdd914 100644 --- a/classes/UserStorage.php +++ b/classes/UserStorage.php @@ -79,7 +79,7 @@ public function insert(&$data) private function _getnewid(&$storage) { - $newid = ($storage['maxid'] + 1) % PHP_INT_MAX; + $newid = ((int)($storage['maxid'] + 1)) % PHP_INT_MAX; while(isset($storage['data'][$newid])) { $newid = ($newid + 1) % PHP_INT_MAX; } diff --git a/classes/XDChartPool.php b/classes/XDChartPool.php index e96b3ff9bb..0d5ac8a961 100644 --- a/classes/XDChartPool.php +++ b/classes/XDChartPool.php @@ -9,50 +9,50 @@ * of visiting the portal. * */ - + class XDChartPool { private $_user = null; - + private $_user_id = null; private $_person_id = null; private $_user_full_name = null; private $_user_email = null; private $_user_token = null; - + private $_table_name = 'ChartPool'; - + private $_pdo = null; - + // -------------------------------------------- - + public function __construct($user) { - + $this->_pdo = DB::factory('database'); - + $this->_user = $user; $this->_user_id = $user->getUserID(); $this->_person_id = $user->getPersonID(); $this->_user_full_name = $user->getFormalName(); $this->_user_token = $user->getToken(); - - $this->_user_email = (xd_utilities\getConfiguration('general', 'debug_mode') == 'on') ? - xd_utilities\getConfiguration('general', 'debug_recipient') : - $user->getEmailAddress(); - + + $this->_user_email = (xd_utilities\getConfiguration('general', 'debug_mode') == 'on') ? + xd_utilities\getConfiguration('general', 'debug_recipient') : + $user->getEmailAddress(); + }//__construct // -------------------------------------------- - + public function emptyCache() { - + $this->_pdo->execute( 'UPDATE ChartPool SET image_data=NULL WHERE user_id=:user_id', array( 'user_id' => $this->_user_id ) ); - + }//emptyCache public function addChartToQueue($chartIdentifier, $chartTitle, $chartDrillDetails, $chartDateDesc) { @@ -64,40 +64,40 @@ public function addChartToQueue($chartIdentifier, $chartTitle, $chartDrillDetail if (empty($chartTitle)){ throw new Exception("A chart title must be specified"); } - + // Since we are now letting the user have full control over the titles of charts (courtesy of the Metric Explorer), // we need to make sure the title is escaped properly such that the thumbnails in the Report Generator don't break. - + $chartIdentifier = str_replace("title=".$chartTitle, "title=".urlencode($chartTitle), $chartIdentifier); - + if ($this->chartExistsInQueue($chartIdentifier)){ throw new Exception("chart_exists_in_queue"); } - + $insertQuery = "INSERT INTO {$this->_table_name} (user_id, chart_id, chart_title, chart_drill_details, chart_date_description, type) VALUES " . "(:user_id, :chart_id, :chart_title, :chart_drill_details, :chart_date_description, 'image')"; - + $this->_pdo->execute( - $insertQuery, + $insertQuery, array( 'user_id' => $this->_user_id, 'chart_id' => $chartIdentifier, - 'chart_title'=> $chartTitle, + 'chart_title'=> $chartTitle, 'chart_date_description' => $chartDateDesc, 'chart_drill_details'=> $chartDrillDetails ) ); - + }//addChartToQueue - + // -------------------------------------------- - + public function removeChartFromQueue($chartIdentifier) { - + if (empty($chartIdentifier)){ throw new Exception("A chart identifier must be specified"); } - + if (!$this->chartExistsInQueue($chartIdentifier)){ throw new Exception("chart_does_not_exist_in_queue"); } @@ -105,21 +105,26 @@ public function removeChartFromQueue($chartIdentifier) { $this->_pdo->execute("DELETE FROM {$this->_table_name} WHERE user_id = :user_id AND chart_id = :chart_id", array('user_id' => $this->_user_id, 'chart_id' => $chartIdentifier)); }//removeChartFromQueue - + // -------------------------------------------- - + public function chartExistsInQueue($chartIdentifier, $chartTitle = '') { - + if (empty($chartIdentifier)){ //throw new Exception("A chart identifier must be specified"); } + // This has been added due to urlencode no longer supporting nulls ( PHP 8.2 ) + if (is_null($chartTitle)) { + $chartTitle = ''; + } + $chartIdentifier = str_replace("title=".$chartTitle, "title=".urlencode($chartTitle), $chartIdentifier); - + $results = $this->_pdo->query("SELECT * FROM {$this->_table_name} WHERE user_id = :user_id AND chart_id = :chart_id", array('user_id' => $this->_user_id, 'chart_id' => $chartIdentifier)); - + return (count($results) != 0); - + }//chartExistsInQueue - + }//XDChartPool diff --git a/classes/XDReportManager.php b/classes/XDReportManager.php index aa037fa91c..076863c9d9 100644 --- a/classes/XDReportManager.php +++ b/classes/XDReportManager.php @@ -1140,7 +1140,8 @@ private function ripTransform(&$arr, $item) public function fetchChartBlob( $type, $insertion_rank, - $chart_id_cache_file = null + $chart_id_cache_file = null, + $logger = null ) { $pdo = DB::factory('database'); $trace = ""; @@ -1153,7 +1154,7 @@ public function fetchChartBlob( ); if (file_exists($temp_file)) { - print file_get_contents($temp_file); + return file_get_contents($temp_file); } else { if ( @@ -1206,10 +1207,8 @@ public function fetchChartBlob( file_put_contents($temp_file, $blob); - print $blob; + return $blob; } - - exit; break; case 'chart_pool': $this->ripTransform($insertion_rank, 'did'); @@ -1234,7 +1233,7 @@ public function fetchChartBlob( $temp_file = $this->generateCachedFilename($insertion_rank); if (file_exists($temp_file)) { - print file_get_contents($temp_file); + return file_get_contents($temp_file); } else { $blob = $this->generateChartBlob( @@ -1244,11 +1243,8 @@ public function fetchChartBlob( $insertion_rank['end_date'] ); file_put_contents($temp_file, $blob); - print $blob; + return $blob; } - - exit; - break; case 'report': $iq = $pdo->query( " @@ -1437,10 +1433,13 @@ public function generateChartBlob( $type, $insertion_rank, $start_date, - $end_date + $end_date, + $logger = null ) { $pdo = DB::factory('database'); - + if (!is_null($logger)) { + $logger->debug("Generating Chart Blob - Type: $type"); + } switch ($type) { case 'volatile': $temp_file = $this->generateCachedFilename( @@ -1451,6 +1450,9 @@ public function generateChartBlob( $temp_file = str_replace('.png', '.xrc', $temp_file); $iq = array(); + if (!is_null($logger)) { + $logger->debug("Checking if Volatile File Exists; $temp_file"); + } if (file_exists($temp_file) == true) { $chart_id_config = file($temp_file); @@ -1465,7 +1467,6 @@ public function generateChartBlob( ); } break; - case 'chart_pool': $iq = $pdo->query( " @@ -1499,7 +1500,7 @@ public function generateChartBlob( } if (count($iq) == 0) { - throw new \Exception("Unable to target chart entry"); + throw new \Exception("Unable to target chart entry $type {$this->_user_id} $insertion_rank ". (new \Exception())->getTraceAsString()); } $chart_id = $iq[0]['chart_id']; diff --git a/classes/XDSessionManager.php b/classes/XDSessionManager.php index 1f6f537b6d..c32c441839 100644 --- a/classes/XDSessionManager.php +++ b/classes/XDSessionManager.php @@ -88,10 +88,10 @@ public static function recordLogin($user) ':last_active' => $init_time, )); - $_SESSION['xdInit'] = $init_time; - $_SESSION['xdUser'] = $user_id; - - $_SESSION['session_token'] = $session_token; + $session = \xd_security\SessionSingleton::getSession(); + $session->set('xdInit', $init_time); + $session->set('xdUser', $user_agent); + $session->set('session_token', $session_token); return $session_token; } @@ -107,12 +107,13 @@ public static function logoutUser($token = "") \xd_security\start_session(); } + $session = \xd_security\SessionSingleton::getSession(); // If a session is still active and a token has been specified, // attempt to record the logout in the SessionManager table // (provided the supplied token is still 'valid' and a // corresponding record in SessionManager can be found) - if (isset($_SESSION['xdInit']) && !empty($token)) { + if ($session->get('xdInit') !== null && !empty($token)) { $session_id = session_id(); $ip_address = $_SERVER['REMOTE_ADDR']; @@ -129,10 +130,11 @@ public static function logoutUser($token = "") ':session_token' => $token, ':session_id' => $session_id, ':ip_address' => $ip_address, - ':init_time' => $_SESSION['xdInit'], + ':init_time' => $session->get('xdInit'), )); } + $session->invalidate(); // Drop the session so that any REST calls requiring // authentication (via tokens) trip the first Exception as the // result of invoking resolveUserFromToken($token) @@ -142,10 +144,10 @@ public static function logoutUser($token = "") $auth = new Authentication\SAML\XDSamlAuthentication(); $auth->logout(); } catch (InvalidArgumentException $ex) { - // This will catch when apache or nginx have been set up - // to to have an alternate saml configuration directory - // that does not exist, so we ignore it as saml isnt set - // up and we dont have to do anything with it + // This will catch when apache or nginx have been set up + // to to have an alternate saml configuration directory + // that does not exist, so we ignore it as saml isnt set + // up and we dont have to do anything with it } } diff --git a/classes/XDUser.php b/classes/XDUser.php index f72135e252..561212d4cc 100644 --- a/classes/XDUser.php +++ b/classes/XDUser.php @@ -7,13 +7,19 @@ use Models\Services\Acls; use Models\Services\Organizations; use DataWarehouse\Query\Exceptions\AccessDeniedException; +use xd_security\SessionSingleton; + +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\User\LegacyPasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; /** * XDMoD Portal User * * @Class XDUser */ -class XDUser extends CCR\Loggable implements JsonSerializable +class XDUser extends CCR\Loggable implements JsonSerializable, UserInterface, PasswordAuthenticatedUserInterface, LegacyPasswordAuthenticatedUserInterface { private $_pdo; // PDO Handle (set in __construct) @@ -166,6 +172,11 @@ class XDUser extends CCR\Loggable implements JsonSerializable * @var boolean */ private $sticky; + + /** + * @var PasswordHasherInterface + */ + private $hasher; // --------------------------- /* @@ -194,9 +205,11 @@ function __construct( $organization_id = null, $person_id = null, array $ssoAttrs = array(), - $sticky = false - ) { - + $sticky = false, + $hasher = null + ) + { + $this->hasher = $hasher; $this->_pdo = DB::factory('database'); $userCheck = $this->_pdo->query("SELECT id FROM Users WHERE username=:username", array( @@ -267,7 +280,7 @@ function __construct( 'db' => false, 'mail' => false, 'console' => false, - 'file'=> LOG_DIR . "/" . xd_utilities\getConfiguration('general', 'exceptions_logfile') + 'file' => LOG_DIR . "/" . xd_utilities\getConfiguration('general', 'exceptions_logfile') ) ) ); @@ -622,7 +635,6 @@ public static function getUserByID($uid, &$targetInstance = NULL) // the results will be the same. $user->_roles = $user->getAcls(true); - return $user; }//getUserByID @@ -643,7 +655,7 @@ public function setPassword($raw_password) throw new AccessDeniedException("Permission Denied. Only local accounts may have their passwords modified."); } - return $this->_password = $raw_password; + $this->_password = $this->hash($raw_password); }//setPassword // --------------------------- @@ -876,10 +888,18 @@ public function getInsertQuery($updateToken = false, $includePassword = false) */ public function arrayToString($array = array()) { + $values = array_reduce( + array_values($array), + function ($carry, $item) { + $carry[] = var_export($item, true); + return $carry; + }, + [] + ); $result = 'Keys [ '; $result .= implode(', ', array_keys($array)) . ']'; $result .= 'Values [ '; - $result .= implode(', ', array_values($array)) . ']'; + $result .= implode(', ', $values) . ']'; return $result; } @@ -955,15 +975,18 @@ public function saveUser() } $update_data['username'] = $this->_username; - $includePassword = strlen($this->_password) <= CHARLIM_PASSWORD; + $includePassword = empty($this->_password) || strlen($this->_password) <= CHARLIM_PASSWORD; if ($includePassword) { if ($this->_password == "" || is_null($this->_password)) { $update_data['password'] = NULL; + } else if (!$forUpdate) { + $this->_password = $this->hash($this->_password); + $update_data['password'] = $this->_password; } else { - $this->_password = password_hash($this->_password, PASSWORD_DEFAULT); $update_data['password'] = $this->_password; } } + $update_data['email_address'] = ($this->_email); $update_data['first_name'] = ($this->_firstName); $update_data['middle_name'] = ($this->_middleName); @@ -1202,15 +1225,14 @@ public function removeUser() // --------------------------- - /* + /** * * @function getUserType; * * @return int (maps to one of the TYPE_* class constants at the top of this file) * */ - - public function getUserType() + public function getUserType(): int { return $this->_user_type; } @@ -1505,7 +1527,7 @@ public function enumAllAvailableRoles() "A PDOException was thrown in 'XDUser::enumAllAvailableRoles'", array( 'exception' => $e, - 'sql'=> $query + 'sql' => $query ) ); @@ -1780,7 +1802,7 @@ public function getActiveOrganization() * */ - public function getRoles($flag = 'informal') + public function getRoles($flag = 'informal'): array { if ($flag == 'informal') { @@ -1816,7 +1838,7 @@ public function getRoles($flag = 'informal') return $roles; } - + return []; }//getRoles // --------------------------- @@ -1895,7 +1917,7 @@ function getAllRoles($includePublicRole = false) public function getUserID() { - return (empty($this->_id)) ? '0' : $this->_id; + return (empty($this->_id)) ? 0 : (int)$this->_id; } /* @@ -1913,13 +1935,14 @@ public function getPersonID($default = FALSE) { // NOTE: RESTful services do not operate on the concept of a session, so we need to check for $_SESSION[..] entities using isset - - if (isset($_SESSION['xdUser']) && ($_SESSION['xdUser'] == $this->_id) && ($default == FALSE)) { + $session = \xd_security\SessionSingleton::getSession(); + $xdUserId = $session->get('xdUser'); + if (isset($xdUserId) && ($xdUserId === $this->_id) && ($default == FALSE)) { // The user object pertains to the user logged in.. - - if (isset($_SESSION['assumed_person_id'])) { - return $_SESSION['assumed_person_id']; + $assumedPersonId = $session->get('assumed_person_id'); + if (isset($assumedPersonId)) { + return $assumedPersonId; } } @@ -1993,7 +2016,7 @@ public function getUpdateTimestamp() * (determines the formal description of a role based on its abbreviation) * * @param string $role_abbrev the role abbreviation to use when looking up the formal name. - * @param bool $pubDisplay Determines whether or not to return the public roles `display` + * @param bool $pubDisplay Determines whether or not to return the public roles `display` * property or it's `name` property. We default to true ( i.e. `display` ) as that is the * behavior that currently exists. * @@ -2127,7 +2150,7 @@ public function setAcls(array $acls) */ public function addAcl(Acl $acl, $overwrite = false) { - if ( ( !array_key_exists($acl->getName(), $this->_acls) && !$overwrite ) || + if ((!array_key_exists($acl->getName(), $this->_acls) && !$overwrite) || $overwrite === true ) { $this->_acls[$acl->getName()] = $acl; @@ -2233,7 +2256,7 @@ public static function getUserByUserName($username) * have the data XDMoD is providing to them filtered by a particular * organization. * - * @param string $aclName the name of the acl that should have a + * @param string $aclName the name of the acl that should have a * relationship created for it with the * provided organization. * @param string $organizationId the name of the organization @@ -2254,7 +2277,7 @@ public function addAclOrganization($aclName, $organizationId) $acl = Acls::getAclByName($aclName); - if ( null == $acl) { + if (null == $acl) { throw new Exception("Unable to retrieve acl for: $aclName"); } @@ -2267,7 +2290,7 @@ public function addAclOrganization($aclName, $organizationId) $this->_pdo->execute($cleanUserAclGroupByParameters, array( ':user_id' => $this->_id, - ':acl_id' => $acl->getAclId() + ':acl_id' => $acl->getAclId() )); $populateUserAclGroupByParameters = <<_pdo->execute($populateUserAclGroupByParameters, array( ':user_id' => $this->_id, - ':acl_id' => $acl->getAclId(), - ':value' => $organizationId + ':acl_id' => $acl->getAclId(), + ':value' => $organizationId )); } // addAclOrganization - /** + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php * @return mixed data which can be serialized by json_encode, * which is a value of any type other than a resource. * @since 5.4.0 */ - public function jsonSerialize() + public function jsonSerialize(): mixed { $ignored = array( - '_pdo', '_primary_role', '_publicUser', '_timeCreated','_timeUpdated', + '_pdo', '_primary_role', '_publicUser', '_timeCreated', '_timeUpdated', '_timePasswordUpdated', '_token', 'logger' ); $reflection = new ReflectionClass($this); $results = array(); $properties = $reflection->getProperties(); - foreach($properties as $property) { + foreach ($properties as $property) { $name = $property->getName(); if (!in_array($name, $ignored)) { $property->setAccessible(true); @@ -2353,7 +2376,7 @@ public function setOrganizationID($organizationID) * authenticating / authorizing a password reset. If an $expiration value is provided, that will * be used instead of generating one via the 'email_token_expiration' portal settings value. * - * @param int|null $expiration the date after which this rid is considered invalid. + * @param int|null $expiration the date after which this rid is considered invalid. * @return string in the form "userId|expiration|hash" * @throws Exception If there are any missing configuration properties that this function relies * on. These include: email_token_expiration and application_secret. @@ -2427,7 +2450,7 @@ public static function validateRID($rid) } catch (Exception $e) { // If there was an exception then it was because we couldn't find a user by that username // so log the error and return the default information. - $expirationDate = date('Y-m-d H:i:s', $expiration ); + $expirationDate = date('Y-m-d H:i:s', $expiration); $log->debug("Error occurred while validating RID for User: $userId, Expiration: $expirationDate"); } @@ -2439,7 +2462,8 @@ public static function validateRID($rid) * * @throws Exception if there is a problem executing any of the required post logged in steps. */ - public function postLogin() { + public function postLogin() + { if (!$this->isSticky()) { $this->updatePerson(); $this->synchronizeOrganization(); @@ -2469,12 +2493,12 @@ public function synchronizeOrganization() // If we have ssoAttrs available and this user's person's organization is 'Unknown' ( -1 ). // Then go ahead and lookup the organization value from sso. - if ($expectedOrganization == -1 && isset($this->ssoAttrs['organization']) && count($this->ssoAttrs['organization']) > 0) { - $expectedOrganization = Organizations::getIdByName($this->ssoAttrs['organization'][0]); + if ($expectedOrganization == -1 && count($this->ssoAttrs) > 0) { + $expectedOrganization = Organizations::getIdByName($this->getSSOAttribute('organization')); } // If these don't match then the user's organization has been updated. Steps need to be taken. - if ($actualOrganization !== $expectedOrganization) { + if ($actualOrganization != $expectedOrganization) { $originalAcls = $this->getAcls(true); // if the user is currently assigned an acl that interacts with XDMoD's centers ( i.e. @@ -2493,7 +2517,7 @@ public function synchronizeOrganization() $this->setAcls(array()); // Update the user w/ their new set of acls. - foreach($otherAcls as $aclName) { + foreach ($otherAcls as $aclName) { $acl = Acls::getAclByName($aclName); $this->addAcl($acl); } @@ -2541,7 +2565,6 @@ public function synchronizeOrganization() ) ); } - // Update / save the user with their new organization $this->setOrganizationId($expectedOrganization); $this->saveUser(); @@ -2560,14 +2583,15 @@ public function updatePerson() $hasSSO = count($this->ssoAttrs) > 0; if ($currentPersonId == PERSON_ID_UNASSOCIATED && $hasSSO) { - $username = $this->ssoAttrs['username'][0]; - $systemUserName = isset($this->ssoAttrs['system_username']) ? $this->ssoAttrs['system_username'][0] : $username; + $username = $this->getSSOAttribute('username'); + $systemUserName = $this->getSSOAttribute('system_username', $username); $expectedPersonId = \DataWarehouse::getPersonIdFromPII($systemUserName, null); // As long as the identified person is not Unknown and it is different than our current Person Id // go ahead and update this user with the new person & that person's organization. if ($expectedPersonId != PERSON_ID_UNASSOCIATED && $currentPersonId != $expectedPersonId) { $organizationId = Organizations::getOrganizationIdForPerson($expectedPersonId); + $this->setPersonID($expectedPersonId); $this->setOrganizationID($organizationId); @@ -2668,4 +2692,114 @@ function ($value) use ($handle) { return $db->query($query, $params); } // public function getResources($resourceNames = array()) + + public function getPassword(): ?string + { + return $this->_password; + } + + + + public function getSalt(): ?string + { + return null; + } + + public function eraseCredentials() + { + // This function is required for Symfony's UserInterface but we don't actually support erasing a users credentials. + } + + public function getUserIdentifier(): string + { + return $this->_username; + } + + public function __serialize(): array + { + return [ + $this->_id, + $this->_username, + $this->_password, + $this->_email, + $this->_firstName, + $this->_middleName, + $this->_lastName, + $this->_timeCreated, + $this->_timeUpdated, + $this->_timePasswordUpdated, + $this->_roles, + $this->_field_of_science, + $this->_organizationID, + $this->_personID, + $this->_user_type, + $this->_token + ]; + } + + public function __unserialize(array $data): void + { + [ + $this->_id, + $this->_username, + $this->_password, + $this->_email, + $this->_firstName, + $this->_middleName, + $this->_lastName, + $this->_timeCreated, + $this->_timeUpdated, + $this->_timePasswordUpdated, + $this->_roles, + $this->_field_of_science, + $this->_organizationID, + $this->_personID, + $this->_user_type, + $this->_token + ] = $data; + } + + private function hash($password) + { + if (!isset($this->hasher)) { + return password_hash($password, PASSWORD_DEFAULT); + } else { + return $this->hasher->hash($password); + } + } + + /** + * Get an SSO Attribute for this user. Handles when the sso attributes are in the form: + * ``` + * [ + * "attributeName" => "attributeValue" + * ] + * ``` + * + * and when they're in the form: + * ``` + * [ + * "attributeName" => [ + * "attributeValue" + * ] + * ] + * ``` + * The latter is the original format of SSO attributes, while the former is the current. + * + * @param string $attributeName the name of the SSO attribute to return. + * @return mixed|null null is returned if the $attributeName does not exist within this users sso attributes, else + * the value of the sso attribute identified by $attributeName is returned. + */ + private function getSSOAttribute($attributeName, $default = null) + { + $result = null; + if (isset($this->ssoAttrs[$attributeName])) { + if (!is_array($this->ssoAttrs[$attributeName])) { + $result = $this->ssoAttrs[$attributeName]; + } else { + $result = $this->ssoAttrs[$attributeName][0]; + } + } + return isset($result) ? $result : $default; + } }//XDUser diff --git a/classes/Xdmod/NodeSet.php b/classes/Xdmod/NodeSet.php index 7eb22141f1..dcee6d56fc 100644 --- a/classes/Xdmod/NodeSet.php +++ b/classes/Xdmod/NodeSet.php @@ -97,7 +97,7 @@ function ($v) use ($node) { /** * @see Iterator */ - public function current() + public function current(): mixed { if (!$this->valid()) { throw new OutOfBoundsException(); @@ -109,7 +109,7 @@ public function current() /** * @see Iterator */ - public function key() + public function key(): mixed { return $this->position; } @@ -117,7 +117,7 @@ public function key() /** * @see Iterator */ - public function next() + public function next(): void { ++$this->position; } @@ -125,7 +125,7 @@ public function next() /** * @see Iterator */ - public function rewind() + public function rewind(): void { $this->position = 0; } @@ -133,7 +133,7 @@ public function rewind() /** * @see Iterator */ - public function valid() + public function valid(): bool { return isset($this->nodes[$this->position]); } diff --git a/composer.json b/composer.json index 932d2e8a89..9a99c0cbd2 100644 --- a/composer.json +++ b/composer.json @@ -1,43 +1,62 @@ { - "extra": { - "COMMENT": "If kassner/log-parser is updated to version >2.1.1, then the call to web_parser->addPattern in classes/ETL/DataEndpoint/WebServerLogFile.php (added in https://github.com/ubccr/xdmod/pull/1816) can be removed along with this 'extra' section." - }, + "type": "project", + "license": "lgpl", + "minimum-stability": "stable", + "prefer-stable": true, "require": { - "php": "^7.4", - "egulias/email-validator": "^1.2", - "google/recaptcha": "~1.1", - "greenlion/php-sql-parser": "~4.2", - "ircmaxell/password-compat": "~1", - "justinrainbow/json-schema": "~5.2", - "jquery/jquery-min-file":"^3.7.1", + "php": "^8.2", + "doctrine/annotations": "^2.0", + "doctrine/dbal": "^3", + "doctrine/doctrine-bundle": "^2.14", + "doctrine/doctrine-migrations-bundle": "^3.4", + "doctrine/orm": "^3.0", + "egulias/email-validator": "^4", + "firebase/php-jwt": "^6.10", + "geoip2/geoip2": "^2.12", + "google/recaptcha": "^1.2", + "greenlion/php-sql-parser": "^4.7", + "ircmaxell/password-compat": "^1.0", + "jquery/jquery-min-file": "^3.7.1", + "justinrainbow/json-schema": "^6.3.1", + "kassner/log-parser": "^2.1", "moment/moment-min-file": "^2.13.0", "moment/moment-timezone-min-file": "^0.5.4", - "paragonie/random_compat": "~2.0", - "phpmailer/phpmailer": "~6.9", + "mongodb/mongodb": "1.18.0", + "monolog/monolog": "^3", + "phpdocumentor/reflection-docblock": "^5.6", + "phpmailer/phpmailer": "^6.9", + "phpoffice/phpword": "^1.3.0", + "phpstan/phpdoc-parser": "^2.1", + "plotly/plotly": "^2.29.1", "robrichards/xmlseclibs": "~3.0", "sencha/extjs-gpl": "3.4.*", - "silex/silex": "v2.3.0", - "simplesamlphp/simplesamlphp": "^1.16", - "symfony/polyfill-php56": "~1.11", - "symfony/process": "~2.0", + "simplesamlphp/simplesamlphp": "*", + "swaggest/json-schema": "^0.12.41", + "symfony/asset": "6.4.*", + "symfony/console": "6.4.*", + "symfony/dotenv": "6.4.*", + "symfony/flex": "^1.17|^2", + "symfony/framework-bundle": "6.4.*", + "symfony/monolog-bundle": "^3.8", + "symfony/property-access": "6.4.*", + "symfony/property-info": "6.4.*", + "symfony/proxy-manager-bridge": "6.4.*", + "symfony/runtime": "6.4.*", + "symfony/security-bundle": "6.4.*", + "symfony/serializer": "6.4.*", + "symfony/twig-bundle": "6.4.*", + "symfony/yaml": "6.4.*", "taq/pdooci": "^1.0", "tildeio/rsvpjs-min-file": "^3.0.18", - "ubccr/simplesamlphp-module-authglobus": "^1.3", - "ubccr/simplesamlphp-module-authoidcoauth2": "^1.1", - "phpoffice/phpword": "^1.2.0", - "monolog/monolog": "^1.25", - "plotly/plotly": "^2.29.1", - "kassner/log-parser": "~1.5", - "geoip2/geoip2": "~2.0", - "ua-parser/uap-php": "^3.9", - "mongodb/mongodb": "^1.14", - "firebase/php-jwt": "^6.10" + "ua-parser/uap-php": "^3.9" }, "require-dev": { - "phpunit/phpunit": "^9.0", "ccampbell/chromephp": "^4.1", - "swaggest/json-schema": "^0.12.41", - "dms/phpunit-arraysubset-asserts": "^0.5.0" + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "phpunit/phpunit": "^9.0", + "symfony/maker-bundle": "^1.43", + "symfony/stopwatch": "6.4.*", + "symfony/web-profiler-bundle": "6.4.*" }, "repositories": [ { @@ -201,6 +220,10 @@ "external_libraries/{$name}": [ "zendframework/zendframework-minimal" ] + }, + "public-dir": "html", + "symfony": { + "docker": false } }, "config": { @@ -209,8 +232,16 @@ "secure-http": false, "allow-plugins": { "composer/installers": true, - "simplesamlphp/composer-module-installer": true - } + "composer/package-versions-deprecated": true, + "simplesamlphp/composer-module-installer": true, + "simplesamlphp/composer-xmlprovider-installer": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "preferred-install": { + "*": "dist" + }, + "sort-packages": true }, "autoload": { "files": [ @@ -262,7 +293,28 @@ "Reports\\": "classes/Reports/", "Rest\\": "classes/Rest/", "User\\": "classes/User/", - "Xdmod\\": "classes/Xdmod/" + "Xdmod\\": "classes/Xdmod/", + "Access\\": "src/" } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" } } diff --git a/composer.lock b/composer.lock index b065b79539..42c0cd2149 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b1f6a77651cfce3d29c0e5a886429ef7", + "content-hash": "3c1bfd67858c59820eaeb68bceab4d71", "packages": [ { "name": "composer/ca-bundle", - "version": "1.5.0", + "version": "1.5.7", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "0c5ccfcfea312b5c5a190a21ac5cef93f74baf99" + "reference": "d665d22c417056996c59019579f1967dfe5c1e82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0c5ccfcfea312b5c5a190a21ac5cef93f74baf99", - "reference": "0c5ccfcfea312b5c5a190a21ac5cef93f74baf99", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/d665d22c417056996c59019579f1967dfe5c1e82", + "reference": "d665d22c417056996c59019579f1967dfe5c1e82", "shasum": "" }, "require": { @@ -27,8 +27,8 @@ }, "require-dev": { "phpstan/phpstan": "^1.10", - "psr/log": "^1.0", - "symfony/phpunit-bridge": "^4.2 || ^5", + "phpunit/phpunit": "^8 || ^9", + "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, "type": "library", @@ -64,7 +64,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.0" + "source": "https://github.com/composer/ca-bundle/tree/1.5.7" }, "funding": [ { @@ -80,7 +80,7 @@ "type": "tidelift" } ], - "time": "2024-03-15T14:00:32+00:00" + "time": "2025-05-26T15:08:54+00:00" }, { "name": "composer/installers", @@ -234,32 +234,40 @@ "time": "2021-09-13T08:19:44+00:00" }, { - "name": "doctrine/lexer", - "version": "1.2.3", + "name": "doctrine/annotations", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/doctrine/lexer.git", - "reference": "c268e882d4dbdd85e36e4ad69e02dc284f89d229" + "url": "https://github.com/doctrine/annotations.git", + "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/c268e882d4dbdd85e36e4ad69e02dc284f89d229", - "reference": "c268e882d4dbdd85e36e4ad69e02dc284f89d229", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/901c2ee5d26eb64ff43c47976e114bf00843acf7", + "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "doctrine/lexer": "^2 || ^3", + "ext-tokenizer": "*", + "php": "^7.2 || ^8.0", + "psr/cache": "^1 || ^2 || ^3" }, "require-dev": { - "doctrine/coding-standard": "^9.0", - "phpstan/phpstan": "^1.3", + "doctrine/cache": "^2.0", + "doctrine/coding-standard": "^10", + "phpstan/phpstan": "^1.10.28", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.11" + "symfony/cache": "^5.4 || ^6.4 || ^7", + "vimeo/psalm": "^4.30 || ^5.14" + }, + "suggest": { + "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" + "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" } }, "notification-url": "https://packagist.org/downloads/", @@ -275,66 +283,62 @@ "name": "Roman Borschel", "email": "roman@code-factory.org" }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, { "name": "Johannes Schmitt", "email": "schmittjoh@gmail.com" } ], - "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "description": "Docblock Annotations Parser", + "homepage": "https://www.doctrine-project.org/projects/annotations.html", "keywords": [ "annotations", "docblock", - "lexer", - "parser", - "php" + "parser" ], "support": { - "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/1.2.3" + "issues": "https://github.com/doctrine/annotations/issues", + "source": "https://github.com/doctrine/annotations/tree/2.0.2" }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" - } - ], - "time": "2022-02-28T11:07:21+00:00" + "time": "2024-09-05T10:17:24+00:00" }, { - "name": "egulias/email-validator", - "version": "1.2.17", + "name": "doctrine/collections", + "version": "2.3.0", "source": { "type": "git", - "url": "https://github.com/egulias/EmailValidator.git", - "reference": "19674b35a0a3456be1b96e137098d31ed386fb61" + "url": "https://github.com/doctrine/collections.git", + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/19674b35a0a3456be1b96e137098d31ed386fb61", - "reference": "19674b35a0a3456be1b96e137098d31ed386fb61", + "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", + "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", "shasum": "" }, "require": { - "doctrine/lexer": "^1.0.1", - "php": ">=5.3.3" + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "phpunit/phpunit": "^4.8.36|^7.5.15", - "satooshi/php-coveralls": "^1.0.1" + "doctrine/coding-standard": "^12", + "ext-json": "*", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^10.5" }, "type": "library", "autoload": { - "psr-0": { - "Egulias\\": "src/" + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -343,179 +347,286 @@ ], "authors": [ { - "name": "Eduardo Gulias Davis" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "A library for validating emails", - "homepage": "https://github.com/egulias/EmailValidator", + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", "keywords": [ - "email", - "emailvalidation", - "emailvalidator", - "validation", - "validator" + "array", + "collections", + "iterators", + "php" ], "support": { - "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/1.2" + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.3.0" }, - "time": "2020-04-11T12:59:45+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2025-03-22T10:17:19+00:00" }, { - "name": "firebase/php-jwt", - "version": "v6.10.0", + "name": "doctrine/dbal", + "version": "3.10.0", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "a49db6f0a5033aef5143295342f1c95521b075ff" + "url": "https://github.com/doctrine/dbal.git", + "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/a49db6f0a5033aef5143295342f1c95521b075ff", - "reference": "a49db6f0a5033aef5143295342f1c95521b075ff", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/1cf840d696373ea0d58ad0a8875c0fadcfc67214", + "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214", "shasum": "" }, "require": { - "php": "^7.4||^8.0" + "composer-runtime-api": "^2", + "doctrine/deprecations": "^0.5.3|^1", + "doctrine/event-manager": "^1|^2", + "php": "^7.4 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "doctrine/cache": "< 1.11" }, "require-dev": { - "guzzlehttp/guzzle": "^6.5||^7.4", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psr/cache": "^1.0||^2.0", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0" + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "13.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.1", + "phpstan/phpstan": "2.1.17", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "9.6.23", + "slevomat/coding-standard": "8.16.2", + "squizlabs/php_codesniffer": "3.13.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/console": "^4.4|^5.4|^6.0|^7.0" }, "suggest": { - "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "symfony/console": "For helpful console commands such as SQL execution and import of files." }, + "bin": [ + "bin/doctrine-dbal" + ], "type": "library", "autoload": { "psr-4": { - "Firebase\\JWT\\": "src" + "Doctrine\\DBAL\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" }, { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" } ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", "keywords": [ - "jwt", - "php" + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" ], "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.10.0" + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/3.10.0" }, - "time": "2023-12-01T16:26:39+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2025-07-10T21:11:04+00:00" }, { - "name": "geoip2/geoip2", - "version": "v2.13.0", + "name": "doctrine/deprecations", + "version": "1.1.5", "source": { "type": "git", - "url": "https://github.com/maxmind/GeoIP2-php.git", - "reference": "6a41d8fbd6b90052bc34dff3b4252d0f88067b23" + "url": "https://github.com/doctrine/deprecations.git", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/6a41d8fbd6b90052bc34dff3b4252d0f88067b23", - "reference": "6a41d8fbd6b90052bc34dff3b4252d0f88067b23", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", "shasum": "" }, "require": { - "ext-json": "*", - "maxmind-db/reader": "~1.8", - "maxmind/web-service-common": "~0.8", - "php": ">=7.2" + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=13" }, "require-dev": { - "friendsofphp/php-cs-fixer": "3.*", - "phpstan/phpstan": "*", - "phpunit/phpunit": "^8.0 || ^9.0", - "squizlabs/php_codesniffer": "3.*" + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" }, "type": "library", "autoload": { "psr-4": { - "GeoIp2\\": "src" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Gregory J. Oschwald", - "email": "goschwald@maxmind.com", - "homepage": "https://www.maxmind.com/" - } - ], - "description": "MaxMind GeoIP2 PHP API", - "homepage": "https://github.com/maxmind/GeoIP2-php", - "keywords": [ - "IP", - "geoip", - "geoip2", - "geolocation", - "maxmind" + "MIT" ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", "support": { - "issues": "https://github.com/maxmind/GeoIP2-php/issues", - "source": "https://github.com/maxmind/GeoIP2-php/tree/v2.13.0" + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" }, - "time": "2022-08-05T20:32:58+00:00" + "time": "2025-04-07T20:06:18+00:00" }, { - "name": "gettext/gettext", - "version": "v3.6.1", + "name": "doctrine/doctrine-bundle", + "version": "2.15.0", "source": { "type": "git", - "url": "https://github.com/php-gettext/Gettext.git", - "reference": "cd3be64443551e3a693117c4bccbe53e36282456" + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "d88294521a1bca943240adca65fa19ca8a7288c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/cd3be64443551e3a693117c4bccbe53e36282456", - "reference": "cd3be64443551e3a693117c4bccbe53e36282456", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/d88294521a1bca943240adca65fa19ca8a7288c6", + "reference": "d88294521a1bca943240adca65fa19ca8a7288c6", "shasum": "" }, "require": { - "gettext/languages": "2.*", - "php": ">=5.3.0" + "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/persistence": "^3.1 || ^4", + "doctrine/sql-formatter": "^1.0.1", + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5 || ^3" + }, + "conflict": { + "doctrine/annotations": ">=3.0", + "doctrine/cache": "< 1.11", + "doctrine/orm": "<2.17 || >=4.0", + "symfony/var-exporter": "< 6.4.1 || 7.0.0", + "twig/twig": "<2.13 || >=3.0 <3.0.4" }, "require-dev": { - "illuminate/view": "*", - "symfony/yaml": "~2", - "twig/extensions": "*", - "twig/twig": "*" + "doctrine/annotations": "^1 || ^2", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^13", + "doctrine/deprecations": "^1.0", + "doctrine/orm": "^2.17 || ^3.1", + "friendsofphp/proxy-manager-lts": "^1.0", + "phpstan/phpstan": "2.1.1", + "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9.6.22", + "psr/log": "^1.1.4 || ^2.0 || ^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.2", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/twig-bridge": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4.1 || ^7.0.1", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^2.13 || ^3.0.4" }, "suggest": { - "illuminate/view": "Is necessary if you want to use the Blade extractor", - "symfony/yaml": "Is necessary if you want to use the Yaml extractor/generator", - "twig/extensions": "Is necessary if you want to use the Twig extractor", - "twig/twig": "Is necessary if you want to use the Twig extractor" + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "ext-pdo": "*", + "symfony/web-profiler-bundle": "To use the data collector." }, - "type": "library", + "type": "symfony-bundle", "autoload": { "psr-4": { - "Gettext\\": "src" + "Doctrine\\Bundle\\DoctrineBundle\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -524,56 +635,88 @@ ], "authors": [ { - "name": "Oscar Otero", - "email": "oom@oscarotero.com", - "homepage": "http://oscarotero.com", - "role": "Developer" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org/" } ], - "description": "PHP gettext manager", - "homepage": "https://github.com/oscarotero/Gettext", + "description": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", "keywords": [ - "JS", - "gettext", - "i18n", - "mo", - "po", - "translation" + "database", + "dbal", + "orm", + "persistence" ], "support": { - "email": "oom@oscarotero.com", - "issues": "https://github.com/oscarotero/Gettext/issues", - "source": "https://github.com/php-gettext/Gettext/tree/v3.6.1" + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.15.0" }, - "time": "2016-08-01T18:09:57+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2025-06-16T19:53:58+00:00" }, { - "name": "gettext/languages", - "version": "2.10.0", + "name": "doctrine/doctrine-migrations-bundle", + "version": "3.4.2", "source": { "type": "git", - "url": "https://github.com/php-gettext/Languages.git", - "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab" + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-gettext/Languages/zipball/4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", - "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/5a6ac7120c2924c4c070a869d08b11ccf9e277b9", + "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9", "shasum": "" }, "require": { - "php": ">=5.3" + "doctrine/doctrine-bundle": "^2.4", + "doctrine/migrations": "^3.2", + "php": "^7.2 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4" - }, - "bin": [ - "bin/export-plural-rules" - ], - "type": "library", + "composer/semver": "^3.0", + "doctrine/coding-standard": "^12", + "doctrine/orm": "^2.6 || ^3", + "phpstan/phpstan": "^1.4 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpstan/phpstan-symfony": "^1.3 || ^2", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/phpunit-bridge": "^6.3 || ^7", + "symfony/var-exporter": "^5.4 || ^6 || ^7" + }, + "type": "symfony-bundle", "autoload": { "psr-4": { - "Gettext\\Languages\\": "src/" + "Doctrine\\Bundle\\MigrationsBundle\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -582,179 +725,259 @@ ], "authors": [ { - "name": "Michele Locati", - "email": "mlocati@gmail.com", - "role": "Developer" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "gettext languages with plural rules", - "homepage": "https://github.com/php-gettext/Languages", + "description": "Symfony DoctrineMigrationsBundle", + "homepage": "https://www.doctrine-project.org", "keywords": [ - "cldr", - "i18n", - "internationalization", - "l10n", - "language", - "languages", - "localization", - "php", - "plural", - "plural rules", - "plurals", - "translate", - "translations", - "unicode" + "dbal", + "migrations", + "schema" ], "support": { - "issues": "https://github.com/php-gettext/Languages/issues", - "source": "https://github.com/php-gettext/Languages/tree/2.10.0" + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.2" }, "funding": [ { - "url": "https://paypal.me/mlocati", + "url": "https://www.doctrine-project.org/sponsorship.html", "type": "custom" }, { - "url": "https://github.com/mlocati", - "type": "github" + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-migrations-bundle", + "type": "tidelift" } ], - "time": "2022-10-18T15:00:10+00:00" + "time": "2025-03-11T17:36:26+00:00" }, { - "name": "google/recaptcha", - "version": "1.2.4", + "name": "doctrine/event-manager", + "version": "2.0.1", "source": { "type": "git", - "url": "https://github.com/google/recaptcha.git", - "reference": "614f25a9038be4f3f2da7cbfd778dc5b357d2419" + "url": "https://github.com/doctrine/event-manager.git", + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/google/recaptcha/zipball/614f25a9038be4f3f2da7cbfd778dc5b357d2419", - "reference": "614f25a9038be4f3f2da7cbfd778dc5b357d2419", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", + "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", "shasum": "" }, "require": { - "php": ">=5.5" + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.2.20|^2.15", - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^4.8.36|^5.7.27|^6.59|^7.5.11" + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.8.8", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^5.24" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, "autoload": { "psr-4": { - "ReCaptcha\\": "src/ReCaptcha" + "Doctrine\\Common\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.", - "homepage": "https://www.google.com/recaptcha/", + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", "keywords": [ - "Abuse", - "captcha", - "recaptcha", - "spam" + "event", + "event dispatcher", + "event manager", + "event system", + "events" ], "support": { - "forum": "https://groups.google.com/forum/#!forum/recaptcha", - "issues": "https://github.com/google/recaptcha/issues", - "source": "https://github.com/google/recaptcha" + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.0.1" }, - "time": "2020-03-31T17:50:54+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2024-05-22T20:47:39+00:00" }, { - "name": "greenlion/php-sql-parser", - "version": "v4.6.0", + "name": "doctrine/inflector", + "version": "2.0.10", "source": { "type": "git", - "url": "https://github.com/greenlion/PHP-SQL-Parser.git", - "reference": "f0e4645eb1612f0a295e3d35bda4c7740ae8c366" + "url": "https://github.com/doctrine/inflector.git", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/greenlion/PHP-SQL-Parser/zipball/f0e4645eb1612f0a295e3d35bda4c7740ae8c366", - "reference": "f0e4645eb1612f0a295e3d35bda4c7740ae8c366", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", "shasum": "" }, "require": { - "php": ">=5.3.2" + "php": "^7.2 || ^8.0" }, "require-dev": { - "analog/analog": "^1.0.6", - "phpunit/phpunit": "^9.5.13", - "squizlabs/php_codesniffer": "^1.5.1" + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" }, "type": "library", "autoload": { - "psr-0": { - "PHPSQLParser\\": "src/" + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Justin Swanhart", - "email": "greenlion@gmail.com", - "homepage": "http://code.google.com/u/greenlion@gmail.com/", - "role": "Owner" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" }, { - "name": "André Rothe", - "email": "phosco@gmx.de", - "homepage": "https://www.phosco.info", - "role": "Committer" + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "A pure PHP SQL (non validating) parser w/ focus on MySQL dialect of SQL", - "homepage": "https://github.com/greenlion/PHP-SQL-Parser", + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", "keywords": [ - "creator", - "mysql", - "parser", - "sql" + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" ], "support": { - "issues": "https://github.com/greenlion/PHP-SQL-Parser/issues", - "source": "https://github.com/greenlion/PHP-SQL-Parser" + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.0.10" }, - "time": "2023-03-09T20:54:23+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2024-02-18T20:23:39+00:00" }, { - "name": "ircmaxell/password-compat", - "version": "v1.0.4", + "name": "doctrine/instantiator", + "version": "2.0.0", "source": { "type": "git", - "url": "https://github.com/ircmaxell/password_compat.git", - "reference": "5c5cde8822a69545767f7c7f3058cb15ff84614c" + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ircmaxell/password_compat/zipball/5c5cde8822a69545767f7c7f3058cb15ff84614c", - "reference": "5c5cde8822a69545767f7c7f3058cb15ff84614c", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, + "require": { + "php": "^8.1" + }, "require-dev": { - "phpunit/phpunit": "4.*" + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { - "files": [ - "lib/password.php" - ] + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -762,126 +985,170 @@ ], "authors": [ { - "name": "Anthony Ferrara", - "email": "ircmaxell@php.net", - "homepage": "http://blog.ircmaxell.com" + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" } ], - "description": "A compatibility library for the proposed simplified password hashing algorithm: https://wiki.php.net/rfc/password_hash", - "homepage": "https://github.com/ircmaxell/password_compat", + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", "keywords": [ - "hashing", - "password" + "constructor", + "instantiate" ], "support": { - "issues": "https://github.com/ircmaxell/password_compat/issues", - "source": "https://github.com/ircmaxell/password_compat/tree/v1.0" + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, - "time": "2014-11-20T16:49:30+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" }, { - "name": "jaimeperez/twig-configurable-i18n", - "version": "v1.2", + "name": "doctrine/lexer", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/jaimeperez/twig-configurable-i18n.git", - "reference": "75d4926fd102c9e62219ad7f94a6136d2f2ccd93" + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jaimeperez/twig-configurable-i18n/zipball/75d4926fd102c9e62219ad7f94a6136d2f2ccd93", - "reference": "75d4926fd102c9e62219ad7f94a6136d2f2ccd93", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { - "twig/extensions": "^1.3" + "php": "^8.1" }, - "type": "project", + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", "autoload": { "psr-4": { - "JaimePerez\\TwigConfigurableI18n\\": "src/" + "Doctrine\\Common\\Lexer\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1" + "MIT" ], "authors": [ { - "name": "Jaime Perez", - "email": "jaime.perez@uninett.no" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "This is an extension on top of Twig's i18n extension, allowing you to customize which functions to use for translations.", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ - "extension", - "gettext", - "i18n", - "internationalization", - "translation", - "twig" + "annotations", + "docblock", + "lexer", + "parser", + "php" ], "support": { - "issues": "https://github.com/jaimeperez/twig-configurable-i18n/issues", - "source": "https://github.com/jaimeperez/twig-configurable-i18n" - }, - "abandoned": "simplesamlphp/twig-configurable-i18n", - "time": "2016-10-03T12:34:15+00:00" - }, - { - "name": "jquery/jquery-min-file", - "version": "3.7.1", - "dist": { - "type": "file", - "url": "https://code.jquery.com/jquery-3.7.1.min.js", - "shasum": "ee48592d1fff952fcf06ce0b666ed4785493afdc" - }, - "require": { - "composer/installers": "~1.0" - }, - "type": "vanilla-plugin", - "extra": { - "installer-name": "jquery" + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, - "license": [ - "MIT" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } ], - "homepage": "https://jquery.com" + "time": "2024-02-05T11:56:58+00:00" }, { - "name": "justinrainbow/json-schema", - "version": "v5.2.13", + "name": "doctrine/migrations", + "version": "3.9.2", "source": { "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" + "url": "https://github.com/doctrine/migrations.git", + "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", - "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/fa94c6f06b1bc6d4759481ec20b8b81d13e861be", + "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be", "shasum": "" }, "require": { - "php": ">=5.3.3" + "composer-runtime-api": "^2", + "doctrine/dbal": "^3.6 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2.0", + "php": "^8.1", + "psr/log": "^1.1.3 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", + "symfony/var-exporter": "^6.2 || ^7.0" + }, + "conflict": { + "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" + "doctrine/coding-standard": "^13", + "doctrine/orm": "^2.13 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", + "doctrine/sql-formatter": "^1.0", + "ext-pdo_sqlite": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "suggest": { + "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." }, "bin": [ - "bin/validate-json" + "bin/doctrine-migrations" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, "autoload": { "psr-4": { - "JsonSchema\\": "src/JsonSchema/" + "Doctrine\\Migrations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -890,573 +1157,657 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" }, { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" }, { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" } ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", + "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", "keywords": [ - "json", - "schema" + "database", + "dbal", + "migrations" ], "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/v5.2.13" + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.2" }, - "time": "2023-09-26T02:20:38+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", + "type": "tidelift" + } + ], + "time": "2025-07-29T11:36:14+00:00" }, { - "name": "kassner/log-parser", - "version": "1.5.0", + "name": "doctrine/orm", + "version": "3.5.0", "source": { "type": "git", - "url": "https://github.com/kassner/log-parser.git", - "reference": "ea846b7edf24a421c5484902b2501c9c8e065796" + "url": "https://github.com/doctrine/orm.git", + "reference": "6deec3655ba3e8f15280aac11e264225854d2369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kassner/log-parser/zipball/ea846b7edf24a421c5484902b2501c9c8e065796", - "reference": "ea846b7edf24a421c5484902b2501c9c8e065796", + "url": "https://api.github.com/repos/doctrine/orm/zipball/6deec3655ba3e8f15280aac11e264225854d2369", + "reference": "6deec3655ba3e8f15280aac11e264225854d2369", "shasum": "" }, "require": { - "php": ">=5.3.4" + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/var-exporter": "^6.3.9 || ^7.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.11", - "phpmd/phpmd": "~2.1", - "phpunit/phpunit": "~4.4", - "sebastian/phpcpd": "~2.0" + "doctrine/coding-standard": "^13.0", + "phpbench/phpbench": "^1.0", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.0.3", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.4.0", + "psr/log": "^1 || ^2 || ^3", + "squizlabs/php_codesniffer": "3.12.0", + "symfony/cache": "^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" }, "type": "library", "autoload": { - "psr-0": { - "Kassner": "src" + "psr-4": { + "Doctrine\\ORM\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Rafael Kassner", - "email": "kassner@gmail.com", - "homepage": "http://www.kassner.com.br/", - "role": "Developer" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" } ], - "description": "PHP Log Parser Library", - "homepage": "http://github.com/kassner/log-parser", + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", "keywords": [ - "apache", - "format", - "log", - "log-format", - "nginx", - "parser" + "database", + "orm" ], "support": { - "issues": "https://github.com/kassner/log-parser/issues", - "source": "https://github.com/kassner/log-parser/tree/master" + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.5.0" }, - "time": "2019-02-04T07:43:30+00:00" + "time": "2025-07-01T17:40:53+00:00" }, { - "name": "maxmind-db/reader", - "version": "v1.11.1", + "name": "doctrine/persistence", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", - "reference": "1e66f73ffcf25e17c7a910a1317e9720a95497c7" + "url": "https://github.com/doctrine/persistence.git", + "reference": "45004aca79189474f113cbe3a53847c2115a55fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/1e66f73ffcf25e17c7a910a1317e9720a95497c7", - "reference": "1e66f73ffcf25e17c7a910a1317e9720a95497c7", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa", + "reference": "45004aca79189474f113cbe3a53847c2115a55fa", "shasum": "" }, "require": { - "php": ">=7.2" + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" }, "conflict": { - "ext-maxminddb": "<1.11.1,>=2.0.0" + "doctrine/common": "<2.10" }, "require-dev": { - "friendsofphp/php-cs-fixer": "3.*", - "php-coveralls/php-coveralls": "^2.1", - "phpstan/phpstan": "*", - "phpunit/phpcov": ">=6.0.0", - "phpunit/phpunit": ">=8.0.0,<10.0.0", - "squizlabs/php_codesniffer": "3.*" - }, - "suggest": { - "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", - "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", - "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups" + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "1.12.7", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^9.6", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" }, "type": "library", "autoload": { "psr-4": { - "MaxMind\\Db\\": "src/MaxMind/Db" + "Doctrine\\Persistence\\": "src/Persistence" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Gregory J. Oschwald", - "email": "goschwald@maxmind.com", - "homepage": "https://www.maxmind.com/" + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" } ], - "description": "MaxMind DB Reader API", - "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", "keywords": [ - "database", - "geoip", - "geoip2", - "geolocation", - "maxmind" + "mapper", + "object", + "odm", + "orm", + "persistence" ], "support": { - "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues", - "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.11.1" + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.0.0" }, - "time": "2023-12-02T00:09:23+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2024-11-01T21:49:07+00:00" }, { - "name": "maxmind/web-service-common", - "version": "v0.9.0", + "name": "doctrine/sql-formatter", + "version": "1.5.2", "source": { "type": "git", - "url": "https://github.com/maxmind/web-service-common-php.git", - "reference": "4dc5a3e8df38aea4ca3b1096cee3a038094e9b53" + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/4dc5a3e8df38aea4ca3b1096cee3a038094e9b53", - "reference": "4dc5a3e8df38aea4ca3b1096cee3a038094e9b53", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", + "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", "shasum": "" }, "require": { - "composer/ca-bundle": "^1.0.3", - "ext-curl": "*", - "ext-json": "*", - "php": ">=7.2" + "php": "^8.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "3.*", - "phpstan/phpstan": "*", - "phpunit/phpunit": "^8.0 || ^9.0", - "squizlabs/php_codesniffer": "3.*" + "doctrine/coding-standard": "^12", + "ergebnis/phpunit-slow-test-detector": "^2.14", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5" }, + "bin": [ + "bin/sql-formatter" + ], "type": "library", "autoload": { "psr-4": { - "MaxMind\\Exception\\": "src/Exception", - "MaxMind\\WebService\\": "src/WebService" + "Doctrine\\SqlFormatter\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Gregory Oschwald", - "email": "goschwald@maxmind.com" + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" } ], - "description": "Internal MaxMind Web Service API", - "homepage": "https://github.com/maxmind/web-service-common-php", - "support": { - "issues": "https://github.com/maxmind/web-service-common-php/issues", - "source": "https://github.com/maxmind/web-service-common-php/tree/v0.9.0" - }, - "time": "2022-03-28T17:43:20+00:00" - }, - { - "name": "moment/moment-min-file", - "version": "2.13.0", - "dist": { - "type": "file", - "url": "https://raw.githubusercontent.com/moment/moment/2.13.0/min/moment.min.js", - "shasum": "a8ca7eea2616fa92e2e85ba6291af6ea012fd190" - }, - "require": { - "composer/installers": "~1.0" - }, - "type": "vanilla-plugin", - "extra": { - "installer-name": "moment" - }, - "license": [ - "MIT" + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" ], - "homepage": "https://momentjs.com" - }, - { - "name": "moment/moment-timezone-min-file", - "version": "0.5.4", - "dist": { - "type": "file", - "url": "https://raw.githubusercontent.com/moment/moment-timezone/0.5.4/builds/moment-timezone-with-data.min.js", - "shasum": "39b9fccc20863c23f19524a756d75cfef2ff9cbe" - }, - "require": { - "composer/installers": "~1.0" - }, - "type": "vanilla-plugin", - "extra": { - "installer-name": "moment-timezone" + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" }, - "license": [ - "MIT" - ], - "homepage": "https://momentjs.com" + "time": "2025-01-24T11:45:48+00:00" }, { - "name": "mongodb/mongodb", - "version": "1.19.0", + "name": "egulias/email-validator", + "version": "4.0.4", "source": { "type": "git", - "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "cbc8104c0b2c32b7cf572ff759324c872e8dc63a" + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/cbc8104c0b2c32b7cf572ff759324c872e8dc63a", - "reference": "cbc8104c0b2c32b7cf572ff759324c872e8dc63a", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", "shasum": "" }, "require": { - "composer-runtime-api": "^2.0", - "ext-hash": "*", - "ext-json": "*", - "ext-mongodb": "^1.18.0", - "php": "^7.4 || ^8.0", - "psr/log": "^1.1.4|^2|^3", - "symfony/polyfill-php80": "^1.27", - "symfony/polyfill-php81": "^1.27" + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" }, "require-dev": { - "doctrine/coding-standard": "^12.0", - "rector/rector": "^0.19", - "squizlabs/php_codesniffer": "^3.7", - "symfony/phpunit-bridge": "^5.2", - "vimeo/psalm": "^5.13" + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "4.0.x-dev" } }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "MongoDB\\": "src/" + "Egulias\\EmailValidator\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Andreas Braun", - "email": "andreas.braun@mongodb.com" - }, - { - "name": "Jeremy Mikola", - "email": "jmikola@gmail.com" - }, - { - "name": "Jérôme Tamarelle", - "email": "jerome.tamarelle@mongodb.com" + "name": "Eduardo Gulias Davis" } ], - "description": "MongoDB driver library", - "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", "keywords": [ - "database", - "driver", - "mongodb", - "persistence" + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" ], "support": { - "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/1.19.0" + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" }, - "time": "2024-05-10T19:49:08+00:00" + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" }, { - "name": "monolog/monolog", - "version": "1.27.1", + "name": "firebase/php-jwt", + "version": "v6.11.1", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "904713c5929655dc9b97288b69cfeedad610c9a1" + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/904713c5929655dc9b97288b69cfeedad610c9a1", - "reference": "904713c5929655dc9b97288b69cfeedad610c9a1", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { - "php": ">=5.3.0", - "psr/log": "~1.0" - }, - "provide": { - "psr/log-implementation": "1.0.0" + "php": "^8.0" }, "require-dev": { - "aws/aws-sdk-php": "^2.4.9 || ^3.0", - "doctrine/couchdb": "~1.0@dev", - "graylog2/gelf-php": "~1.0", - "php-amqplib/php-amqplib": "~2.4", - "php-console/php-console": "^3.1.3", - "phpstan/phpstan": "^0.12.59", - "phpunit/phpunit": "~4.5", - "ruflin/elastica": ">=0.90 <3.0", - "sentry/sentry": "^0.13", - "swiftmailer/swiftmailer": "^5.3|^6.0" + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" }, "suggest": { - "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", - "doctrine/couchdb": "Allow sending log messages to a CouchDB server", - "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", - "ext-mongo": "Allow sending log messages to a MongoDB server", - "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", - "mongodb/mongodb": "Allow sending log messages to a MongoDB server via PHP Driver", - "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", - "php-console/php-console": "Allow sending log messages to Google Chrome", - "rollbar/rollbar": "Allow sending log messages to Rollbar", - "ruflin/elastica": "Allow sending log messages to an Elastic Search server", - "sentry/sentry": "Allow sending log messages to a Sentry server" + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" }, "type": "library", "autoload": { "psr-4": { - "Monolog\\": "src/Monolog" + "Firebase\\JWT\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" } ], - "description": "Sends your logs to files, sockets, inboxes, databases and various web services", - "homepage": "http://github.com/Seldaek/monolog", + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", "keywords": [ - "log", - "logging", - "psr-3" + "jwt", + "php" ], "support": { - "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/1.27.1" + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, + { + "name": "friendsofphp/proxy-manager-lts", + "version": "v1.0.18", + "source": { + "type": "git", + "url": "https://github.com/FriendsOfPHP/proxy-manager-lts.git", + "reference": "2c8a6cffc3220e99352ad958fe7cf06bf6f7690f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FriendsOfPHP/proxy-manager-lts/zipball/2c8a6cffc3220e99352ad958fe7cf06bf6f7690f", + "reference": "2c8a6cffc3220e99352ad958fe7cf06bf6f7690f", + "shasum": "" + }, + "require": { + "laminas/laminas-code": "~3.4.1|^4.0", + "php": ">=7.1", + "symfony/filesystem": "^4.4.17|^5.0|^6.0|^7.0" + }, + "conflict": { + "laminas/laminas-stdlib": "<3.2.1", + "zendframework/zend-stdlib": "<3.2.1" + }, + "replace": { + "ocramius/proxy-manager": "^2.1" + }, + "require-dev": { + "ext-phar": "*", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/Ocramius/ProxyManager", + "name": "ocramius/proxy-manager" + } + }, + "autoload": { + "psr-4": { + "ProxyManager\\": "src/ProxyManager" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + } + ], + "description": "Adding support for a wider range of PHP versions to ocramius/proxy-manager", + "homepage": "https://github.com/FriendsOfPHP/proxy-manager-lts", + "keywords": [ + "aop", + "lazy loading", + "proxy", + "proxy pattern", + "service proxies" + ], + "support": { + "issues": "https://github.com/FriendsOfPHP/proxy-manager-lts/issues", + "source": "https://github.com/FriendsOfPHP/proxy-manager-lts/tree/v1.0.18" }, "funding": [ { - "url": "https://github.com/Seldaek", + "url": "https://github.com/Ocramius", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "url": "https://tidelift.com/funding/github/packagist/ocramius/proxy-manager", "type": "tidelift" } ], - "time": "2022-06-09T08:53:42+00:00" + "time": "2024-03-20T12:50:41+00:00" }, { - "name": "paragonie/random_compat", - "version": "v2.0.21", + "name": "geoip2/geoip2", + "version": "v2.13.0", "source": { "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae" + "url": "https://github.com/maxmind/GeoIP2-php.git", + "reference": "6a41d8fbd6b90052bc34dff3b4252d0f88067b23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/96c132c7f2f7bc3230723b66e89f8f150b29d5ae", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae", + "url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/6a41d8fbd6b90052bc34dff3b4252d0f88067b23", + "reference": "6a41d8fbd6b90052bc34dff3b4252d0f88067b23", "shasum": "" }, "require": { - "php": ">=5.2.0" + "ext-json": "*", + "maxmind-db/reader": "~1.8", + "maxmind/web-service-common": "~0.8", + "php": ">=7.2" }, "require-dev": { - "phpunit/phpunit": "*" - }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^8.0 || ^9.0", + "squizlabs/php_codesniffer": "3.*" }, "type": "library", "autoload": { - "files": [ - "lib/random.php" - ] + "psr-4": { + "GeoIp2\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "Apache-2.0" ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" } ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "description": "MaxMind GeoIP2 PHP API", + "homepage": "https://github.com/maxmind/GeoIP2-php", "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" + "IP", + "geoip", + "geoip2", + "geolocation", + "maxmind" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" + "issues": "https://github.com/maxmind/GeoIP2-php/issues", + "source": "https://github.com/maxmind/GeoIP2-php/tree/v2.13.0" }, - "time": "2022-02-16T17:07:03+00:00" + "time": "2022-08-05T20:32:58+00:00" }, { - "name": "phpmailer/phpmailer", - "version": "v6.9.1", + "name": "gettext/gettext", + "version": "v5.7.3", "source": { "type": "git", - "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "039de174cd9c17a8389754d3b877a2ed22743e18" + "url": "https://github.com/php-gettext/Gettext.git", + "reference": "95820f020e4f2f05e0bbaa5603e4c6ec3edc50f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/039de174cd9c17a8389754d3b877a2ed22743e18", - "reference": "039de174cd9c17a8389754d3b877a2ed22743e18", + "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/95820f020e4f2f05e0bbaa5603e4c6ec3edc50f1", + "reference": "95820f020e4f2f05e0bbaa5603e4c6ec3edc50f1", "shasum": "" }, "require": { - "ext-ctype": "*", - "ext-filter": "*", - "ext-hash": "*", - "php": ">=5.5.0" + "gettext/languages": "^2.3", + "php": "^7.2|^8.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/annotations": "^1.2.6 || ^1.13.3", - "php-parallel-lint/php-console-highlighter": "^1.0.0", - "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.3.5", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.2", - "yoast/phpunit-polyfills": "^1.0.4" - }, - "suggest": { - "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", - "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", - "ext-openssl": "Needed for secure SMTP sending and DKIM signing", - "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", - "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", - "league/oauth2-google": "Needed for Google XOAUTH2 authentication", - "psr/log": "For optional PSR-3 debug logging", - "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", - "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + "brick/varexporter": "^0.3.5", + "friendsofphp/php-cs-fixer": "^3.2", + "oscarotero/php-cs-fixer-config": "^2.0", + "phpunit/phpunit": "^8.0|^9.0", + "squizlabs/php_codesniffer": "^3.0" }, "type": "library", "autoload": { "psr-4": { - "PHPMailer\\PHPMailer\\": "src/" + "Gettext\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-only" + "MIT" ], "authors": [ { - "name": "Marcus Bointon", - "email": "phpmailer@synchromedia.co.uk" - }, - { - "name": "Jim Jagielski", - "email": "jimjag@gmail.com" - }, - { - "name": "Andy Prevost", - "email": "codeworxtech@users.sourceforge.net" - }, - { - "name": "Brent R. Matzelle" + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" } ], - "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "description": "PHP gettext manager", + "homepage": "https://github.com/php-gettext/Gettext", + "keywords": [ + "JS", + "gettext", + "i18n", + "mo", + "po", + "translation" + ], "support": { - "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.1" + "email": "oom@oscarotero.com", + "issues": "https://github.com/php-gettext/Gettext/issues", + "source": "https://github.com/php-gettext/Gettext/tree/v5.7.3" }, "funding": [ { - "url": "https://github.com/Synchro", + "url": "https://paypal.me/oscarotero", + "type": "custom" + }, + { + "url": "https://github.com/oscarotero", "type": "github" + }, + { + "url": "https://www.patreon.com/misteroom", + "type": "patreon" } ], - "time": "2023-11-25T22:23:28+00:00" + "time": "2024-12-01T10:18:08+00:00" }, { - "name": "phpoffice/math", - "version": "0.1.0", + "name": "gettext/languages", + "version": "2.12.1", "source": { "type": "git", - "url": "https://github.com/PHPOffice/Math.git", - "reference": "f0f8cad98624459c540cdd61d2a174d834471773" + "url": "https://github.com/php-gettext/Languages.git", + "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/Math/zipball/f0f8cad98624459c540cdd61d2a174d834471773", - "reference": "f0f8cad98624459c540cdd61d2a174d834471773", + "url": "https://api.github.com/repos/php-gettext/Languages/zipball/0b0b0851c55168e1dfb14305735c64019732b5f1", + "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-xml": "*", - "php": "^7.1|^8.0" + "php": ">=5.3" }, "require-dev": { - "phpstan/phpstan": "^0.12.88 || ^1.0.0", - "phpunit/phpunit": "^7.0 || ^9.0" + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4" }, + "bin": [ + "bin/export-plural-rules", + "bin/import-cldr-data" + ], "type": "library", "autoload": { "psr-4": { - "PhpOffice\\Math\\": "src/Math/", - "Tests\\PhpOffice\\Math\\": "tests/Math/" + "Gettext\\Languages\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1465,284 +1816,273 @@ ], "authors": [ { - "name": "Progi1984", - "homepage": "https://lefevre.dev" + "name": "Michele Locati", + "email": "mlocati@gmail.com", + "role": "Developer" } ], - "description": "Math - Manipulate Math Formula", - "homepage": "https://phpoffice.github.io/Math/", + "description": "gettext languages with plural rules", + "homepage": "https://github.com/php-gettext/Languages", "keywords": [ - "MathML", - "officemathml", - "php" + "cldr", + "i18n", + "internationalization", + "l10n", + "language", + "languages", + "localization", + "php", + "plural", + "plural rules", + "plurals", + "translate", + "translations", + "unicode" ], "support": { - "issues": "https://github.com/PHPOffice/Math/issues", - "source": "https://github.com/PHPOffice/Math/tree/0.1.0" + "issues": "https://github.com/php-gettext/Languages/issues", + "source": "https://github.com/php-gettext/Languages/tree/2.12.1" }, - "time": "2023-09-25T12:08:20+00:00" + "funding": [ + { + "url": "https://paypal.me/mlocati", + "type": "custom" + }, + { + "url": "https://github.com/mlocati", + "type": "github" + } + ], + "time": "2025-03-19T11:14:02+00:00" }, { - "name": "phpoffice/phpword", - "version": "1.2.0", + "name": "gettext/translator", + "version": "v1.2.1", "source": { "type": "git", - "url": "https://github.com/PHPOffice/PHPWord.git", - "reference": "e76b701ef538cb749641514fcbc31a68078550fa" + "url": "https://github.com/php-gettext/Translator.git", + "reference": "8ae0ac79053bcb732a6c584cd86f7a82ef183161" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/e76b701ef538cb749641514fcbc31a68078550fa", - "reference": "e76b701ef538cb749641514fcbc31a68078550fa", + "url": "https://api.github.com/repos/php-gettext/Translator/zipball/8ae0ac79053bcb732a6c584cd86f7a82ef183161", + "reference": "8ae0ac79053bcb732a6c584cd86f7a82ef183161", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-xml": "*", - "php": "^7.1|^8.0", - "phpoffice/math": "^0.1" + "php": "^7.2|^8.0" }, "require-dev": { - "dompdf/dompdf": "^2.0", - "ext-gd": "*", - "ext-libxml": "*", - "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.3", - "mpdf/mpdf": "^8.1", - "phpmd/phpmd": "^2.13", - "phpstan/phpstan-phpunit": "@stable", - "phpunit/phpunit": ">=7.0", - "symfony/process": "^4.4 || ^5.0", - "tecnickcom/tcpdf": "^6.5" + "friendsofphp/php-cs-fixer": "^2.15", + "gettext/gettext": "^5.0.0", + "oscarotero/php-cs-fixer-config": "^1.0", + "phpunit/phpunit": "^8.0", + "squizlabs/php_codesniffer": "^3.0" }, "suggest": { - "dompdf/dompdf": "Allows writing PDF", - "ext-gd2": "Allows adding images", - "ext-xmlwriter": "Allows writing OOXML and ODF", - "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template", - "ext-zip": "Allows writing OOXML and ODF" + "gettext/gettext": "Is necessary to load and generate array files used by the translator" }, "type": "library", "autoload": { "psr-4": { - "PhpOffice\\PhpWord\\": "src/PhpWord" + "Gettext\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-3.0" + "MIT" ], "authors": [ { - "name": "Mark Baker" - }, - { - "name": "Gabriel Bull", - "email": "me@gabrielbull.com", - "homepage": "http://gabrielbull.com/" - }, - { - "name": "Franck Lefevre", - "homepage": "https://rootslabs.net/blog/" - }, - { - "name": "Ivan Lanin", - "homepage": "http://ivan.lanin.org" - }, - { - "name": "Roman Syroeshko", - "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" - }, - { - "name": "Antoine de Troostembergh" + "name": "Oscar Otero", + "email": "oom@oscarotero.com", + "homepage": "http://oscarotero.com", + "role": "Developer" } ], - "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", - "homepage": "https://phpoffice.github.io/PHPWord/", + "description": "Gettext translator functions", + "homepage": "https://github.com/php-gettext/Translator", "keywords": [ - "ISO IEC 29500", - "OOXML", - "Office Open XML", - "OpenDocument", - "OpenXML", - "PhpOffice", - "PhpWord", - "Rich Text Format", - "WordprocessingML", - "doc", - "docx", - "html", - "odf", - "odt", - "office", - "pdf", + "gettext", + "i18n", "php", - "reader", - "rtf", - "template", - "template processor", - "word", - "writer" + "translator" ], "support": { - "issues": "https://github.com/PHPOffice/PHPWord/issues", - "source": "https://github.com/PHPOffice/PHPWord/tree/1.2.0" + "email": "oom@oscarotero.com", + "issues": "https://github.com/php-gettext/Translator/issues", + "source": "https://github.com/php-gettext/Translator/tree/v1.2.1" }, - "time": "2023-11-30T11:22:23+00:00" + "funding": [ + { + "url": "https://paypal.me/oscarotero", + "type": "custom" + }, + { + "url": "https://github.com/oscarotero", + "type": "github" + }, + { + "url": "https://www.patreon.com/misteroom", + "type": "patreon" + } + ], + "time": "2025-01-09T09:20:22+00:00" }, { - "name": "pimple/pimple", - "version": "v3.5.0", + "name": "google/recaptcha", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/silexphp/Pimple.git", - "reference": "a94b3a4db7fb774b3d78dad2315ddc07629e1bed" + "url": "https://github.com/google/recaptcha.git", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silexphp/Pimple/zipball/a94b3a4db7fb774b3d78dad2315ddc07629e1bed", - "reference": "a94b3a4db7fb774b3d78dad2315ddc07629e1bed", + "url": "https://api.github.com/repos/google/recaptcha/zipball/56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", + "reference": "56522c261d2e8c58ba416c90f81a4cd9f2ed89b9", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1 || ^2.0" + "php": ">=8" }, "require-dev": { - "symfony/phpunit-bridge": "^5.4@dev" + "friendsofphp/php-cs-fixer": "^3.14", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.4.x-dev" + "dev-master": "1.3.x-dev" } }, "autoload": { - "psr-0": { - "Pimple": "src/" + "psr-4": { + "ReCaptcha\\": "src/ReCaptcha" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - } + "BSD-3-Clause" ], - "description": "Pimple, a simple Dependency Injection Container", - "homepage": "https://pimple.symfony.com", + "description": "Client library for reCAPTCHA, a free service that protects websites from spam and abuse.", + "homepage": "https://www.google.com/recaptcha/", "keywords": [ - "container", - "dependency injection" + "Abuse", + "captcha", + "recaptcha", + "spam" ], "support": { - "source": "https://github.com/silexphp/Pimple/tree/v3.5.0" - }, - "time": "2021-10-28T11:13:42+00:00" - }, - { - "name": "plotly/plotly", - "version": "2.29.1", - "dist": { - "type": "file", - "url": "https://cdn.plot.ly/plotly-2.29.1.min.js", - "shasum": "62b7c9478e01491b8d774300683cd8aac2d02a45" - }, - "require": { - "composer/installers": "~1.0" - }, - "type": "vanilla-plugin", - "extra": { - "installer-name": "plotly" + "forum": "https://groups.google.com/forum/#!forum/recaptcha", + "issues": "https://github.com/google/recaptcha/issues", + "source": "https://github.com/google/recaptcha" }, - "license": [ - "MIT" - ], - "homepage": "https://github.com/plotly/plotly.js" + "time": "2025-06-26T22:21:57+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "greenlion/php-sql-parser", + "version": "v4.7.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/greenlion/PHP-SQL-Parser.git", + "reference": "0cd49149efc5868db9c32d1a09558ea516892586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/greenlion/PHP-SQL-Parser/zipball/0cd49149efc5868db9c32d1a09558ea516892586", + "reference": "0cd49149efc5868db9c32d1a09558ea516892586", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=5.3.2" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "analog/analog": "^1.0.6", + "phpunit/phpunit": "^9.5.13", + "squizlabs/php_codesniffer": "^2.8.1" }, + "type": "library", "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" + "psr-0": { + "PHPSQLParser\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Justin Swanhart", + "email": "greenlion@gmail.com", + "homepage": "http://code.google.com/u/greenlion@gmail.com/", + "role": "Owner" + }, + { + "name": "André Rothe", + "email": "phosco@gmx.de", + "homepage": "https://www.phosco.info", + "role": "Committer" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "A pure PHP SQL (non validating) parser w/ focus on MySQL dialect of SQL", + "homepage": "https://github.com/greenlion/PHP-SQL-Parser", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "creator", + "mysql", + "parser", + "sql" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/greenlion/PHP-SQL-Parser/issues", + "source": "https://github.com/greenlion/PHP-SQL-Parser" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2024-12-02T12:14:07+00:00" }, { - "name": "psr/log", - "version": "1.1.4", + "name": "guzzlehttp/psr7", + "version": "2.7.1", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "GuzzleHttp\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1751,147 +2091,177 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "PSR-7 message implementation that also provides common utility methods", "keywords": [ - "log", - "psr", - "psr-3" + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" }, - "time": "2021-05-03T11:20:27+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" }, { - "name": "robrichards/xmlseclibs", - "version": "3.1.1", + "name": "ircmaxell/password-compat", + "version": "v1.0.4", "source": { "type": "git", - "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df" + "url": "https://github.com/ircmaxell/password_compat.git", + "reference": "5c5cde8822a69545767f7c7f3058cb15ff84614c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/f8f19e58f26cdb42c54b214ff8a820760292f8df", - "reference": "f8f19e58f26cdb42c54b214ff8a820760292f8df", + "url": "https://api.github.com/repos/ircmaxell/password_compat/zipball/5c5cde8822a69545767f7c7f3058cb15ff84614c", + "reference": "5c5cde8822a69545767f7c7f3058cb15ff84614c", "shasum": "" }, - "require": { - "ext-openssl": "*", - "php": ">= 5.4" + "require-dev": { + "phpunit/phpunit": "4.*" }, "type": "library", "autoload": { - "psr-4": { - "RobRichards\\XMLSecLibs\\": "src" - } + "files": [ + "lib/password.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "description": "A PHP library for XML Security", - "homepage": "https://github.com/robrichards/xmlseclibs", + "authors": [ + { + "name": "Anthony Ferrara", + "email": "ircmaxell@php.net", + "homepage": "http://blog.ircmaxell.com" + } + ], + "description": "A compatibility library for the proposed simplified password hashing algorithm: https://wiki.php.net/rfc/password_hash", + "homepage": "https://github.com/ircmaxell/password_compat", "keywords": [ - "security", - "signature", - "xml", - "xmldsig" + "hashing", + "password" ], "support": { - "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.1" + "issues": "https://github.com/ircmaxell/password_compat/issues", + "source": "https://github.com/ircmaxell/password_compat/tree/v1.0" }, - "time": "2020-09-05T13:00:25+00:00" + "time": "2014-11-20T16:49:30+00:00" }, { - "name": "sencha/extjs-gpl", - "version": "3.4.1.1", + "name": "jquery/jquery-min-file", + "version": "3.7.1", "dist": { - "type": "zip", - "url": "https://cdn.sencha.com/ext/gpl/ext-3.4.1.1-gpl.zip", - "shasum": "26734b47eae909ff7f8cd7de4cadfb3531bd3cdc" + "type": "file", + "url": "https://code.jquery.com/jquery-3.7.1.min.js", + "shasum": "ee48592d1fff952fcf06ce0b666ed4785493afdc" }, "require": { "composer/installers": "~1.0" }, "type": "vanilla-plugin", "extra": { - "installer-name": "extjs" + "installer-name": "jquery" }, "license": [ - "GPL-3.0" + "MIT" ], - "homepage": "https://www.sencha.com/products/extjs" + "homepage": "https://jquery.com" }, { - "name": "silex/silex", - "version": "v2.3.0", + "name": "justinrainbow/json-schema", + "version": "6.4.2", "source": { "type": "git", - "url": "https://github.com/silexphp/Silex.git", - "reference": "6bc31c1b8c4ef614a7115320fd2d3b958032f131" + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "ce1fd2d47799bb60668643bc6220f6278a4c1d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/silexphp/Silex/zipball/6bc31c1b8c4ef614a7115320fd2d3b958032f131", - "reference": "6bc31c1b8c4ef614a7115320fd2d3b958032f131", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/ce1fd2d47799bb60668643bc6220f6278a4c1d02", + "reference": "ce1fd2d47799bb60668643bc6220f6278a4c1d02", "shasum": "" }, "require": { - "php": ">=7.1.3", - "pimple/pimple": "^3.0", - "symfony/event-dispatcher": "^4.0", - "symfony/http-foundation": "^4.0", - "symfony/http-kernel": "^4.0", - "symfony/routing": "^4.0" - }, - "replace": { - "silex/api": "self.version", - "silex/providers": "self.version" + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/dbal": "^2.2", - "monolog/monolog": "^1.4.1", - "swiftmailer/swiftmailer": "^5", - "symfony/asset": "^4.0", - "symfony/browser-kit": "^4.0", - "symfony/config": "^4.0", - "symfony/css-selector": "^4.0", - "symfony/debug": "^4.0", - "symfony/doctrine-bridge": "^4.0", - "symfony/dom-crawler": "^4.0", - "symfony/expression-language": "^4.0", - "symfony/finder": "^4.0", - "symfony/form": "^4.0", - "symfony/intl": "^4.0", - "symfony/monolog-bridge": "^4.0", - "symfony/options-resolver": "^4.0", - "symfony/phpunit-bridge": "^3.2", - "symfony/process": "^4.0", - "symfony/security": "^4.0", - "symfony/serializer": "^4.0", - "symfony/translation": "^4.0", - "symfony/twig-bridge": "^4.0", - "symfony/validator": "^4.0", - "symfony/var-dumper": "^4.0", - "symfony/web-link": "^4.0", - "twig/twig": "^2.0" + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "1.2.0", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" }, + "bin": [ + "bin/validate-json" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "2.3.x-dev" + "dev-master": "6.x-dev" } }, "autoload": { "psr-4": { - "Silex\\": "src/Silex" + "JsonSchema\\": "src/JsonSchema/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1900,295 +2270,4913 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" }, { "name": "Igor Wiedler", "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" } ], - "description": "The PHP micro-framework based on the Symfony Components", - "homepage": "http://silex.sensiolabs.org", + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ - "microframework" + "json", + "schema" ], "support": { - "issues": "https://github.com/silexphp/Silex/issues", - "source": "https://github.com/silexphp/Silex/tree/v2.3.0" + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.4.2" }, - "abandoned": "symfony/flex", - "time": "2018-04-20T05:17:01+00:00" + "time": "2025-06-03T18:27:04+00:00" }, { - "name": "simplesamlphp/assert", - "version": "v0.8.0", + "name": "kassner/log-parser", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/simplesamlphp/assert.git", - "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73" + "url": "https://github.com/kassner/log-parser.git", + "reference": "6a573bd2985c810e3c459d762cabfad1666c37b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/simplesamlphp/assert/zipball/d3b0f38f4ae083822471c15e3c4a0401ddaeac73", - "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73", + "url": "https://api.github.com/repos/kassner/log-parser/zipball/6a573bd2985c810e3c459d762cabfad1666c37b4", + "reference": "6a573bd2985c810e3c459d762cabfad1666c37b4", "shasum": "" }, "require": { - "ext-spl": "*", - "php": "^7.4 || ^8.0", - "webmozart/assert": "^1.11" - }, - "require-dev": { - "simplesamlphp/simplesamlphp-test-framework": "^1.2.1" + "php": ">=7.4.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "v0.8.x-dev" - } - }, "autoload": { "psr-4": { - "SimpleSAML\\Assert\\": "src/" + "Kassner\\LogParser\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-or-later" + "Apache-2.0" ], "authors": [ { - "name": "Tim van Dijen", - "email": "tvdijen@gmail.com" - }, - { - "name": "Jaime Perez Crespo", - "email": "jaimepc@gmail.com" + "name": "Rafael Kassner", + "email": "kassner@gmail.com", + "homepage": "https://www.kassner.com.br/", + "role": "Developer" } ], - "description": "A wrapper around webmozart/assert to make it useful beyond checking method arguments", + "description": "PHP Log Parser Library", + "homepage": "http://github.com/kassner/log-parser", + "keywords": [ + "apache", + "format", + "log", + "log-format", + "nginx", + "parser" + ], "support": { - "issues": "https://github.com/simplesamlphp/assert/issues", - "source": "https://github.com/simplesamlphp/assert/tree/v0.8.0" + "issues": "https://github.com/kassner/log-parser/issues", + "source": "https://github.com/kassner/log-parser/tree/2.2.0" }, - "time": "2022-09-20T20:18:55+00:00" + "time": "2024-08-20T20:01:20+00:00" }, { - "name": "simplesamlphp/composer-module-installer", - "version": "v1.3.4", + "name": "laminas/laminas-code", + "version": "4.16.0", "source": { "type": "git", - "url": "https://github.com/simplesamlphp/composer-module-installer.git", - "reference": "36508ed9580a30c4d5ab0bb3c25c00d0b5d42946" + "url": "https://github.com/laminas/laminas-code.git", + "reference": "1793e78dad4108b594084d05d1fb818b85b110af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/simplesamlphp/composer-module-installer/zipball/36508ed9580a30c4d5ab0bb3c25c00d0b5d42946", - "reference": "36508ed9580a30c4d5ab0bb3c25c00d0b5d42946", + "url": "https://api.github.com/repos/laminas/laminas-code/zipball/1793e78dad4108b594084d05d1fb818b85b110af", + "reference": "1793e78dad4108b594084d05d1fb818b85b110af", "shasum": "" }, "require": { - "composer-plugin-api": "^1.1 || ^2.0", - "php": "^7.4 || ^8.0", - "simplesamlphp/assert": "^0.8.0 || ^1.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" }, "require-dev": { - "composer/composer": "^2.4", - "simplesamlphp/simplesamlphp-test-framework": "^1.2.1" + "doctrine/annotations": "^2.0.1", + "ext-phar": "*", + "laminas/laminas-coding-standard": "^3.0.0", + "laminas/laminas-stdlib": "^3.18.0", + "phpunit/phpunit": "^10.5.37", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.15.0" }, - "type": "composer-plugin", - "extra": { - "class": "SimpleSAML\\Composer\\ModuleInstallerPlugin" + "suggest": { + "doctrine/annotations": "Doctrine\\Common\\Annotations >=1.0 for annotation features", + "laminas/laminas-stdlib": "Laminas\\Stdlib component" }, + "type": "library", "autoload": { "psr-4": { - "SimpleSAML\\Composer\\": "src/" + "Laminas\\Code\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-only" + "BSD-3-Clause" + ], + "description": "Extensions to the PHP Reflection API, static code scanning, and code generation", + "homepage": "https://laminas.dev", + "keywords": [ + "code", + "laminas", + "laminasframework" ], - "description": "A Composer plugin that allows installing SimpleSAMLphp modules through Composer.", "support": { - "issues": "https://github.com/simplesamlphp/composer-module-installer/issues", - "source": "https://github.com/simplesamlphp/composer-module-installer/tree/v1.3.4" + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-code/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-code/issues", + "rss": "https://github.com/laminas/laminas-code/releases.atom", + "source": "https://github.com/laminas/laminas-code" }, - "time": "2023-03-08T20:58:22+00:00" + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2024-11-20T13:15:13+00:00" }, { - "name": "simplesamlphp/saml2", - "version": "v3.2.6", + "name": "marc-mabe/php-enum", + "version": "v4.7.1", "source": { "type": "git", - "url": "https://github.com/simplesamlphp/saml2.git", - "reference": "a56e46ef8e0c5245a4ca7facc3d308b493215751" + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/simplesamlphp/saml2/zipball/a56e46ef8e0c5245a4ca7facc3d308b493215751", - "reference": "a56e46ef8e0c5245a4ca7facc3d308b493215751", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/7159809e5cfa041dca28e61f7f7ae58063aae8ed", + "reference": "7159809e5cfa041dca28e61f7f7ae58063aae8ed", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-openssl": "*", - "ext-zlib": "*", - "php": ">=5.4", - "psr/log": "~1.0", - "robrichards/xmlseclibs": "^3.0" + "ext-reflection": "*", + "php": "^7.1 | ^8.0" }, "require-dev": { - "mockery/mockery": "~0.9", - "phpmd/phpmd": "~1.5", - "phpunit/phpunit": "~4", - "sebastian/phpcpd": "~1.4", - "sensiolabs/security-checker": "~1.1", - "squizlabs/php_codesniffer": "~1.4" + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "v3.1.x-dev" + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" } }, "autoload": { - "files": [ - "src/_autoload.php" - ], - "psr-0": { - "SAML2\\": "src/" + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.1" + }, + "time": "2024-11-28T04:54:44+00:00" + }, + { + "name": "maxmind-db/reader", + "version": "v1.12.1", + "source": { + "type": "git", + "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", + "reference": "815939e006b7e68062b540ec9e86aaa8be2b6ce4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/815939e006b7e68062b540ec9e86aaa8be2b6ce4", + "reference": "815939e006b7e68062b540ec9e86aaa8be2b6ce4", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "3.*" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups" + }, + "type": "library", + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-or-later" + "Apache-2.0" ], "authors": [ { - "name": "Andreas Åkre Solberg", - "email": "andreas.solberg@uninett.no" + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" } ], - "description": "SAML2 PHP library from SimpleSAMLphp", + "description": "MaxMind DB Reader API", + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "keywords": [ + "database", + "geoip", + "geoip2", + "geolocation", + "maxmind" + ], "support": { - "issues": "https://github.com/simplesamlphp/saml2/issues", - "source": "https://github.com/simplesamlphp/saml2/tree/master" + "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues", + "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.12.1" }, - "time": "2018-11-20T11:11:28+00:00" + "time": "2025-05-05T20:56:32+00:00" }, { - "name": "simplesamlphp/simplesamlphp", - "version": "1.16.3", + "name": "maxmind/web-service-common", + "version": "v0.10.0", "source": { "type": "git", - "url": "https://github.com/simplesamlphp/simplesamlphp.git", - "reference": "abc208dbc9c94eb8bab8266825ca035cc96072ba" + "url": "https://github.com/maxmind/web-service-common-php.git", + "reference": "d7c7c42fc31bff26e0ded73a6e187bcfb193f9c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp/zipball/abc208dbc9c94eb8bab8266825ca035cc96072ba", - "reference": "abc208dbc9c94eb8bab8266825ca035cc96072ba", + "url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/d7c7c42fc31bff26e0ded73a6e187bcfb193f9c4", + "reference": "d7c7c42fc31bff26e0ded73a6e187bcfb193f9c4", "shasum": "" }, "require": { - "ext-date": "*", - "ext-dom": "*", - "ext-hash": "*", + "composer/ca-bundle": "^1.0.3", + "ext-curl": "*", "ext-json": "*", - "ext-mbstring": "*", - "ext-openssl": "*", - "ext-pcre": "*", - "ext-spl": "*", - "ext-zlib": "*", - "gettext/gettext": "^3.5", - "jaimeperez/twig-configurable-i18n": "^1.2", - "php": ">=5.4", - "robrichards/xmlseclibs": "^3.0", - "simplesamlphp/saml2": "~3.2.2", - "twig/twig": "~1.0", - "whitehat101/apr1-md5": "~1.0" + "php": ">=8.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.2", - "mikey179/vfsstream": "~1.6", - "phpunit/phpunit": "~4.8.35" + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^8.0 || ^9.0", + "squizlabs/php_codesniffer": "3.*" }, - "type": "project", + "type": "library", "autoload": { - "files": [ - "lib/_autoload_modules.php" - ], "psr-4": { - "SimpleSAML\\": "lib/SimpleSAML" + "MaxMind\\Exception\\": "src/Exception", + "MaxMind\\WebService\\": "src/WebService" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory Oschwald", + "email": "goschwald@maxmind.com" } + ], + "description": "Internal MaxMind Web Service API", + "homepage": "https://github.com/maxmind/web-service-common-php", + "support": { + "issues": "https://github.com/maxmind/web-service-common-php/issues", + "source": "https://github.com/maxmind/web-service-common-php/tree/v0.10.0" + }, + "time": "2024-11-14T23:14:52+00:00" + }, + { + "name": "moment/moment-min-file", + "version": "2.13.0", + "dist": { + "type": "file", + "url": "https://raw.githubusercontent.com/moment/moment/2.13.0/min/moment.min.js", + "shasum": "a8ca7eea2616fa92e2e85ba6291af6ea012fd190" + }, + "require": { + "composer/installers": "~1.0" + }, + "type": "vanilla-plugin", + "extra": { + "installer-name": "moment" + }, + "license": [ + "MIT" + ], + "homepage": "https://momentjs.com" + }, + { + "name": "moment/moment-timezone-min-file", + "version": "0.5.4", + "dist": { + "type": "file", + "url": "https://raw.githubusercontent.com/moment/moment-timezone/0.5.4/builds/moment-timezone-with-data.min.js", + "shasum": "39b9fccc20863c23f19524a756d75cfef2ff9cbe" + }, + "require": { + "composer/installers": "~1.0" + }, + "type": "vanilla-plugin", + "extra": { + "installer-name": "moment-timezone" + }, + "license": [ + "MIT" + ], + "homepage": "https://momentjs.com" + }, + { + "name": "mongodb/mongodb", + "version": "1.18.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "d421c418ef56a96f3dfa6b2828f936df6848ccf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/d421c418ef56a96f3dfa6b2828f936df6848ccf9", + "reference": "d421c418ef56a96f3dfa6b2828f936df6848ccf9", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-hash": "*", + "ext-json": "*", + "ext-mongodb": "^1.18.0", + "php": "^7.4 || ^8.0", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php80": "^1.27", + "symfony/polyfill-php81": "^1.27" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "rector/rector": "^0.19", + "squizlabs/php_codesniffer": "^3.7", + "symfony/phpunit-bridge": "^5.2", + "vimeo/psalm": "^5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.18.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/1.18.0" + }, + "time": "2024-03-27T17:04:50+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", + "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" + }, + "time": "2025-04-13T19:20:35+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + }, + "time": "2024-11-09T15:12:26+00:00" + }, + { + "name": "phplang/scope-exit", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/phplang/scope-exit.git", + "reference": "239b73abe89f9414aa85a7ca075ec9445629192b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phplang/scope-exit/zipball/239b73abe89f9414aa85a7ca075ec9445629192b", + "reference": "239b73abe89f9414aa85a7ca075ec9445629192b", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpLang\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD" + ], + "authors": [ + { + "name": "Sara Golemon", + "email": "pollita@php.net", + "homepage": "https://twitter.com/SaraMG", + "role": "Developer" + } + ], + "description": "Emulation of SCOPE_EXIT construct from C++", + "homepage": "https://github.com/phplang/scope-exit", + "keywords": [ + "cleanup", + "exit", + "scope" + ], + "support": { + "issues": "https://github.com/phplang/scope-exit/issues", + "source": "https://github.com/phplang/scope-exit/tree/master" + }, + "time": "2016-09-17T00:15:18+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-04-24T15:19:31+00:00" + }, + { + "name": "phpoffice/math", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/Math.git", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpunit/phpunit": "^7.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\Math\\": "src/Math/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Progi1984", + "homepage": "https://lefevre.dev" + } + ], + "description": "Math - Manipulate Math Formula", + "homepage": "https://phpoffice.github.io/Math/", + "keywords": [ + "MathML", + "officemathml", + "php" + ], + "support": { + "issues": "https://github.com/PHPOffice/Math/issues", + "source": "https://github.com/PHPOffice/Math/tree/0.3.0" + }, + "time": "2025-05-29T08:31:49+00:00" + }, + { + "name": "phpoffice/phpword", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PHPWord.git", + "reference": "6d75328229bc93790b37e93741adf70646cea958" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958", + "reference": "6d75328229bc93790b37e93741adf70646cea958", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-gd": "*", + "ext-json": "*", + "ext-xml": "*", + "ext-zip": "*", + "php": "^7.1|^8.0", + "phpoffice/math": "^0.3" + }, + "require-dev": { + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-libxml": "*", + "friendsofphp/php-cs-fixer": "^3.3", + "mpdf/mpdf": "^7.0 || ^8.0", + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": ">=7.0", + "symfony/process": "^4.4 || ^5.0", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Allows writing PDF", + "ext-xmlwriter": "Allows writing OOXML and ODF", + "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpWord\\": "src/PhpWord" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com", + "homepage": "http://gabrielbull.com/" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net/blog/" + }, + { + "name": "Ivan Lanin", + "homepage": "http://ivan.lanin.org" + }, + { + "name": "Roman Syroeshko", + "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" + }, + { + "name": "Antoine de Troostembergh" + } + ], + "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", + "homepage": "https://phpoffice.github.io/PHPWord/", + "keywords": [ + "ISO IEC 29500", + "OOXML", + "Office Open XML", + "OpenDocument", + "OpenXML", + "PhpOffice", + "PhpWord", + "Rich Text Format", + "WordprocessingML", + "doc", + "docx", + "html", + "odf", + "odt", + "office", + "pdf", + "php", + "reader", + "rtf", + "template", + "template processor", + "word", + "writer" + ], + "support": { + "issues": "https://github.com/PHPOffice/PHPWord/issues", + "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0" + }, + "time": "2025-06-05T10:32:36+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" + }, + "time": "2025-07-13T07:04:09+00:00" + }, + { + "name": "plotly/plotly", + "version": "2.29.1", + "dist": { + "type": "file", + "url": "https://cdn.plot.ly/plotly-2.29.1.min.js", + "shasum": "62b7c9478e01491b8d774300683cd8aac2d02a45" + }, + "require": { + "composer/installers": "~1.0" + }, + "type": "vanilla-plugin", + "extra": { + "installer-name": "plotly" + }, + "license": [ + "MIT" + ], + "homepage": "https://github.com/plotly/plotly.js" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "robrichards/xmlseclibs", + "version": "3.1.3", + "source": { + "type": "git", + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/2bdfd742624d739dfadbd415f00181b4a77aaf07", + "reference": "2bdfd742624d739dfadbd415f00181b4a77aaf07", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/robrichards/xmlseclibs/issues", + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.3" + }, + "time": "2024-11-20T21:13:56+00:00" + }, + { + "name": "sencha/extjs-gpl", + "version": "3.4.1.1", + "dist": { + "type": "zip", + "url": "https://cdn.sencha.com/ext/gpl/ext-3.4.1.1-gpl.zip", + "shasum": "26734b47eae909ff7f8cd7de4cadfb3531bd3cdc" + }, + "require": { + "composer/installers": "~1.0" + }, + "type": "vanilla-plugin", + "extra": { + "installer-name": "extjs" + }, + "license": [ + "GPL-3.0" + ], + "homepage": "https://www.sencha.com/products/extjs" + }, + { + "name": "simplesamlphp/assert", + "version": "v1.8.2", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/assert.git", + "reference": "b551f50399540172f387d97b2e7246e6c352154d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/assert/zipball/b551f50399540172f387d97b2e7246e6c352154d", + "reference": "b551f50399540172f387d97b2e7246e6c352154d", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-filter": "*", + "ext-pcre": "*", + "ext-spl": "*", + "guzzlehttp/psr7": "~2.7.1", + "php": "^8.1", + "webmozart/assert": "~1.11.0" + }, + "require-dev": { + "ext-intl": "*", + "simplesamlphp/simplesamlphp-test-framework": "~1.9.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + }, + { + "name": "Jaime Perez Crespo", + "email": "jaimepc@gmail.com" + } + ], + "description": "A wrapper around webmozart/assert to make it useful beyond checking method arguments", + "support": { + "issues": "https://github.com/simplesamlphp/assert/issues", + "source": "https://github.com/simplesamlphp/assert/tree/v1.8.2" + }, + "time": "2025-06-28T12:57:30+00:00" + }, + { + "name": "simplesamlphp/composer-module-installer", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/composer-module-installer.git", + "reference": "edb2155d200e2a208816d06f42cfa78bfd9e7cf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/composer-module-installer/zipball/edb2155d200e2a208816d06f42cfa78bfd9e7cf4", + "reference": "edb2155d200e2a208816d06f42cfa78bfd9e7cf4", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.6", + "php": "^8.1", + "simplesamlphp/assert": "^1.6" + }, + "require-dev": { + "composer/composer": "^2.8.3", + "simplesamlphp/simplesamlphp-test-framework": "^1.8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "SimpleSAML\\Composer\\ModuleInstallerPlugin" + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "description": "A Composer plugin that allows installing SimpleSAMLphp modules through Composer.", + "support": { + "issues": "https://github.com/simplesamlphp/composer-module-installer/issues", + "source": "https://github.com/simplesamlphp/composer-module-installer/tree/v1.4.0" + }, + "time": "2024-12-08T16:57:03+00:00" + }, + { + "name": "simplesamlphp/composer-xmlprovider-installer", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/composer-xmlprovider-installer.git", + "reference": "3d882187b5b0b404c381a2e4d17498ca4b2785b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/composer-xmlprovider-installer/zipball/3d882187b5b0b404c381a2e4d17498ca4b2785b3", + "reference": "3d882187b5b0b404c381a2e4d17498ca4b2785b3", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "composer/composer": "^2.4", + "simplesamlphp/simplesamlphp-test-framework": "^1.5.4" + }, + "type": "composer-plugin", + "extra": { + "class": "SimpleSAML\\Composer\\XMLProvider\\XMLProviderInstallerPlugin" + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Composer\\XMLProvider\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "description": "A composer plugin that will auto-generate a classmap with all classes that implement SerializableElementInterface.", + "support": { + "issues": "https://github.com/simplesamlphp/composer-xmlprovider-installer/issues", + "source": "https://github.com/simplesamlphp/composer-xmlprovider-installer/tree/v1.0.2" + }, + "time": "2025-06-28T18:54:25+00:00" + }, + { + "name": "simplesamlphp/saml2", + "version": "v5.0.2", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/saml2.git", + "reference": "d23dce11ac5a9b84a37a283ea7fbb0d780771e6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/saml2/zipball/d23dce11ac5a9b84a37a283ea7fbb0d780771e6c", + "reference": "d23dce11ac5a9b84a37a283ea7fbb0d780771e6c", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-openssl": "*", + "ext-pcre": "*", + "ext-zlib": "*", + "nyholm/psr7": "~1.8.2", + "php": "^8.1", + "psr/clock": "~1.0.0", + "psr/http-message": "~2.0", + "psr/log": "~2.3.1 || ~3.0.0", + "simplesamlphp/assert": "~1.8.1", + "simplesamlphp/xml-common": "~1.25.0", + "simplesamlphp/xml-security": "~1.13.4", + "simplesamlphp/xml-soap": "~1.7.0" + }, + "require-dev": { + "beste/clock": "~3.0.0", + "ext-intl": "*", + "mockery/mockery": "~1.6.12", + "simplesamlphp/composer-xmlprovider-installer": "~1.0.2", + "simplesamlphp/simplesamlphp-test-framework": "~1.9.2" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SimpleSAML\\SAML2\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Andreas Åkre Solberg", + "email": "andreas.solberg@uninett.no" + } + ], + "description": "SAML2 PHP library from SimpleSAMLphp", + "support": { + "issues": "https://github.com/simplesamlphp/saml2/issues", + "source": "https://github.com/simplesamlphp/saml2/tree/v5.0.2" + }, + "time": "2025-07-01T19:07:40+00:00" + }, + { + "name": "simplesamlphp/saml2-legacy", + "version": "v4.18.1", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/saml2-legacy.git", + "reference": "9bbf43a5ace9c8e5107dad3a613b014b456ecd56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/saml2-legacy/zipball/9bbf43a5ace9c8e5107dad3a613b014b456ecd56", + "reference": "9bbf43a5ace9c8e5107dad3a613b014b456ecd56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-openssl": "*", + "ext-zlib": "*", + "php": ">=7.1 || ^8.0", + "psr/log": "~1.1 || ^2.0 || ^3.0", + "robrichards/xmlseclibs": "^3.1.1", + "webmozart/assert": "^1.9" + }, + "conflict": { + "robrichards/xmlseclibs": "3.1.2" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "sebastian/phpcpd": "~4.1 || ^5.0 || ^6.0", + "simplesamlphp/simplesamlphp-test-framework": "~0.1.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v4.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "SAML2\\": "src/SAML2" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Andreas Åkre Solberg", + "email": "andreas.solberg@uninett.no" + } + ], + "description": "SAML2 PHP library from SimpleSAMLphp", + "support": { + "source": "https://github.com/simplesamlphp/saml2-legacy/tree/v4.18.1" + }, + "time": "2025-03-16T11:50:02+00:00" + }, + { + "name": "simplesamlphp/simplesamlphp", + "version": "v2.4.2", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/simplesamlphp.git", + "reference": "d791ed73656102f4d553f7e0335cc6a528b1c2dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp/zipball/d791ed73656102f4d553f7e0335cc6a528b1c2dd", + "reference": "d791ed73656102f4d553f7e0335cc6a528b1c2dd", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-dom": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-pcre": "*", + "ext-session": "*", + "ext-simplexml": "*", + "ext-spl": "*", + "ext-zlib": "*", + "gettext/gettext": "^5.7", + "gettext/translator": "^1.1", + "php": "^8.1", + "phpmailer/phpmailer": "^6.8", + "psr/log": "^3.0", + "simplesamlphp/assert": "^1.1", + "simplesamlphp/composer-module-installer": "^1.3", + "simplesamlphp/saml2": "^5.0.0", + "simplesamlphp/saml2-legacy": "^4.18.1", + "simplesamlphp/simplesamlphp-assets-base": "~2.3.0", + "simplesamlphp/xml-security": "^1.7", + "symfony/cache": "^6.4", + "symfony/config": "^6.4", + "symfony/console": "^6.4", + "symfony/dependency-injection": "^6.4", + "symfony/filesystem": "^6.4", + "symfony/finder": "^6.4", + "symfony/framework-bundle": "^6.4", + "symfony/http-foundation": "^6.4", + "symfony/http-kernel": "^6.4", + "symfony/intl": "^6.4", + "symfony/password-hasher": "^6.4", + "symfony/polyfill-intl-icu": "^1.28", + "symfony/routing": "^6.4", + "symfony/translation-contracts": "^3.0", + "symfony/twig-bridge": "^6.4", + "symfony/var-exporter": "^6.4", + "symfony/yaml": "^6.4", + "twig/intl-extra": "^3.7", + "twig/twig": "^3.14.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-pdo_sqlite": "*", + "gettext/php-scanner": "1.3.1", + "mikey179/vfsstream": "~1.6", + "predis/predis": "^2.2", + "simplesamlphp/simplesamlphp-test-framework": "^1.9.2", + "symfony/translation": "^6.4" + }, + "suggest": { + "ext-curl": "Needed in order to check for updates automatically", + "ext-intl": "Needed if translations for non-English languages are required.", + "ext-ldap": "Needed if an LDAP backend is used", + "ext-memcache": "Needed if a Memcache server is used to store session information", + "ext-mysql": "Needed if a MySQL backend is used, either for authentication or to store session information", + "ext-pdo": "Needed if a database backend is used, either for authentication or to store session information", + "ext-pgsql": "Needed if a PostgreSQL backend is used, either for authentication or to store session information", + "predis/predis": "Needed if a Redis server is used to store session information" + }, + "type": "project", + "extra": { + "branch-alias": { + "dev-master": "2.5.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/_autoload_modules.php" + ], + "psr-4": { + "SimpleSAML\\": "src/SimpleSAML", + "SimpleSAML\\Module\\core\\": "modules/core/src", + "SimpleSAML\\Module\\cron\\": "modules/cron/src", + "SimpleSAML\\Module\\saml\\": "modules/saml/src", + "SimpleSAML\\Module\\admin\\": "modules/admin/src", + "SimpleSAML\\Module\\multiauth\\": "modules/multiauth/src", + "SimpleSAML\\Module\\exampleauth\\": "modules/exampleauth/src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Andreas Åkre Solberg", + "email": "andreas.solberg@uninett.no" + }, + { + "name": "Olav Morken", + "email": "olav.morken@uninett.no" + }, + { + "name": "Jaime Perez", + "email": "jaime.perez@uninett.no" + } + ], + "description": "A PHP implementation of a SAML 2.0 service provider and identity provider.", + "homepage": "https://simplesamlphp.org", + "keywords": [ + "SAML2", + "idp", + "oauth", + "shibboleth", + "sp", + "ws-federation" + ], + "support": { + "issues": "https://github.com/simplesamlphp/simplesamlphp/issues", + "source": "https://github.com/simplesamlphp/simplesamlphp" + }, + "time": "2025-06-04T13:10:38+00:00" + }, + { + "name": "simplesamlphp/simplesamlphp-assets-base", + "version": "v2.3.10", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/simplesamlphp-assets-base.git", + "reference": "39ac268fb1c49333a188df6094b69e28e35150f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp-assets-base/zipball/39ac268fb1c49333a188df6094b69e28e35150f6", + "reference": "39ac268fb1c49333a188df6094b69e28e35150f6", + "shasum": "" + }, + "require": { + "php": "^8.1", + "simplesamlphp/composer-module-installer": "^1.3.4" + }, + "type": "simplesamlphp-module", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + } + ], + "description": "Assets for the SimpleSAMLphp main repository", + "support": { + "issues": "https://github.com/simplesamlphp/simplesamlphp-assets-base/issues", + "source": "https://github.com/simplesamlphp/simplesamlphp-assets-base/tree/v2.3.10" + }, + "time": "2025-07-20T01:44:13+00:00" + }, + { + "name": "simplesamlphp/xml-common", + "version": "v1.25.1", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/xml-common.git", + "reference": "999603aa521d91e17b562bb0b498513af80eb190" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/xml-common/zipball/999603aa521d91e17b562bb0b498513af80eb190", + "reference": "999603aa521d91e17b562bb0b498513af80eb190", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": "^8.1", + "simplesamlphp/assert": "~1.8.1", + "simplesamlphp/composer-xmlprovider-installer": "~1.0.2", + "symfony/finder": "~6.4.0" + }, + "require-dev": { + "simplesamlphp/simplesamlphp-test-framework": "~1.9.2" + }, + "type": "simplesamlphp-xmlprovider", + "autoload": { + "psr-4": { + "SimpleSAML\\XML\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Jaime Perez", + "email": "jaime.perez@uninett.no" + }, + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + } + ], + "description": "A library with classes and utilities for handling XML structures.", + "homepage": "http://simplesamlphp.org", + "keywords": [ + "saml", + "xml" + ], + "support": { + "issues": "https://github.com/simplesamlphp/xml-common/issues", + "source": "https://github.com/simplesamlphp/xml-common" + }, + "time": "2025-06-29T13:05:44+00:00" + }, + { + "name": "simplesamlphp/xml-security", + "version": "v1.13.7", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/xml-security.git", + "reference": "f6f32a3c2c6b398408d5bccc9d59445edc1cb67d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/xml-security/zipball/f6f32a3c2c6b398408d5bccc9d59445edc1cb67d", + "reference": "f6f32a3c2c6b398408d5bccc9d59445edc1cb67d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": "^8.1", + "simplesamlphp/assert": "~1.8.1", + "simplesamlphp/xml-common": "~1.25.0" + }, + "require-dev": { + "simplesamlphp/simplesamlphp-test-framework": "~1.9.2" + }, + "type": "simplesamlphp-xmlprovider", + "autoload": { + "psr-4": { + "SimpleSAML\\XMLSecurity\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Jaime Perez Crespo", + "email": "jaime.perez@uninett.no", + "role": "Maintainer" + }, + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com", + "role": "Maintainer" + } + ], + "description": "SimpleSAMLphp library for XML Security", + "homepage": "https://github.com/simplesamlphp/xml-security", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/simplesamlphp/xml-security/issues", + "source": "https://github.com/simplesamlphp/xml-security/tree/v1.13.7" + }, + "time": "2025-06-29T13:07:27+00:00" + }, + { + "name": "simplesamlphp/xml-soap", + "version": "v1.7.1", + "source": { + "type": "git", + "url": "https://github.com/simplesamlphp/xml-soap.git", + "reference": "ca1ee4ea29c62fa66fc30d040b4013b4543f4f76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplesamlphp/xml-soap/zipball/ca1ee4ea29c62fa66fc30d040b4013b4543f4f76", + "reference": "ca1ee4ea29c62fa66fc30d040b4013b4543f4f76", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "php": "^8.1", + "simplesamlphp/assert": "~1.8.1", + "simplesamlphp/xml-common": "~1.25.0" + }, + "require-dev": { + "simplesamlphp/simplesamlphp-test-framework": "~1.9.2" + }, + "type": "simplesamlphp-xmlprovider", + "extra": { + "branch-alias": { + "dev-master": "v2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SimpleSAML\\SOAP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Tim van Dijen", + "email": "tvdijen@gmail.com" + } + ], + "description": "SimpleSAMLphp library for XML SOAP", + "support": { + "issues": "https://github.com/simplesamlphp/xml-soap/issues", + "source": "https://github.com/simplesamlphp/xml-soap/tree/v1.7.1" + }, + "time": "2025-06-03T21:07:04+00:00" + }, + { + "name": "swaggest/json-diff", + "version": "v3.12.1", + "source": { + "type": "git", + "url": "https://github.com/swaggest/json-diff.git", + "reference": "7ebc4eab95bcc73916433964c266588d09b35052" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swaggest/json-diff/zipball/7ebc4eab95bcc73916433964c266588d09b35052", + "reference": "7ebc4eab95bcc73916433964c266588d09b35052", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.1" + }, + "require-dev": { + "phperf/phpunit": "4.8.37" + }, + "type": "library", + "autoload": { + "psr-4": { + "Swaggest\\JsonDiff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Viacheslav Poturaev", + "email": "vearutop@gmail.com" + } + ], + "description": "JSON diff/rearrange/patch/pointer library for PHP", + "support": { + "issues": "https://github.com/swaggest/json-diff/issues", + "source": "https://github.com/swaggest/json-diff/tree/v3.12.1" + }, + "time": "2025-03-10T08:22:10+00:00" + }, + { + "name": "swaggest/json-schema", + "version": "v0.12.43", + "source": { + "type": "git", + "url": "https://github.com/swaggest/php-json-schema.git", + "reference": "1f3a77a382c5d273a0f1fe34be3b8af4060a88cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swaggest/php-json-schema/zipball/1f3a77a382c5d273a0f1fe34be3b8af4060a88cd", + "reference": "1f3a77a382c5d273a0f1fe34be3b8af4060a88cd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.1", + "phplang/scope-exit": "^1.0", + "swaggest/json-diff": "^3.8.2", + "symfony/polyfill-mbstring": "^1.19" + }, + "require-dev": { + "phperf/phpunit": "4.8.37" + }, + "suggest": { + "ext-mbstring": "For better performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "Swaggest\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Viacheslav Poturaev", + "email": "vearutop@gmail.com" + } + ], + "description": "High definition PHP structures with JSON-schema based validation", + "support": { + "email": "vearutop@gmail.com", + "issues": "https://github.com/swaggest/php-json-schema/issues", + "source": "https://github.com/swaggest/php-json-schema/tree/v0.12.43" + }, + "time": "2024-12-22T21:18:27+00:00" + }, + { + "name": "symfony/asset", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "cfee7c0d64be113383db74a2fdd65d426b7f3aab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/cfee7c0d64be113383db74a2fdd65d426b7f3aab", + "reference": "cfee7c0d64be113383db74a2fdd65d426b7f3aab", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/http-foundation": "<5.4" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/cache", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/d038cd3054aeaf1c674022a77048b2ef6376a175", + "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.3.6|^7.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/var-dumper": "<5.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T09:32:03+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-13T15:25:07+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/config", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/80e2cf005cf17138c97193be0434cdcfd1b2212e", + "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-26T13:50:30+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350", + "reference": "59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T10:38:54+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "929ab73b93247a15166ee79e807ccee4f930322d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/929ab73b93247a15166ee79e807ccee4f930322d", + "reference": "929ab73b93247a15166ee79e807ccee4f930322d", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4.20|^7.2.5" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.1", + "symfony/finder": "<5.4", + "symfony/proxy-manager-bridge": "<6.3", + "symfony/yaml": "<5.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.1|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T17:30:48+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/doctrine-bridge", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", + "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.6", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<2.15", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/form": "<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/messenger": "<6.4", + "symfony/property-info": "<6.4", + "symfony/security-bundle": "<6.4", + "symfony/security-core": "<6.4", + "symfony/validator": "<6.4" + }, + "require-dev": { + "doctrine/collections": "^1.8|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^3.6|^4", + "doctrine/orm": "^2.15|^3", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/doctrine-messenger": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/form": "^6.4.6|^7.0.6", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/type-info": "^7.1.8", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Doctrine with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:36:08+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "234b6c602f12b00693f4b0d1054386fb30dfc8ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/234b6c602f12b00693f4b0d1054386fb30dfc8ff", + "reference": "234b6c602f12b00693f4b0d1054386fb30dfc8ff", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/process": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:57+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-22T09:11:45+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "73089124388c8510efb8d2d1689285d285937b08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/73089124388c8510efb8d2d1689285d285937b08", + "reference": "73089124388c8510efb8d2d1689285d285937b08", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T12:02:45+00:00" + }, + { + "name": "symfony/flex", + "version": "v2.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/flex.git", + "reference": "423c36e369361003dc31ef11c5f15fb589e52c01" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/flex/zipball/423c36e369361003dc31ef11c5f15fb589e52c01", + "reference": "423c36e369361003dc31ef11c5f15fb589e52c01", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1", + "php": ">=8.0" + }, + "conflict": { + "composer/semver": "<1.7.2" + }, + "require-dev": { + "composer/composer": "^2.1", + "symfony/dotenv": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/phpunit-bridge": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" + }, + "autoload": { + "psr-4": { + "Symfony\\Flex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-05T07:45:19+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "869b94902dd38f2f33718908f2b5d4868e3b9241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/869b94902dd38f2f33718908f2b5d4868e3b9241", + "reference": "869b94902dd38f2f33718908f2b5d4868e3b9241", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.4.12|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.1|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4", + "symfony/polyfill-mbstring": "~1.0", + "symfony/routing": "^6.4|^7.0" + }, + "conflict": { + "doctrine/annotations": "<1.13.1", + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/asset": "<5.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.3", + "symfony/console": "<5.4|>=7.0", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<6.3", + "symfony/lock": "<5.4", + "symfony/mailer": "<5.4", + "symfony/messenger": "<6.3", + "symfony/mime": "<6.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4", + "symfony/runtime": "<5.4.45|>=6.0,<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<5.4", + "symfony/security-csrf": "<5.4", + "symfony/serializer": "<6.4", + "symfony/stopwatch": "<5.4", + "symfony/translation": "<6.4", + "symfony/twig-bridge": "<5.4", + "symfony/twig-bundle": "<5.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "doctrine/annotations": "^1.13.1|^2", + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.2|^7.0", + "symfony/console": "^5.4.9|^6.0.9|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/dotenv": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-client": "^6.3|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/mailer": "^5.4|^6.0|^7.0", + "symfony/messenger": "^6.3|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/notifier": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/scheduler": "^6.4.4|^7.0.4", + "symfony/security-bundle": "^5.4|^6.0|^7.0", + "symfony/semaphore": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/workflow": "^6.4|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/twig": "^2.10|^3.0.4" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-30T07:06:12+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "0341e41d8d8830c31a1dff5cbc5bdb3ec872a073" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0341e41d8d8830c31a1dff5cbc5bdb3ec872a073", + "reference": "0341e41d8d8830c31a1dff5cbc5bdb3ec872a073", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726", + "reference": "b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<5.4", + "symfony/cache": "<5.4", + "symfony/config": "<6.1", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<5.4", + "symfony/messenger": "<5.4", + "symfony/translation": "<5.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<5.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.3", + "twig/twig": "<2.13" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.2|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.5|^6.0.5|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.4|^7.0.4", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.4|^7.0", + "symfony/var-exporter": "^6.2|^7.0", + "twig/twig": "^2.13|^3.0.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-31T09:23:30+00:00" + }, + { + "name": "symfony/intl", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "c0938cd29804e65308051a42d1387f0dd57e1eaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/c0938cd29804e65308051a42d1387f0dd57e1eaf", + "reference": "c0938cd29804e65308051a42d1387f0dd57e1eaf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/monolog-bridge", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bridge.git", + "reference": "b0ff45e8d9289062a963deaf8b55e92488322e3f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b0ff45e8d9289062a963deaf8b55e92488322e3f", + "reference": "b0ff45e8d9289062a963deaf8b55e92488322e3f", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1|^2|^3", + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/http-foundation": "<5.4", + "symfony/security-core": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/mailer": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Monolog\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Monolog with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/monolog-bundle", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/monolog-bundle.git", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "shasum": "" + }, + "require": { + "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", + "php": ">=7.2.5", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", + "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MonologBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MonologBundle", + "homepage": "https://symfony.com", + "keywords": [ + "log", + "logging" + ], + "support": { + "issues": "https://github.com/symfony/monolog-bundle/issues", + "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-11-06T17:08:13+00:00" + }, + { + "name": "symfony/password-hasher", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "dcab5ac87450aaed26483ba49c2ce86808da7557" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/dcab5ac87450aaed26483ba49c2ce86808da7557", + "reference": "dcab5ac87450aaed26483ba49c2ce86808da7557", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "symfony/security-core": "<5.4" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/security-core": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-20T22:24:30+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-or-later" + "MIT" ], "authors": [ { - "name": "Andreas Åkre Solberg", - "email": "andreas.solberg@uninett.no" - }, - { - "name": "Olav Morken", - "email": "olav.morken@uninett.no" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Jaime Perez", - "email": "jaime.perez@uninett.no" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "A PHP implementation of a SAML 2.0 service provider and identity provider, also compatible with Shibboleth 1.3 and 2.0.", - "homepage": "http://simplesamlphp.org", + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", "keywords": [ - "SAML2", - "idp", - "oauth", - "shibboleth", - "sp", - "ws-federation" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "issues": "https://github.com/simplesamlphp/simplesamlphp/issues", - "source": "https://github.com/simplesamlphp/simplesamlphp" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" }, - "time": "2018-12-20T16:49:03+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/debug", - "version": "v4.4.44", + "name": "symfony/polyfill-php83", + "version": "v1.32.0", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "1a692492190773c5310bc7877cb590c04c2f05be" + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/1a692492190773c5310bc7877cb590c04c2f05be", - "reference": "1a692492190773c5310bc7877cb590c04c2f05be", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", "shasum": "" }, "require": { - "php": ">=7.1.3", - "psr/log": "^1|^2|^3" - }, - "conflict": { - "symfony/http-kernel": "<3.4" - }, - "require-dev": { - "symfony/http-kernel": "^3.4|^4.0|^5.0" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Polyfill\\Php83\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2197,18 +7185,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to ease debugging PHP code", + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/debug/tree/v4.4.44" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -2224,39 +7218,41 @@ "type": "tidelift" } ], - "abandoned": "symfony/error-handler", - "time": "2022-07-28T16:29:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v2.5.3", + "name": "symfony/polyfill-php84", + "version": "v1.32.0", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "000df7860439609837bbe28670b0be15783b7fbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/80d075412b557d41002320b96a096ca65aa2c98d", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "2.5-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { "files": [ - "function.php" + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2273,10 +7269,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" }, "funding": [ { @@ -2292,36 +7294,34 @@ "type": "tidelift" } ], - "time": "2023-01-24T14:02:46+00:00" + "time": "2025-02-20T12:04:08+00:00" }, { - "name": "symfony/error-handler", - "version": "v4.4.44", + "name": "symfony/property-access", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/error-handler.git", - "reference": "be731658121ef2d8be88f3a1ec938148a9237291" + "url": "https://github.com/symfony/property-access.git", + "reference": "a33acdae7c76f837c1db5465cc3445adf3ace94a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/be731658121ef2d8be88f3a1ec938148a9237291", - "reference": "be731658121ef2d8be88f3a1ec938148a9237291", + "url": "https://api.github.com/repos/symfony/property-access/zipball/a33acdae7c76f837c1db5465cc3445adf3ace94a", + "reference": "a33acdae7c76f837c1db5465cc3445adf3ace94a", "shasum": "" }, "require": { - "php": ">=7.1.3", - "psr/log": "^1|^2|^3", - "symfony/debug": "^4.4.5", - "symfony/var-dumper": "^4.4|^5.0" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/property-info": "^5.4|^6.0|^7.0" }, "require-dev": { - "symfony/http-kernel": "^4.4|^5.0", - "symfony/serializer": "^4.4|^5.0" + "symfony/cache": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\ErrorHandler\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2341,10 +7341,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to manage errors and ease debugging PHP code", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], "support": { - "source": "https://github.com/symfony/error-handler/tree/v4.4.44" + "source": "https://github.com/symfony/property-access/tree/v6.4.24" }, "funding": [ { @@ -2355,57 +7366,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-07-28T16:29:46+00:00" + "time": "2025-07-15T12:03:16+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v4.4.44", + "name": "symfony/property-info", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "1e866e9e5c1b22168e0ce5f0b467f19bba61266a" + "url": "https://github.com/symfony/property-info.git", + "reference": "1056ae3621eeddd78d7c5ec074f1c1784324eec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1e866e9e5c1b22168e0ce5f0b467f19bba61266a", - "reference": "1e866e9e5c1b22168e0ce5f0b467f19bba61266a", + "url": "https://api.github.com/repos/symfony/property-info/zipball/1056ae3621eeddd78d7c5ec074f1c1784324eec6", + "reference": "1056ae3621eeddd78d7c5ec074f1c1784324eec6", "shasum": "" }, "require": { - "php": ">=7.1.3", - "symfony/event-dispatcher-contracts": "^1.1", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.1", + "symfony/string": "^5.4|^6.0|^7.0" }, "conflict": { - "symfony/dependency-injection": "<3.4" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "1.1" + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<5.4", + "symfony/dependency-injection": "<5.4|>=6.0,<6.4", + "symfony/serializer": "<5.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^3.4|^4.0|^5.0", - "symfony/dependency-injection": "^3.4|^4.0|^5.0", - "symfony/error-handler": "~3.4|~4.4", - "symfony/expression-language": "^3.4|^4.0|^5.0", - "symfony/http-foundation": "^3.4|^4.0|^5.0", - "symfony/service-contracts": "^1.1|^2", - "symfony/stopwatch": "^3.4|^4.0|^5.0" - }, - "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/serializer": "^5.4|^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2417,18 +7426,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v4.4.44" + "source": "https://github.com/symfony/property-info/tree/v6.4.24" }, "funding": [ { @@ -2439,48 +7456,48 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-07-20T09:59:04+00:00" + "time": "2025-07-14T16:38:25+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v1.10.0", + "name": "symfony/proxy-manager-bridge", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "761c8b8387cfe5f8026594a75fdf0a4e83ba6974" + "url": "https://github.com/symfony/proxy-manager-bridge.git", + "reference": "2a14a1539f2854a8adb73319abf8923b1d7a6589" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/761c8b8387cfe5f8026594a75fdf0a4e83ba6974", - "reference": "761c8b8387cfe5f8026594a75fdf0a4e83ba6974", + "url": "https://api.github.com/repos/symfony/proxy-manager-bridge/zipball/2a14a1539f2854a8adb73319abf8923b1d7a6589", + "reference": "2a14a1539f2854a8adb73319abf8923b1d7a6589", "shasum": "" }, "require": { - "php": ">=7.1.3" - }, - "suggest": { - "psr/event-dispatcher": "", - "symfony/event-dispatcher-implementation": "" + "friendsofphp/proxy-manager-lts": "^1.0.2", + "php": ">=8.1", + "symfony/dependency-injection": "^6.3|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "1.1-dev" - } + "require-dev": { + "symfony/config": "^6.1|^7.0" }, + "type": "symfony-bridge", "autoload": { "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Bridge\\ProxyManager\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2488,26 +7505,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Provides integration for ProxyManager with various Symfony components", "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v1.10.0" + "source": "https://github.com/symfony/proxy-manager-bridge/tree/v6.4.24" }, "funding": [ { @@ -2518,47 +7527,58 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-07-20T09:59:04+00:00" + "time": "2025-07-14T16:38:25+00:00" }, { - "name": "symfony/http-client-contracts", - "version": "v2.5.3", + "name": "symfony/routing", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "e5cc97c2b4a4db0ba26bebc154f1426e3fd1d2f1" + "url": "https://github.com/symfony/routing.git", + "reference": "e4f94e625c8e6f910aa004a0042f7b2d398278f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/e5cc97c2b4a4db0ba26bebc154f1426e3fd1d2f1", - "reference": "e5cc97c2b4a4db0ba26bebc154f1426e3fd1d2f1", + "url": "https://api.github.com/repos/symfony/routing/zipball/e4f94e625c8e6f910aa004a0042f7b2d398278f5", + "reference": "e4f94e625c8e6f910aa004a0042f7b2d398278f5", "shasum": "" }, "require": { - "php": ">=7.2.5" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "symfony/http-client-implementation": "" + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "2.5-dev" - } + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\HttpClient\\": "" - } + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2566,26 +7586,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to HTTP clients", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "router", + "routing", + "uri", + "url" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/routing/tree/v6.4.24" }, "funding": [ { @@ -2596,41 +7614,53 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-03-26T19:42:53+00:00" + "time": "2025-07-15T08:46:37+00:00" }, { - "name": "symfony/http-foundation", - "version": "v4.4.49", + "name": "symfony/runtime", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "191413c7b832c015bb38eae963f2e57498c3c173" + "url": "https://github.com/symfony/runtime.git", + "reference": "c1cc6721646f546627236c57f835272806087337" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/191413c7b832c015bb38eae963f2e57498c3c173", - "reference": "191413c7b832c015bb38eae963f2e57498c3c173", + "url": "https://api.github.com/repos/symfony/runtime/zipball/c1cc6721646f546627236c57f835272806087337", + "reference": "c1cc6721646f546627236c57f835272806087337", "shasum": "" }, "require": { - "php": ">=7.1.3", - "symfony/mime": "^4.3|^5.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php80": "^1.16" + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.1" + }, + "conflict": { + "symfony/dotenv": "<5.4" }, "require-dev": { - "predis/predis": "~1.0", - "symfony/expression-language": "^3.4|^4.0|^5.0" + "composer/composer": "^1.0.2|^2.0", + "symfony/console": "^5.4.9|^6.0.9|^7.0", + "symfony/dotenv": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" }, "exclude-from-classmap": [ "/Tests/" @@ -2642,18 +7672,21 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Defines an object-oriented layer for the HTTP specification", + "description": "Enables decoupling PHP applications from global state", "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], "support": { - "source": "https://github.com/symfony/http-foundation/tree/v4.4.49" + "source": "https://github.com/symfony/runtime/tree/v6.4.24" }, "funding": [ { @@ -2664,77 +7697,89 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-11-04T16:17:57+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/http-kernel", - "version": "v4.4.51", + "name": "symfony/security-bundle", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/http-kernel.git", - "reference": "ad8ab192cb619ff7285c95d28c69b36d718416c7" + "url": "https://github.com/symfony/security-bundle.git", + "reference": "3b1b64ab12e74d76fedddd1df1fa68bd014d3efb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ad8ab192cb619ff7285c95d28c69b36d718416c7", - "reference": "ad8ab192cb619ff7285c95d28c69b36d718416c7", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/3b1b64ab12e74d76fedddd1df1fa68bd014d3efb", + "reference": "3b1b64ab12e74d76fedddd1df1fa68bd014d3efb", "shasum": "" }, "require": { - "php": ">=7.1.3", - "psr/log": "^1|^2", - "symfony/error-handler": "^4.4", - "symfony/event-dispatcher": "^4.4", - "symfony/http-client-contracts": "^1.1|^2", - "symfony/http-foundation": "^4.4.30|^5.3.7", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16" + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.1", + "symfony/clock": "^6.3|^7.0", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.4.11|^7.1.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.2|^7.0", + "symfony/http-kernel": "^6.2", + "symfony/password-hasher": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.2|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/security-http": "^6.3.6|^7.0", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/browser-kit": "<4.3", - "symfony/config": "<3.4", - "symfony/console": ">=5", - "symfony/dependency-injection": "<4.3", - "symfony/translation": "<4.2", - "twig/twig": "<1.43|<2.13,>=2" - }, - "provide": { - "psr/log-implementation": "1.0|2.0" + "symfony/browser-kit": "<5.4", + "symfony/console": "<5.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-client": "<5.4", + "symfony/ldap": "<5.4", + "symfony/serializer": "<6.4", + "symfony/twig-bundle": "<5.4", + "symfony/validator": "<6.4" }, "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^4.3|^5.0", - "symfony/config": "^3.4|^4.0|^5.0", - "symfony/console": "^3.4|^4.0", - "symfony/css-selector": "^3.4|^4.0|^5.0", - "symfony/dependency-injection": "^4.3|^5.0", - "symfony/dom-crawler": "^3.4|^4.0|^5.0", - "symfony/expression-language": "^3.4|^4.0|^5.0", - "symfony/finder": "^3.4|^4.0|^5.0", - "symfony/process": "^3.4|^4.0|^5.0", - "symfony/routing": "^3.4|^4.0|^5.0", - "symfony/stopwatch": "^3.4|^4.0|^5.0", - "symfony/templating": "^3.4|^4.0|^5.0", - "symfony/translation": "^4.2|^5.0", - "symfony/translation-contracts": "^1.1|^2", - "twig/twig": "^1.43|^2.13|^3.0.4" - }, - "suggest": { - "symfony/browser-kit": "", - "symfony/config": "", - "symfony/console": "", - "symfony/dependency-injection": "" - }, - "type": "library", + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/twig-bridge": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/twig": "^2.13|^3.0.4", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1", + "web-token/jwt-signature-algorithm-eddsa": "^3.1", + "web-token/jwt-signature-algorithm-hmac": "^3.1", + "web-token/jwt-signature-algorithm-none": "^3.1", + "web-token/jwt-signature-algorithm-rsa": "^3.1" + }, + "type": "symfony-bundle", "autoload": { "psr-4": { - "Symfony\\Component\\HttpKernel\\": "" + "Symfony\\Bundle\\SecurityBundle\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2754,10 +7799,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a structured process for converting a Request into a Response", + "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v4.4.51" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.24" }, "funding": [ { @@ -2768,54 +7813,63 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-11-10T13:31:29+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/mime", - "version": "v5.4.41", + "name": "symfony/security-core", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "c71c7a1aeed60b22d05e738197e31daf2120bd42" + "url": "https://github.com/symfony/security-core.git", + "reference": "8ff659ffd3b823f0b3969b6c7a602b80b6ec2e53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/c71c7a1aeed60b22d05e738197e31daf2120bd42", - "reference": "c71c7a1aeed60b22d05e738197e31daf2120bd42", + "url": "https://api.github.com/repos/symfony/security-core/zipball/8ff659ffd3b823f0b3969b6c7a602b80b6ec2e53", + "reference": "8ff659ffd3b823f0b3969b6c7a602b80b6ec2e53", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<4.4", - "symfony/serializer": "<5.4.35|>=6,<6.3.12|>=6.4,<6.4.3" + "symfony/event-dispatcher": "<5.4", + "symfony/http-foundation": "<5.4", + "symfony/ldap": "<5.4", + "symfony/security-guard": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<5.4" }, "require-dev": { - "egulias/email-validator": "^2.1.10|^3.1|^4", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/process": "^5.4|^6.4", - "symfony/property-access": "^4.4|^5.1|^6.0", - "symfony/property-info": "^4.4|^5.1|^6.0", - "symfony/serializer": "^5.4.35|~6.3.12|^6.4.3" + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/ldap": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/validator": "^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Mime\\": "" + "Symfony\\Component\\Security\\Core\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2835,14 +7889,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Allows manipulating MIME messages", + "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", - "keywords": [ - "mime", - "mime-type" - ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.4.41" + "source": "https://github.com/symfony/security-core/tree/v6.4.24" }, "funding": [ { @@ -2853,50 +7903,51 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-06-28T09:36:24+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.30.0", + "name": "symfony/security-csrf", + "version": "v7.3.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + "url": "https://github.com/symfony/security-csrf.git", + "reference": "2b4b0c46c901729e4e90719eacd980381f53e0a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/2b4b0c46c901729e4e90719eacd980381f53e0a3", + "reference": "2b4b0c46c901729e4e90719eacd980381f53e0a3", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.2", + "symfony/security-core": "^6.4|^7.0" }, - "provide": { - "ext-ctype": "*" + "conflict": { + "symfony/http-foundation": "<6.4" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Component\\Security\\Csrf\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2904,24 +7955,18 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + "source": "https://github.com/symfony/security-csrf/tree/v7.3.0" }, "funding": [ { @@ -2937,44 +7982,60 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2025-01-02T18:42:10+00:00" }, { - "name": "symfony/polyfill-intl-idn", - "version": "v1.30.0", + "name": "symfony/security-http", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c" + "url": "https://github.com/symfony/security-http.git", + "reference": "bd6ce061b70071afea0a4805903b6ed3f6f64e07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", - "reference": "a6e83bdeb3c84391d1dfe16f42e40727ce524a5c", + "url": "https://api.github.com/repos/symfony/security-http/zipball/bd6ce061b70071afea0a4805903b6ed3f6f64e07", + "reference": "bd6ce061b70071afea0a4805903b6ed3f6f64e07", "shasum": "" }, "require": { - "php": ">=7.1", - "symfony/polyfill-intl-normalizer": "^1.10", - "symfony/polyfill-php72": "^1.10" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.2|^7.0", + "symfony/http-kernel": "^6.3|^7.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "symfony/clock": "<6.3", + "symfony/event-dispatcher": "<5.4.9|>=6,<6.0.9", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<5.4", + "symfony/security-csrf": "<5.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/clock": "^6.3|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "web-token/jwt-checker": "^3.1", + "web-token/jwt-signature-algorithm-ecdsa": "^3.1" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - } + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2982,30 +8043,18 @@ ], "authors": [ { - "name": "Laurent Bassin", - "email": "laurent@bassin.info" - }, - { - "name": "Trevor Rowbotham", - "email": "trevor.rowbotham@pm.me" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "idn", - "intl", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.30.0" + "source": "https://github.com/symfony/security-http/tree/v6.4.24" }, "funding": [ { @@ -3016,49 +8065,78 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.30.0", + "name": "symfony/serializer", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" + "url": "https://github.com/symfony/serializer.git", + "reference": "c01c719c8a837173dc100f2bd141a6271ea68a1d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "url": "https://api.github.com/repos/symfony/serializer/zipball/c01c719c8a837173dc100f2bd141a6271ea68a1d", + "reference": "c01c719c8a837173dc100f2bd141a6271ea68a1d", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4.24|>=6,<6.2.11", + "symfony/uid": "<5.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<5.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/property-access": "^5.4.26|^6.3|^7.0", + "symfony/property-info": "^5.4.24|^6.2.11|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^5.4|^6.0|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Component\\Serializer\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3067,26 +8145,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" + "source": "https://github.com/symfony/serializer/tree/v6.4.24" }, "funding": [ { @@ -3097,50 +8167,56 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "name": "symfony/service-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3156,17 +8232,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -3182,34 +8259,34 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { - "name": "symfony/polyfill-php56", - "version": "v1.20.0", + "name": "symfony/stopwatch", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php56.git", - "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + "url": "https://github.com/symfony/stopwatch.git", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", - "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "symfony/service-contracts": "^2.5|^3" }, - "type": "metapackage", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" }, - "branch-alias": { - "dev-main": "1.20-dev" - } + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3217,24 +8294,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "description": "Provides a way to profile code", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0" + "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" }, "funding": [ { @@ -3245,44 +8316,60 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2020-10-23T14:02:19+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/polyfill-php72", - "version": "v1.30.0", + "name": "symfony/string", + "version": "v7.3.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "10112722600777e02d2745716b70c5db4ca70442" + "url": "https://github.com/symfony/string.git", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/10112722600777e02d2745716b70c5db4ca70442", - "reference": "10112722600777e02d2745716b70c5db4ca70442", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" }, + "type": "library", "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - } + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3298,16 +8385,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.30.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -3318,46 +8407,50 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { - "name": "symfony/polyfill-php73", - "version": "v1.30.0", + "name": "symfony/translation-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1" + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/ec444d3f3f6505bb28d11afa41e75faadebc10a1", - "reference": "ec444d3f3f6505bb28d11afa41e75faadebc10a1", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" + "Symfony\\Contracts\\Translation\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3374,16 +8467,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "description": "Generic abstractions related to translation", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.30.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" }, "funding": [ { @@ -3399,41 +8494,80 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-27T08:32:26+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "name": "symfony/twig-bridge", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "af9ef04e348f93410c83d04d2806103689a3d924" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/af9ef04e348f93410c83d04d2806103689a3d924", + "reference": "af9ef04e348f93410c83d04d2806103689a3d924", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^2.13|^3.0.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/console": "<5.4", + "symfony/form": "<6.3", + "symfony/http-foundation": "<5.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.2", + "symfony/serializer": "<6.4", + "symfony/translation": "<5.4", + "symfony/workflow": "<5.4" }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/asset-mapper": "^6.3|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/form": "^6.4.20|^7.2.5", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/mime": "^6.2|^7.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^5.4|^6.0|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/security-http": "^5.4|^6.0|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^6.1|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/workflow": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0", + "twig/cssinliner-extra": "^2.12|^3", + "twig/inky-extra": "^2.12|^3", + "twig/markdown-extra": "^2.12|^3" + }, + "type": "symfony-bridge", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Bridge\\Twig\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3442,28 +8576,18 @@ ], "authors": [ { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.24" }, "funding": [ { @@ -3471,7 +8595,11 @@ "type": "custom" }, { - "url": "https://github.com/fabpot", + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" }, { @@ -3479,41 +8607,55 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2025-07-26T12:47:35+00:00" }, { - "name": "symfony/polyfill-php81", - "version": "v1.29.0", + "name": "symfony/twig-bundle", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d" + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "3b48b6e8225495c6d2438828982b4d219ca565ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/c565ad1e63f30e7477fc40738343c62b40bc672d", - "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/3b48b6e8225495c6d2438828982b4d219ca565ba", + "reference": "3b48b6e8225495c6d2438828982b4d219ca565ba", "shasum": "" }, "require": { - "php": ">=7.1" + "composer-runtime-api": ">=2.1", + "php": ">=8.1", + "symfony/config": "^6.1|^7.0", + "symfony/dependency-injection": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.2", + "symfony/twig-bridge": "^6.4", + "twig/twig": "^2.13|^3.0.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "symfony/framework-bundle": "<5.4", + "symfony/translation": "<5.4" }, + "require-dev": { + "symfony/asset": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/form": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/web-link": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bundle", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" + "Symfony\\Bundle\\TwigBundle\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3522,24 +8664,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.29.0" + "source": "https://github.com/symfony/twig-bundle/tree/v6.4.24" }, "funding": [ { @@ -3550,39 +8686,56 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-01-29T20:11:03+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/process", - "version": "v2.8.52", + "name": "symfony/var-dumper", + "version": "v7.3.2", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8" + "url": "https://github.com/symfony/var-dumper.git", + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c3591a09c78639822b0b290d44edb69bf9f05dc8", - "reference": "c3591a09c78639822b0b290d44edb69bf9f05dc8", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { - "php": ">=5.3.9" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.8-dev" - } + "conflict": { + "symfony/console": "<6.4" }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", "autoload": { + "files": [ + "Resources/functions/dump.php" + ], "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\VarDumper\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3594,64 +8747,70 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Process Component", + "description": "Provides mechanisms for walking through any arbitrary PHP variable", "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], "support": { - "source": "https://github.com/symfony/process/tree/v2.8.50" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, - "time": "2018-11-11T11:18:13+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-29T20:02:46+00:00" }, { - "name": "symfony/routing", - "version": "v4.4.44", + "name": "symfony/var-exporter", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "f7751fd8b60a07f3f349947a309b5bdfce22d6ae" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/f7751fd8b60a07f3f349947a309b5bdfce22d6ae", - "reference": "f7751fd8b60a07f3f349947a309b5bdfce22d6ae", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35", + "reference": "1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35", "shasum": "" }, "require": { - "php": ">=7.1.3", - "symfony/polyfill-php80": "^1.16" - }, - "conflict": { - "symfony/config": "<4.2", - "symfony/dependency-injection": "<3.4", - "symfony/yaml": "<3.4" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "doctrine/annotations": "^1.10.4", - "psr/log": "^1|^2|^3", - "symfony/config": "^4.2|^5.0", - "symfony/dependency-injection": "^3.4|^4.0|^5.0", - "symfony/expression-language": "^3.4|^4.0|^5.0", - "symfony/http-foundation": "^3.4|^4.0|^5.0", - "symfony/yaml": "^3.4|^4.0|^5.0" - }, - "suggest": { - "doctrine/annotations": "For using the annotation loader", - "symfony/config": "For using the all-in-one router or any loader", - "symfony/expression-language": "For using expression matching", - "symfony/http-foundation": "For using a Symfony Request object", - "symfony/yaml": "For using the YAML loader" + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\VarExporter\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3663,24 +8822,28 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Allows exporting any serializable PHP data structure to plain PHP code", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" ], "support": { - "source": "https://github.com/symfony/routing/tree/v4.4.44" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.24" }, "funding": [ { @@ -3691,58 +8854,49 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-07-20T09:59:04+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/var-dumper", - "version": "v5.4.42", + "name": "symfony/yaml", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "0c17c56d8ea052fc33942251c75d0e28936e043d" + "url": "https://github.com/symfony/yaml.git", + "reference": "742a8efc94027624b36b10ba58e23d402f961f51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0c17c56d8ea052fc33942251c75d0e28936e043d", - "reference": "0c17c56d8ea052fc33942251c75d0e28936e043d", + "url": "https://api.github.com/repos/symfony/yaml/zipball/742a8efc94027624b36b10ba58e23d402f961f51", + "reference": "742a8efc94027624b36b10ba58e23d402f961f51", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<4.4" + "symfony/console": "<5.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^4.4|^5.0|^6.0", - "symfony/http-kernel": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/uid": "^5.1|^6.0", - "twig/twig": "^2.13|^3.0.4" - }, - "suggest": { - "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", - "ext-intl": "To show region name in time zone dump", - "symfony/console": "To use the ServerDumpCommand and/or the bin/var-dump-server script" + "symfony/console": "^5.4|^6.0|^7.0" }, "bin": [ - "Resources/bin/var-dump-server" + "Resources/bin/yaml-lint" ], "type": "library", "autoload": { - "files": [ - "Resources/functions/dump.php" - ], "psr-4": { - "Symfony\\Component\\VarDumper\\": "" + "Symfony\\Component\\Yaml\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3754,22 +8908,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", - "keywords": [ - "debug", - "dump" - ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v5.4.42" + "source": "https://github.com/symfony/yaml/tree/v6.4.24" }, "funding": [ { @@ -3780,12 +8930,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-07-26T12:23:09+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "taq/pdooci", @@ -3857,42 +9011,35 @@ "homepage": "https://github.com/tildeio/rsvp.js" }, { - "name": "twig/extensions", - "version": "v1.5.4", + "name": "twig/intl-extra", + "version": "v3.21.0", "source": { "type": "git", - "url": "https://github.com/twigphp/Twig-extensions.git", - "reference": "57873c8b0c1be51caa47df2cdb824490beb16202" + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "05bc5d46b9df9e62399eae53e7c0b0633298b146" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig-extensions/zipball/57873c8b0c1be51caa47df2cdb824490beb16202", - "reference": "57873c8b0c1be51caa47df2cdb824490beb16202", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/05bc5d46b9df9e62399eae53e7c0b0633298b146", + "reference": "05bc5d46b9df9e62399eae53e7c0b0633298b146", "shasum": "" }, "require": { - "twig/twig": "^1.27|^2.0" + "php": ">=8.1.0", + "symfony/intl": "^5.4|^6.4|^7.0", + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^3.4", - "symfony/translation": "^2.7|^3.4" - }, - "suggest": { - "symfony/translation": "Allow the time_diff output to be translated" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.5-dev" - } - }, "autoload": { - "psr-0": { - "Twig_Extensions_": "lib/" - }, "psr-4": { - "Twig\\Extensions\\": "src/" - } + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3901,53 +9048,65 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" } ], - "description": "Common additional features for Twig that do not directly belong in core", + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", "keywords": [ - "i18n", - "text" + "intl", + "twig" ], "support": { - "issues": "https://github.com/twigphp/Twig-extensions/issues", - "source": "https://github.com/twigphp/Twig-extensions/tree/master" + "source": "https://github.com/twigphp/intl-extra/tree/v3.21.0" }, - "abandoned": true, - "time": "2018-12-05T18:34:18+00:00" + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2025-01-31T20:45:36+00:00" }, { "name": "twig/twig", - "version": "v1.44.7", + "version": "v3.21.1", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "0887422319889e442458e48e2f3d9add1a172ad5" + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/0887422319889e442458e48e2f3d9add1a172ad5", - "reference": "0887422319889e442458e48e2f3d9add1a172ad5", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-ctype": "^1.8" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "psr/container": "^1.0", - "symfony/phpunit-bridge": "^4.4.9|^5.0.9" + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.44-dev" - } - }, "autoload": { - "psr-0": { - "Twig_": "lib/" - }, + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], "psr-4": { "Twig\\": "src/" } @@ -3980,7 +9139,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v1.44.7" + "source": "https://github.com/twigphp/Twig/tree/v3.21.1" }, "funding": [ { @@ -3992,20 +9151,20 @@ "type": "tidelift" } ], - "time": "2022-09-28T08:38:36+00:00" + "time": "2025-05-03T07:21:55+00:00" }, { "name": "ua-parser/uap-php", - "version": "v3.9.14", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/ua-parser/uap-php.git", - "reference": "b796c5ea5df588e65aeb4e2c6cce3811dec4fed6" + "reference": "f44bdd1b38198801cf60b0681d2d842980e47af5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ua-parser/uap-php/zipball/b796c5ea5df588e65aeb4e2c6cce3811dec4fed6", - "reference": "b796c5ea5df588e65aeb4e2c6cce3811dec4fed6", + "url": "https://api.github.com/repos/ua-parser/uap-php/zipball/f44bdd1b38198801cf60b0681d2d842980e47af5", + "reference": "f44bdd1b38198801cf60b0681d2d842980e47af5", "shasum": "" }, "require": { @@ -4053,99 +9212,9 @@ "description": "A multi-language port of Browserscope's user agent parser.", "support": { "issues": "https://github.com/ua-parser/uap-php/issues", - "source": "https://github.com/ua-parser/uap-php/tree/v3.9.14" - }, - "time": "2020-10-02T23:36:20+00:00" - }, - { - "name": "ubccr/simplesamlphp-module-authglobus", - "version": "1.3.0", - "source": { - "type": "git", - "url": "https://github.com/ubccr/simplesamlphp-module-authglobus.git", - "reference": "d81f53960bdfdb015de267d804863e85d2efb5f6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ubccr/simplesamlphp-module-authglobus/zipball/d81f53960bdfdb015de267d804863e85d2efb5f6", - "reference": "d81f53960bdfdb015de267d804863e85d2efb5f6", - "shasum": "" - }, - "require": { - "simplesamlphp/composer-module-installer": "~1.0" - }, - "require-dev": { - "simplesamlphp/simplesamlphp": "^1.14", - "squizlabs/php_codesniffer": "2.8.0" - }, - "type": "simplesamlphp-module", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0" - ], - "authors": [ - { - "name": "Rudra Chakraborty", - "email": "rudracha@buffalo.edu", - "role": "Scientific Programmer, University at Buffalo" - } - ], - "description": "Globus Auth module for SimpleSAMLphp.", - "support": { - "issues": "https://github.com/ubccr/simplesamlphp-module-authglobus/issues", - "source": "https://github.com/ubccr/simplesamlphp-module-authglobus/tree/master" - }, - "time": "2018-09-10T15:22:34+00:00" - }, - { - "name": "ubccr/simplesamlphp-module-authoidcoauth2", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/ubccr/simplesamlphp-module-authoidcoauth2.git", - "reference": "bad54f7b08bbadfee2444c8a469289a8f0ca51ad" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ubccr/simplesamlphp-module-authoidcoauth2/zipball/bad54f7b08bbadfee2444c8a469289a8f0ca51ad", - "reference": "bad54f7b08bbadfee2444c8a469289a8f0ca51ad", - "shasum": "" - }, - "require": { - "simplesamlphp/composer-module-installer": "~1.0" - }, - "require-dev": { - "simplesamlphp/simplesamlphp": "^1.14", - "squizlabs/php_codesniffer": "2.8.0" + "source": "https://github.com/ua-parser/uap-php/tree/v3.10.0" }, - "type": "simplesamlphp-module", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0" - ], - "authors": [ - { - "name": "Open XDMoD", - "email": "ccr-xdmod-help@buffalo.edu", - "role": "Open XDMoD Project Team, University at Buffalo" - }, - { - "name": "Ben Plessinger", - "email": "bpless@buffalo.edu", - "role": "Senior Scientific Programmer, University at Buffalo" - }, - { - "name": "Ryan Rathsam", - "email": "ryanrath@buffalo.edu", - "role": "Scientific Programmer, University at Buffalo" - } - ], - "description": "Oauth2 / OIDC auth module for SimpleSAMLphp.", - "support": { - "issues": "https://github.com/ubccr/simplesamlphp-module-authoidcoauth2/issues", - "source": "https://github.com/ubccr/simplesamlphp-module-authoidcoauth2/tree/v1.1.0" - }, - "time": "2020-09-11T18:18:04+00:00" + "time": "2025-07-17T15:43:24+00:00" }, { "name": "webmozart/assert", @@ -4200,58 +9269,10 @@ "validate" ], "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" - }, - "time": "2022-06-03T18:03:27+00:00" - }, - { - "name": "whitehat101/apr1-md5", - "version": "v1.0.0", - "source": { - "type": "git", - "url": "https://github.com/whitehat101/apr1-md5.git", - "reference": "8b261c9fc0481b4e9fa9d01c6ca70867b5d5e819" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/whitehat101/apr1-md5/zipball/8b261c9fc0481b4e9fa9d01c6ca70867b5d5e819", - "reference": "8b261c9fc0481b4e9fa9d01c6ca70867b5d5e819", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "4.0.*" - }, - "type": "library", - "autoload": { - "psr-4": { - "WhiteHat101\\Crypt\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jeremy Ebler", - "email": "jebler@gmail.com" - } - ], - "description": "Apache's APR1-MD5 algorithm in pure PHP", - "homepage": "https://github.com/whitehat101/apr1-md5", - "keywords": [ - "MD5", - "apr1" - ], - "support": { - "issues": "https://github.com/whitehat101/apr1-md5/issues", - "source": "https://github.com/whitehat101/apr1-md5/tree/master" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, - "time": "2015-02-11T11:06:42+00:00" + "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ @@ -4304,24 +9325,25 @@ }, { "name": "dms/phpunit-arraysubset-asserts", - "version": "v0.5.0", + "version": "v0.4.0", "source": { "type": "git", "url": "https://github.com/rdohms/phpunit-arraysubset-asserts.git", - "reference": "aa6b9e858414e91cca361cac3b2035ee57d212e0" + "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/aa6b9e858414e91cca361cac3b2035ee57d212e0", - "reference": "aa6b9e858414e91cca361cac3b2035ee57d212e0", + "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/428293c2a00eceefbad71a2dbdfb913febb35de2", + "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2", "shasum": "" }, "require": { "php": "^5.4 || ^7.0 || ^8.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "require-dev": { - "dms/coding-standard": "^9" + "dms/coding-standard": "^9", + "squizlabs/php_codesniffer": "^3.4" }, "type": "library", "autoload": { @@ -4342,92 +9364,22 @@ "description": "This package provides ArraySubset and related asserts once deprecated in PHPUnit 8", "support": { "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues", - "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/v0.5.0" - }, - "time": "2023-06-02T17:33:53+00:00" - }, - { - "name": "doctrine/instantiator", - "version": "1.5.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/v0.4.0" }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" - } - ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2022-02-13T15:00:28+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -4435,11 +9387,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -4465,7 +9418,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -4473,20 +9426,20 @@ "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nikic/php-parser", - "version": "v5.0.2", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/139676794dc1e9231bf7bcd123cfc0c99182cb13", - "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -4497,7 +9450,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -4529,9 +9482,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2024-03-05T20:51:40+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "phar-io/manifest", @@ -4651,85 +9604,37 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phplang/scope-exit", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/phplang/scope-exit.git", - "reference": "239b73abe89f9414aa85a7ca075ec9445629192b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phplang/scope-exit/zipball/239b73abe89f9414aa85a7ca075ec9445629192b", - "reference": "239b73abe89f9414aa85a7ca075ec9445629192b", - "shasum": "" - }, - "require-dev": { - "phpunit/phpunit": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "PhpLang\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD" - ], - "authors": [ - { - "name": "Sara Golemon", - "email": "pollita@php.net", - "homepage": "https://twitter.com/SaraMG", - "role": "Developer" - } - ], - "description": "Emulation of SCOPE_EXIT construct from C++", - "homepage": "https://github.com/phplang/scope-exit", - "keywords": [ - "cleanup", - "exit", - "scope" - ], - "support": { - "issues": "https://github.com/phplang/scope-exit/issues", - "source": "https://github.com/phplang/scope-exit/tree/master" - }, - "time": "2016-09-17T00:15:18+00:00" - }, { "name": "phpunit/php-code-coverage", - "version": "9.2.31", + "version": "9.2.32", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965" + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/48c34b5d8d983006bd2adc2d0de92963b9155965", - "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.18 || ^5.0", + "nikic/php-parser": "^4.19.1 || ^5.1.0", "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.3", - "phpunit/php-text-template": "^2.0.2", - "sebastian/code-unit-reverse-lookup": "^2.0.2", - "sebastian/complexity": "^2.0", - "sebastian/environment": "^5.1.2", - "sebastian/lines-of-code": "^1.0.3", - "sebastian/version": "^3.0.1", - "theseer/tokenizer": "^1.2.0" + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.6" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -4738,7 +9643,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.2-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { @@ -4767,7 +9672,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.31" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { @@ -4775,7 +9680,7 @@ "type": "github" } ], - "time": "2024-03-02T06:37:42+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5020,45 +9925,45 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.19", + "version": "9.6.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a1a54a473501ef4cdeaae4e06891674114d79db8" + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a1a54a473501ef4cdeaae4e06891674114d79db8", - "reference": "a1a54a473501ef4cdeaae4e06891674114d79db8", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", + "reference": "43d2cb18d0675c38bd44982a5d1d88f6d53d8d95", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1 || ^2", + "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", + "myclabs/deep-copy": "^1.13.1", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.28", - "phpunit/php-file-iterator": "^3.0.5", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.3", - "phpunit/php-timer": "^5.0.2", - "sebastian/cli-parser": "^1.0.1", - "sebastian/code-unit": "^1.0.6", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.3", - "sebastian/environment": "^5.1.3", - "sebastian/exporter": "^4.0.5", - "sebastian/global-state": "^5.0.1", - "sebastian/object-enumerator": "^4.0.3", - "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^3.2", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.6", + "sebastian/global-state": "^5.0.7", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", "sebastian/version": "^3.0.2" }, "suggest": { @@ -5103,7 +10008,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.19" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.23" }, "funding": [ { @@ -5114,12 +10019,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2024-04-05T04:35:58+00:00" + "time": "2025-05-02T06:40:34+00:00" }, { "name": "sebastian/cli-parser", @@ -6085,29 +10998,57 @@ "time": "2020-09-28T06:39:44+00:00" }, { - "name": "swaggest/json-diff", - "version": "v3.10.5", + "name": "symfony/maker-bundle", + "version": "v1.64.0", "source": { "type": "git", - "url": "https://github.com/swaggest/json-diff.git", - "reference": "17bfc66b330f46e12a7e574133497a290cd79ba5" + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "c86da84640b0586e92aee2b276ee3638ef2f425a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swaggest/json-diff/zipball/17bfc66b330f46e12a7e574133497a290cd79ba5", - "reference": "17bfc66b330f46e12a7e574133497a290cd79ba5", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/c86da84640b0586e92aee2b276ee3638ef2f425a", + "reference": "c86da84640b0586e92aee2b276ee3638ef2f425a", "shasum": "" }, "require": { - "ext-json": "*" + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" }, "require-dev": { - "phperf/phpunit": "4.8.37" + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.5.0", + "doctrine/orm": "^2.15|^3", + "symfony/http-client": "^6.4|^7.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } }, - "type": "library", "autoload": { "psr-4": { - "Swaggest\\JsonDiff\\": "src/" + "Symfony\\Bundle\\MakerBundle\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -6116,49 +11057,143 @@ ], "authors": [ { - "name": "Viacheslav Poturaev", - "email": "vearutop@gmail.com" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "JSON diff/rearrange/patch/pointer library for PHP", + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" + ], "support": { - "issues": "https://github.com/swaggest/json-diff/issues", - "source": "https://github.com/swaggest/json-diff/tree/v3.10.5" + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.64.0" }, - "time": "2023-11-17T11:12:46+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:08+00:00" }, { - "name": "swaggest/json-schema", - "version": "v0.12.42", + "name": "symfony/process", + "version": "v7.3.0", "source": { "type": "git", - "url": "https://github.com/swaggest/php-json-schema.git", - "reference": "d23adb53808b8e2da36f75bc0188546e4cbe3b45" + "url": "https://github.com/symfony/process.git", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swaggest/php-json-schema/zipball/d23adb53808b8e2da36f75bc0188546e4cbe3b45", - "reference": "d23adb53808b8e2da36f75bc0188546e4cbe3b45", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", "shasum": "" }, "require": { - "ext-json": "*", - "php": ">=5.4", - "phplang/scope-exit": "^1.0", - "swaggest/json-diff": "^3.8.2", - "symfony/polyfill-mbstring": "^1.19" - }, - "require-dev": { - "phperf/phpunit": "4.8.37" - }, - "suggest": { - "ext-mbstring": "For better performance" + "php": ">=8.2" }, "type": "library", "autoload": { "psr-4": { - "Swaggest\\JsonSchema\\": "src/" + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } + ], + "time": "2025-04-17T09:11:12+00:00" + }, + { + "name": "symfony/web-profiler-bundle", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd", + "reference": "ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0", + "twig/twig": "^2.13|^3.0.4" + }, + "conflict": { + "symfony/form": "<5.4", + "symfony/mailer": "<5.4", + "symfony/messenger": "<5.4", + "symfony/twig-bundle": ">=7.0" + }, + "require-dev": { + "symfony/browser-kit": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/css-selector": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -6166,17 +11201,41 @@ ], "authors": [ { - "name": "Viacheslav Poturaev", - "email": "vearutop@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "High definition PHP structures with JSON-schema based validation", + "description": "Provides a development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], "support": { - "email": "vearutop@gmail.com", - "issues": "https://github.com/swaggest/php-json-schema/issues", - "source": "https://github.com/swaggest/php-json-schema/tree/v0.12.42" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.24" }, - "time": "2023-09-12T14:43:42+00:00" + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-20T15:15:57+00:00" }, { "name": "theseer/tokenizer", @@ -6232,10 +11291,10 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": {}, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^7.4" + "php": "^8.2" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000000..35579e2b4d --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,12 @@ + ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], +]; diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000000..6899b72003 --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,19 @@ +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 + #default_redis_provider: redis://localhost + + # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) + #app: cache.adapter.apcu + + # Namespaced pools use the above "app" backend by default + #pools: + #my.dedicated.cache: null diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml new file mode 100644 index 0000000000..6e8b2baf7d --- /dev/null +++ b/config/packages/dev/monolog.yaml @@ -0,0 +1,17 @@ +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: warning + # 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 diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000000..d42c52d6d2 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,50 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + + # 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%' + use_savepoints: true + orm: + auto_generate_proxy_classes: true + enable_lazy_ghost_objects: true + report_fields_where_declared: true + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + auto_generate_proxy_classes: false + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + 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 diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000000..29231d94bd --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +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 diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000000..879229786e --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,26 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + annotations: + enabled: false + error_controller: Access\Errors\ErrorController + secret: '%env(APP_SECRET)%' + #csrf_protection: true + http_method_override: false + + # Enables session support. Note that the session will ONLY be started if you read or write from it. + # Remove or comment this section to explicitly disable session support. + session: + handler_id: null + cookie_secure: auto + cookie_samesite: lax + storage_factory_id: session.storage.factory.native + + #esi: true + #fragments: true + php_errors: + log: true +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/google_recaptcha.yaml b/config/packages/google_recaptcha.yaml new file mode 100644 index 0000000000..8670b13e59 --- /dev/null +++ b/config/packages/google_recaptcha.yaml @@ -0,0 +1,21 @@ +services: + + # Inject this service in your controllers/services to verify a submitted captcha. + ReCaptcha\ReCaptcha: + arguments: + $secret: '%env(GOOGLE_RECAPTCHA_SECRET)%' + $requestMethod: '@ReCaptcha\RequestMethod' + + # Curl is set here as default transport to communicate with Google servers. + # If you do not have php-curl extension, you can change for a socket or a plain POST request. + # Check out the repository for all other request methods: + # https://github.com/google/recaptcha/tree/master/src/ReCaptcha/RequestMethod + ReCaptcha\RequestMethod: '@ReCaptcha\RequestMethod\CurlPost' + ReCaptcha\RequestMethod\CurlPost: null + ReCaptcha\RequestMethod\Curl: null + +# Uncomment this line if you want to inject the site key to all your Twig templates. +# You can also inject the "google_recaptcha_site_key" container parameter to your controllers. +#twig: +# globals: +# google_recaptcha_site_key: '%google_recaptcha_site_key%' diff --git a/config/packages/maker.yaml b/config/packages/maker.yaml new file mode 100644 index 0000000000..231da51c6e --- /dev/null +++ b/config/packages/maker.yaml @@ -0,0 +1,5 @@ +when@dev: + maker: + root_namespace: 'Access\' + generate_final_classes: true + generate_final_entities: false diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000000..8c9efa91e0 --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,61 @@ +monolog: + channels: + - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists + +when@dev: + monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + 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"] + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + buffer_size: 50 # How many messages should be saved? Prevent memory leaks + nested: + type: stream + path: php://stderr + level: debug + formatter: monolog.formatter.json + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] + deprecation: + type: stream + channels: [deprecation] + path: php://stderr diff --git a/config/packages/nyholm_psr7.yaml b/config/packages/nyholm_psr7.yaml new file mode 100644 index 0000000000..ade8312498 --- /dev/null +++ b/config/packages/nyholm_psr7.yaml @@ -0,0 +1,11 @@ +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 diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000000..86eedb23f3 --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000000..4b766ce57f --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,12 @@ +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: http://localhost + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000000..d3aafc3b58 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,60 @@ +security: + password_hashers: + ACCESS\Entity\User: 'auto' + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + id: Access\Security\UsernameUserProvider + all_users: + chain: + providers: [ 'app_user_provider' ] + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + lazy: true + provider: all_users + custom_authenticators: + - Access\Security\Authenticators\FormLoginAuthenticator + - Access\Security\Authenticators\SimpleSamlPhpAuthenticator + switch_user: true + logout: + path: xdmod_logout + invalidate_session: true + access_denied_handler: Access\Security\AccessDeniedHandler + entry_point: Access\Security\Authenticators\FormLoginAuthenticator + api: + lazy: true + provider: all_users + json_login: + check_path: /api/login + login_path: /api/login + logout: + path: api_logout + target: / + + + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used + access_control: + - { path: ^/saml/login, roles: PUBLIC_ACCESS } + - { path: ^/saml/metadata, roles: PUBLIC_ACCESS } + # - { path: ^/, roles: PUBLIC_ACCESS} + # - { path: ^/admin, roles: ROLE_ADMIN } + # - { path: ^/profile, roles: ROLE_US1ER } + +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 diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000000..4e25e4a5bd --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,7 @@ +twig: + default_path: '%kernel.project_dir%/templates' + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/twig_extensions.yaml b/config/packages/twig_extensions.yaml new file mode 100644 index 0000000000..da780f5fa0 --- /dev/null +++ b/config/packages/twig_extensions.yaml @@ -0,0 +1,11 @@ +services: + _defaults: + public: false + autowire: true + autoconfigure: true + + # Uncomment any lines below to activate that Twig extension + #Twig\Extensions\ArrayExtension: null + #Twig\Extensions\DateExtension: null + #Twig\Extensions\IntlExtension: null + #Twig\Extensions\TextExtension: null diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml new file mode 100644 index 0000000000..f414c16548 --- /dev/null +++ b/config/packages/web_profiler.yaml @@ -0,0 +1,21 @@ +when@dev: + # web_profiler_wdt: + # resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + # prefix: /_wdt + # + # web_profiler_profiler: + # resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + # prefix: /_profiler + web_profiler: + toolbar: true + intercept_redirects: false + framework: + profiler: { only_exceptions: false } +when@test: + web_profiler: + toolbar: false + intercept_redirects: false + + framework: + profiler: { collect: false } + diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000000..5ebcdb2153 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + -

Federated Open XDMoD

-

- Federated XDMoD supports the collection and aggregation of data from a number of fully-functional and individually managed XDMoD instances into a single federated instance of XDMoD capable of displaying federation-wide metrics. - Each participating institution deploys an XDMoD instance through which local data will be collected and shipped to a central Federation Hub where it is aggregated to provide a federation-wide view of the data. - Data particular to an individual center is available from the Hub by applying filters and drill-downs. -

-

-

- -
- - - Example data flow from heterogeneous computing resources to an XDMoD federated hub. - XDMoD instances X and Y ingest data into their databases from the computing resources that they monitor. - Following ingestion on the satellite instances, job data are replicated to the federated hub's database, where they are aggregated for use in the federated XDMoD user interface. - - -
-
-

-

- A simple example use of the federated module is: - Three academic instituitions each with their own HPC resource. - Each institution has its own XDMoD instance which contains the accounting data for only their HPC resource. - These institutions federate their data to a central hub. - HPC accounting data for all three HPC resources is shown on the central hub. - This central hub can then be used to report on the combined data. -

-

- This example illistrates only one use case. - The federated module supports cloud data as well as HPC. Support for other data realms is planned. - There are no pre defined limits on the number of instances that can be part of a federation. -

-

- For more information see Section II of Federating XDMoD to Monitor Affiliated Computing Resources. -

-

- Documentation avialable at https://federated.xdmod.org. -

-

- Source code and downloads at https://github.com/ubccr/xdmod-federated. -

-$key. - * - * @param str $section the section in which the desired value resides. - * @param str $key the key under which the desired value can be found. - * @param mixed $default the default value to provide if there is nothing found. - * - * @return mixed - **/ -function getConfigValue($section, $key, $default = null) -{ - try { - $result = \xd_utilities\getConfiguration($section, $key); - } catch(\Exception $e) { - $result = $default; - } - return $result; -} - -$role = getConfigValue('federated', 'role'); -if($role === 'instance'){ - $hubUrl = getConfigValue('federated', 'huburl'); - echo '

This instance is part of a federation

'; - echo 'Federation Hub: ' . $hubUrl .''; -} -elseif ($role === 'hub'){ - $db = DB::factory('datawarehouse'); - $instanceResults = $db->query('SELECT * FROM federation_instances;'); - $instances = array(); - $lastCloudQuery = array(); - $derived = 1; - foreach ($instanceResults as $instance) { - $prefix = $instance['prefix']; - $extra = json_decode($instance['extra'], true); - $instances[$prefix] = array( - 'contact' => $extra['contact'], - 'url' => $extra['url'], - 'lastCloudEvent' => null, - 'lastJobTask' => null - ); - unset($extra['contact']); - unset($extra['url']); - $instances[$prefix]['extra'] = $extra; - array_push( - $lastCloudQuery, - '(SELECT \'' . $prefix . '\' AS prefix, FROM_UNIXTIME(event_time_ts) as event_ts FROM `' . $prefix . '-modw_cloud`.`event` ORDER BY 2 DESC LIMIT 1) `A' . $derived . '`' - ); - $derived++; - } - $lastCloudResults = $db->query('SELECT * FROM ' . implode($lastCloudQuery, ' UNION ALL SELECT * FROM ')); - foreach ($lastCloudResults as $result) { - $instances[$result['prefix']]['lastCloudEvent'] = $result['event_ts']; - } - echo '

Instances that are part of this Federation

    '; - foreach($instances as $instance){ - echo '
  • ' . $instance['url'] . '

    last event retrieved (' . $instance['lastCloudEvent'] . ')
  • '; - } - echo '
'; -} -else { - echo 'This installation is not part of a federation.'; -} diff --git a/html/about/images/Case_Western_logo.png b/html/about/images/Case_Western_logo.png deleted file mode 100644 index aedb6ae1d34c88b47d334efbe3b70843a2451bfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81675 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 z 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 A@Bjb+ diff --git a/html/about/images/SDSC_logo.jpg b/html/about/images/SDSC_logo.jpg deleted file mode 100644 index 5e0c72c9dfd086e40ff709ac7062f8cf1a7c3031..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9464 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 aX3{z1JKHD0Tby)u#~9xiz*4}=^8WyHFGVl_ diff --git a/html/about/images/Tufts_logo.png b/html/about/images/Tufts_logo.png deleted file mode 100644 index 4ec9bda0e0558e108dc6ec0106cf81d7dfa72e6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6887 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 A-2eap diff --git a/html/about/links.html b/html/about/links.html deleted file mode 100644 index 311c379457..0000000000 --- a/html/about/links.html +++ /dev/null @@ -1,20 +0,0 @@ - -

Links

- - - - - - - - - - - -
- -
- -
- -
diff --git a/html/about/openxd.html b/html/about/openxd.html deleted file mode 100644 index 72175a21cd..0000000000 --- a/html/about/openxd.html +++ /dev/null @@ -1,39 +0,0 @@ - -

Open XDMoD

-
-

While initially focused on the NSF XSEDE program, an open source version of XDMoD that provides similar functionality for academic and industrial HPC centers is available and undergoing continued development, namely Open XDMoD. Open XDMoD for use by academic and industrial HPC centers is available for download through GitHub (http://open.xdmod.org).

-

Highlights include:

-
    -
  • A graphical user interface with extensive graphic and analytical capability.
  • -
  • Detailed utilization metrics including number of jobs, CPU hours, wait times, job size, etc.
  • -
  • Customizable Metric Explorer where users can generate custom plots comparing multiple metrics
  • -
  • A custom report builder for the automatic generation of detailed periodic reports.
  • -
  • Support for resource managers includes
  • -
      -
    • SLURM, SGE/UGE, PBS/TORQUE/PBS Pro, LSF
    • -
    -
  • Optional modules supported
  • - -
-
- - - - - - - - - - - - - - - -
-
Fig.1 Open Source XDMoD Summary Tab

-
Fig.2 Open Source XDMoD Usage Tab

diff --git a/html/about/presentations.html b/html/about/presentations.html deleted file mode 100644 index f63691f1fa..0000000000 --- a/html/about/presentations.html +++ /dev/null @@ -1,148 +0,0 @@ - -

Presentations

-
- -
PEARC '25
-
    -
  • Nikolay A. Simakov. "Enhancing an HPC Resources Modeling Framework with a Realistic, Slurm-Like, HPC Resource Model". Presentation available at doi:10.13140/RG.2.2.16351.98724.
  • -
-
Supercomputing 2024 (SC24), Atlanta, GA
-
    -
  • Nikolay A. Simakov. "Benchmarking and Continuous Performance Monitoring of HPC Resources using the XDMoD Application Kernel Module." SIGHPC Systems Professionals Workshop HPCSYSPROS24 at SC24. November 22, 2024. The presentation is available at doi:10.13140/RG.2.2.13362.62409.
  • -
- -
2024-12-12 Internet2 Technical Exchange: Boston, MA
-
    -
  • Jennifer Schopf, "Understanding Globus Data Transfers with NetSage"
  • -
- -
ACCESS Resource Provider Workshop September 2024
-
    -
  • Aaron Weeden, "What We Do in ACCESS Metrics"
  • -
- -
PEARC24: Providence, RI
-
    -
  • Nikolay A. Simakov, "Modeling Users on High-Performance Computing Resource"
  • -
  • Tom Furlani, "ACCESS Metrics Overview and Career Guidance"
  • -
- -
Research Computing at Smaller Institutions Conference, Swarthmore College, June 2024
-
    -
  • Joseph White, "Making the Case: Monitoring and Metrics"
  • -
- -
ACCESS Resource Provider Forum May 2024
-
    -
  • Aaron Weeden, "Plans for reporting on NAIRR Pilot usage"
  • -
- -
HPC Asia 2024: Nagoya, Japan
-
    -
  • N.A. Simakov, "First Impressions of the NVIDIA Grace CPU Superchip and NVIDIA Grace Hopper Superchip and Scientific Workloads"
  • -
- -
2023-10-26 ACCESS RP Forum (virtual)
-
    -
  • How to leverage ACCESS XDMoD to facilitate Campus Champion support for campus researchers
  • -
- -
2023-09-19 Campus Champions All Champions Call (virtual)
-
    -
  • How to leverage ACCESS XDMoD to facilitate Resource Provider Operations
  • -
- -
Metrics2023: Denver, CO
-
    -
  • Dr. Abani Patra, "Measuring Performance and Usage - Evolution of the Measuring and Monitoring of NSF Supercomputing"
  • -
  • N.A. Simakov, "Feasibility of Application-Agnostic Performance per Currency Metric on an Example of Gromacs, a Molecular Dynamics Application"
  • -
  • Aaron Weeden, "The Data Analytics Framework for XDMoD"
  • -
- -
PEARC23: Portland, OR
-
    -
  • Open OnDemand, XDMoD, and ColdFront: an HPC center management toolset (tutorial)
  • -
  • Introduction to CI usage and performance data analysis with XDMoD and the new Analytics Framework. (tutorial)
  • -
  • N.A Simakov, "The Taming of the Wolf - how to use the Ookami Cray Apollo 80 system and Fujitsu A64FX processors" (workshop)
  • -
  • Dr. Jennifer M. Schopf, Doug Southworth, "EPOC Support for Cyberinfrastructure and Data Movement" (Panel discussion)
  • -
- -
Cray User Group meeting (CUG) 2023 in Helsinki, Finland, May 7 – 11, 2023
-
    -
  • N.A. Simakov, "Benchmarking High-End ARM Systems with Scientific Applications. Performance and Energy Efficiency"
  • -
- -
ISC High Performance 2023 (ISC23): Hamburg, Germany
- - -
ARM HPC User Group (AHUG) Symposium at SC 2022
-
    -
  • N.A. Simakov, “Are we ready for broader adoption of ARM in the HPC community: Benchmarks and Applications on High-End ARM Systems with XDMoD Application Kernels”
  • -
- -
PEARC22: Boston, MA
- - -
PEARC21: (virtual)
- - -
Supercomputing 2020 (SC'20): Atlanta, GA (virtual), November 18, 2020
- - -
Gateways20: Bethesda, MD (virtual), October 13, 2020
- - -
NYSERNet 2020: (virtual), October 2, 2020
- - -
PEARC20: Portland, OR (virtual)
- - -
PEARC19: Chicago, IL
- - -
2018-09-05 Research Computing Campus Champions Presentation
- - -
SC17: Denver, CO
- - -
SC16: Salt Lake City, UT
- - -
XSEDE16: Miami, FL
- - -
XSEDE15: Saint Louis, MO
- diff --git a/html/about/roadmap.php b/html/about/roadmap.php index ef522ff478..e69de29bb2 100644 --- a/html/about/roadmap.php +++ b/html/about/roadmap.php @@ -1,60 +0,0 @@ - - * @license https://opensource.org/licenses/LGPL-3.0 LGPL-3.0 - */ - -require_once __DIR__ . '/../../configuration/linker.php'; - -/** - * Attempt to retrieve a value from the configuration located at - * $section->$key. - * - * @param str $section the section in which the desired value resides. - * @param str $key the key under which the desired value can be found. - * @param mixed $default the default value to provide if there is nothing found. - * - * @return mixed - **/ -function getConfigValue($section, $key, $default=null) -{ - try { - $result = xd_utilities\getConfiguration($section, $key); - } catch(\Exception $e) { - $result = $default; - } - return $result; -} - -$result = array(); - -$url = getConfigValue('roadmap', 'url'); -$header = getConfigValue('roadmap', 'header', ''); - -if (!empty($header)) { - $result[]="

$header

"; -} - -if (!empty($url)) { - $result[]=" - - -
- - - - - - +declare(strict_types=1); + +use Access\Kernel; + +require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; + +# These are only here temporarily, +# put together which headers / values are set based on config options. +header('Access-Control-Allow-Origin: *'); +header("Access-Control-Allow-Headers: *"); +header("Access-Control-Allow-Methods: *"); +header("Allow: *"); + +// Configurable constants --------------------------- +$orgConfig = \Configuration\XdmodConfiguration::assocArrayFactory( + 'organization.json', + CONFIG_DIR +); +// orgConfig is returned as array(0=>array('name' => '', 'abbrev' => '')) +$org = array_shift($orgConfig); +define('ORGANIZATION_NAME', $org['name']); +$org_abbrev = $org['abbrev']; +if (empty($org_abbrev)) { + $org_abbrev = ORGANIZATION_NAME; +}; +define('ORGANIZATION_NAME_ABBREV', $org_abbrev); + +$hierarchy = \Configuration\XdmodConfiguration::assocArrayFactory( + 'hierarchy.json', + CONFIG_DIR +); +define('HIERARCHY_TOP_LEVEL_LABEL', $hierarchy['top_level_label']); +define('HIERARCHY_TOP_LEVEL_INFO', $hierarchy['top_level_info']); +define('HIERARCHY_MIDDLE_LEVEL_LABEL', $hierarchy['middle_level_label']); +define('HIERARCHY_MIDDLE_LEVEL_INFO', $hierarchy['middle_level_info']); +define('HIERARCHY_BOTTOM_LEVEL_LABEL', $hierarchy['bottom_level_label']); +define('HIERARCHY_BOTTOM_LEVEL_INFO', $hierarchy['bottom_level_info']); + +return function (array $context) { + return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); +}; diff --git a/html/internal_dashboard/analytics/index.php b/html/internal_dashboard/analytics/index.php deleted file mode 100644 index 4e2458f796..0000000000 --- a/html/internal_dashboard/analytics/index.php +++ /dev/null @@ -1,9 +0,0 @@ -"; - } else { - echo json_encode($response); - } - - exit; -} - - -xd_security\enforceUserRequirements(array(STATUS_LOGGED_IN, STATUS_MANAGER_ROLE), 'xdDashboardUser'); - -// ===================================================== - -$pdo = DB::factory('database'); - -// ===================================================== - -switch ($operation) { - - case 'enum_account_requests': - - $results = $pdo->query("SELECT id, first_name, last_name, organization, title, email_address, field_of_science, additional_information, time_submitted, status, comments FROM AccountRequests"); - - $response['success'] = true; - $response['count'] = count($results); - $response['response'] = $results; - - $response['md5'] = md5(json_encode($response)); - - if (isset($_POST['md5only'])) { - unset($response['count']); - unset($response['response']); - } - - break; - - case 'update_request': - - $id = \xd_security\assertParameterSet('id'); - $comments = \xd_security\assertParameterSet('comments'); - - $results = $pdo->query("SELECT id FROM AccountRequests WHERE id=:id", array('id' => $id)); - - if (count($results) == 1) { - - $pdo->execute("UPDATE AccountRequests SET comments=:comments WHERE id=:id", array('comments' => $comments, 'id' => $id)); - - $response['success'] = true; - - } else { - - $response['success'] = false; - $response['message'] = 'invalid id specified'; - - } - - break; - - case 'delete_request': - - $id_parameter = \xd_security\assertParameterSet('id', '/^\d+(,\d+)*$/'); - - $id_strings = explode(',', $id_parameter); - $ids = array_map('intval', $id_strings); - - $id_placeholders = implode(', ', array_fill(0, count($ids), '?')); - $results = $pdo->execute("DELETE FROM AccountRequests WHERE id IN ($id_placeholders)", $ids); - - $response['success'] = true; - - break; - - case 'enum_existing_users': - - $group_filter = \xd_security\assertParameterSet('group_filter'); - $role_filter = \xd_security\assertParameterSet('role_filter'); - - $context_filter = isset($_REQUEST['context_filter']) ? $_REQUEST['context_filter'] : ''; - - $results = Users::getUsers($group_filter, $role_filter, $context_filter); - $filtered = array(); - foreach ($results as $user) { - if ($user['username'] !== 'Public User') { - $filtered[] = $user; - } - } - - $response['success'] = true; - $response['count'] = count($filtered); - $response['response'] = $filtered; - - break; - - case 'enum_user_types_and_roles': - - $query = "SELECT id, type, color FROM moddb.UserTypes"; - - $results = $pdo->query($query); - - $response['user_types'] = $results; - - $query = "SELECT display AS description, acl_id AS role_id FROM moddb.acls WHERE name != 'pub' ORDER BY description"; - - $results = $pdo->query($query); - - $response['user_roles'] = $results; - - $response['success'] = true; - - break; - - case 'enum_user_visits': - case 'enum_user_visits_export': - - $timeframe = strtolower(\xd_security\assertParameterSet('timeframe')); - $user_types = explode(',', \xd_security\assertParameterSet('user_types')); - - if ($timeframe !== 'year' && $timeframe !== 'month') { - - $response['success'] = false; - $response['message'] = 'invalid value specified for the timeframe'; - - break; - - } - - $response['success'] = true; - $response['stats'] = XDStatistics::getUserVisitStats($timeframe, $user_types); - - if ($operation == 'enum_user_visits_export') { - - header("Content-type: application/xls"); - header("Content-Disposition:attachment;filename=\"xdmod_visitation_stats_by_$timeframe.csv\""); - - if (isset($response['stats'][0])) { - print implode(',', array_keys($response['stats'][0])) . "\n"; - } - - $previous_timeframe = ''; - - foreach ($response['stats'] as $entry) { - - if ($previous_timeframe !== $entry['timeframe']) { - - $previous_timeframe = $entry['timeframe']; - print "\n"; - - } - - if ($entry['user_type'] == 700) { - - $entry['user_type'] = 'XSEDE'; - - $u = explode(';', $entry['username']); - - $entry['username'] = $u[1]; - - } - - print implode(',', $entry) . "\n"; - - } - - exit; - - } - - break; - - - case 'ak_arr': - - $start_date = $_REQUEST['start_date']; - $end_date = $_REQUEST['end_date']; - - $response['success'] = true; - $resource['response'] = array(array('x' => array(1, 2, 3), 'y' => array(5, 2, 1))); - $resource['count'] = count($response['response']); - - - break; - - default: - - $response['success'] = false; - $response['message'] = 'operation not recognized'; - - break; - -}//switch - -// ===================================================== - -print json_encode($response); diff --git a/html/internal_dashboard/controllers/dashboard.php b/html/internal_dashboard/controllers/dashboard.php deleted file mode 100644 index 9aa8def3d5..0000000000 --- a/html/internal_dashboard/controllers/dashboard.php +++ /dev/null @@ -1,10 +0,0 @@ -registerOperation('get_menu'); -$controller->invoke('REQUEST', 'xdDashboardUser'); - diff --git a/html/internal_dashboard/controllers/dashboard/get_menu.php b/html/internal_dashboard/controllers/dashboard/get_menu.php deleted file mode 100644 index f5b3c25943..0000000000 --- a/html/internal_dashboard/controllers/dashboard/get_menu.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - -try { - $config = \Configuration\XdmodConfiguration::assocArrayFactory( - 'internal_dashboard.json', - CONFIG_DIR - ); - - $returnData = array( - 'success' => true, - 'response' => $config['menu'], - ); - - $returnData['count'] = count($returnData['response']); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/log.php b/html/internal_dashboard/controllers/log.php deleted file mode 100644 index 788fdf6e67..0000000000 --- a/html/internal_dashboard/controllers/log.php +++ /dev/null @@ -1,12 +0,0 @@ -registerOperation('get_summary'); -$controller->registerOperation('get_messages'); -$controller->registerOperation('get_levels'); -$controller->invoke('REQUEST', 'xdDashboardUser'); - diff --git a/html/internal_dashboard/controllers/log/get_levels.php b/html/internal_dashboard/controllers/log/get_levels.php deleted file mode 100644 index 4b1681b170..0000000000 --- a/html/internal_dashboard/controllers/log/get_levels.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - -try { - - $returnData = array( - 'success' => true, - 'response' => array( - array('id' => \CCR\Log::EMERG, 'name' => 'Emergency'), - array('id' => \CCR\Log::ALERT, 'name' => 'Alert'), - array('id' => \CCR\Log::CRIT, 'name' => 'Critical'), - array('id' => \CCR\Log::ERR, 'name' => 'Error'), - array('id' => \CCR\Log::WARNING, 'name' => 'Warning'), - array('id' => \CCR\Log::NOTICE, 'name' => 'Notice'), - array('id' => \CCR\Log::INFO, 'name' => 'Info'), - array('id' => \CCR\Log::DEBUG, 'name' => 'Debug'), - ), - ); - - $returnData['count'] = count($returnData['response']); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/log/get_messages.php b/html/internal_dashboard/controllers/log/get_messages.php deleted file mode 100644 index f152cc182b..0000000000 --- a/html/internal_dashboard/controllers/log/get_messages.php +++ /dev/null @@ -1,98 +0,0 @@ - - */ - -use CCR\DB; - -try { - - $pdo = DB::factory('logger'); - - $sql = ' - SELECT id, logtime, ident, priority, message - FROM log_table - '; - - $clauses = array(); - $params = array(); - - if (isset($_REQUEST['ident'])) { - $clauses[] = 'ident = ?'; - $params[] = $_REQUEST['ident']; - } - - if (isset($_REQUEST['logLevels']) && is_array($_REQUEST['logLevels'])) { - $clauses[] = 'priority IN (' . implode(',', - array_pad(array(), count($_REQUEST['logLevels']), '?')) . ')'; - $params = array_merge($params, $_REQUEST['logLevels']); - } - - if (isset($_REQUEST['only_most_recent']) && $_REQUEST['only_most_recent']) { - if (!isset($_REQUEST['ident'])) { - throw new Exception('"ident" required'); - } - - $summary = Log\Summary::factory($_REQUEST['ident']); - - if (null !== ($startRowId = $summary->getProcessStartRowId())) { - $clauses[] = 'id >= ?'; - $params[] = $startRowId; - } - - if (null !== ($endRowId = $summary->getProcessEndRowId())) { - $clauses[] = 'id <= ?'; - $params[] = $endRowId; - } - } else { - if (isset($_REQUEST['start_date'])) { - $clauses[] = 'logtime >= ?'; - $params[] = $_REQUEST['start_date'] . ' 00:00:00'; - } - - if (isset($_REQUEST['end_date'])) { - $clauses[] = 'logtime <= ?'; - $params[] = $_REQUEST['end_date'] . ' 23:59:59'; - } - } - - if (count($clauses)) { - $sql .= ' WHERE ' . implode(' AND ', $clauses); - } - - $sql .= ' ORDER BY id DESC'; - - if (isset($_REQUEST['start']) && isset($_REQUEST['limit'])) { - $sql .= sprintf( - ' LIMIT %d, %d', - $_REQUEST['start'], - $_REQUEST['limit'] - ); - } - - $returnData = array( - 'success' => true, - 'response' => $pdo->query($sql, $params), - ); - - $sql = 'SELECT COUNT(*) AS count FROM log_table'; - - if (count($clauses)) { - $sql .= ' WHERE ' . implode(' AND ', $clauses); - } - - list($countRow) = $pdo->query($sql, $params); - - $returnData['count'] = $countRow['count']; - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/log/get_summary.php b/html/internal_dashboard/controllers/log/get_summary.php deleted file mode 100644 index 39c29155e9..0000000000 --- a/html/internal_dashboard/controllers/log/get_summary.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - -try { - - $summary = Log\Summary::factory($_REQUEST['ident']); - - $returnData = array( - 'success' => true, - 'response' => array($summary->getData()), - 'count' => 1, - ); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/mailer.php b/html/internal_dashboard/controllers/mailer.php deleted file mode 100644 index beb2e3eaae..0000000000 --- a/html/internal_dashboard/controllers/mailer.php +++ /dev/null @@ -1,106 +0,0 @@ -apply(array( - 'version' => $version, - 'contact_email' => $contact_email, - 'organization' => ORGANIZATION_NAME, - 'maintainer_signature' => MailWrapper::getMaintainerSignature(), - 'date' => date('l, j F'), - 'site_title' => \xd_utilities\getConfiguration('general', 'title'), - 'site_address' => $site_address, - 'product_name' => MailWrapper::getProductName(), - )); - - $response['success'] = true; - $response['content'] = $template->getContents(); - - break; - case 'enum_target_addresses': - $group_filter = \xd_security\assertParameterSet('group_filter'); - $acl_filter = \xd_security\assertParameterSet('role_filter'); - - list($query, $params) = \xd_dashboard\listUserEmailsByGroupAndAcl($group_filter, $acl_filter); - - $results = $pdo->query($query, $params); - - $addresses = array(); - - foreach ($results as $r) { - $addresses[] = $r['email_address']; - } - - sort($addresses); - - $response['success'] = true; - $response['count'] = count($addresses); - $response['response'] = $addresses; - - break; - case 'send_plain_mail': - $response['success'] = true; - - $title = \xd_utilities\getConfiguration('general', 'title'); - - // Send a copy of the email to the contact page recipient. - $response['status'] = MailWrapper::sendMail(array( - 'body' => \xd_security\assertParameterSet('message', '/.*/', false), - 'subject' => "[$title] " . \xd_security\assertParameterSet('subject'), - 'toAddress' => \xd_utilities\getConfiguration('general', 'contact_page_recipient'), - 'toName' => 'Undisclosed Recipients', - 'fromAddress' => \xd_utilities\getConfiguration('general', 'contact_page_recipient'), - 'fromName' => $title, - 'bcc' => \xd_security\assertParameterSet('target_addresses') - )); - break; - default: - $response['success'] = false; - $response['message'] = "Operation '$operation' not recognized"; - break; -} - -print json_encode($response); - diff --git a/html/internal_dashboard/controllers/pseudo_login.php b/html/internal_dashboard/controllers/pseudo_login.php deleted file mode 100644 index 59fdf9c592..0000000000 --- a/html/internal_dashboard/controllers/pseudo_login.php +++ /dev/null @@ -1,105 +0,0 @@ -postLogin(); - - $redirect_url = str_replace('internal_dashboard/controllers/pseudo_login.php', '', getAbsoluteURL()); - - header("Location: $redirect_url"); - - exit; - - }//if (uid set) - -?> - - - - - - - - - - - - query("SELECT id, username, first_name, last_name FROM moddb.Users ORDER BY last_name"); - - print ""; - print "\n"; - - $rIndex = 0; - - foreach ($result as $r) { - - $bgColor = ($rIndex++ % 2 == 0) ? '#eef' : '#fff'; - - $formal_name = $r['last_name'].', '.$r['first_name']; - $username = $r['username']; - - if (strpos($username, ';') !== false) { - - list($xsede_username, $dummy) = explode(';', $username); - $username = $xsede_username." (XSEDE)"; - - } - - $user_id = $r['id']; - $login_link = "Login as this user"; - - print "\n"; - - }//foreach - - print "
NameUsername 
"; - print implode('', array($formal_name, $username, $login_link)); - print "
"; - - ?> - - - - diff --git a/html/internal_dashboard/controllers/summary.php b/html/internal_dashboard/controllers/summary.php deleted file mode 100644 index 8be202f12d..0000000000 --- a/html/internal_dashboard/controllers/summary.php +++ /dev/null @@ -1,12 +0,0 @@ -registerOperation('get_config'); -$controller->registerOperation('get_portlets'); -$controller->invoke('REQUEST', 'xdDashboardUser'); - diff --git a/html/internal_dashboard/controllers/summary/get_config.php b/html/internal_dashboard/controllers/summary/get_config.php deleted file mode 100644 index 56fd1091d4..0000000000 --- a/html/internal_dashboard/controllers/summary/get_config.php +++ /dev/null @@ -1,64 +0,0 @@ - - */ - -use Log\Summary; - -try { - $config = \Configuration\XdmodConfiguration::assocArrayFactory( - 'internal_dashboard.json', - CONFIG_DIR - ); - - $summaries = array(); - - foreach ($config['summary'] as $summary) { - - // Add an empty config if none is found. - if (!isset($summary['config'])) { - $summary['config'] = array(); - } - - // Add log config. - if ($summary['class'] === 'XDMoD.Log.TabPanel') { - $logList = array(); - - foreach ($config['logs'] as $log) { - $logSummary = Summary::factory($log['ident']); - - if ($logSummary->getProcessStartRowId() === null) { - continue; - } - - $logList[] = array( - 'id' => $log['ident'] . '-log-panel', - 'ident' => $log['ident'], - 'title' => $log['title'], - ); - } - - $summary['config']['logConfigList'] = $logList; - } - - $summaries[] = $summary; - } - - $returnData = array( - 'success' => true, - 'response' => $summaries, - ); - - $returnData['count'] = count($returnData['response']); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/summary/get_portlets.php b/html/internal_dashboard/controllers/summary/get_portlets.php deleted file mode 100644 index 22291d3e5a..0000000000 --- a/html/internal_dashboard/controllers/summary/get_portlets.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - -use Log\Summary; - -try { - $config = \Configuration\XdmodConfiguration::assocArrayFactory( - 'internal_dashboard.json', - CONFIG_DIR - ); - - $portlets = array(); - - foreach ($config['portlets'] as $portlet) { - - // Add an empty config if none is found. - if (!isset($portlet['config'])) { - $portlet['config'] = array(); - } - - $portlets[] = $portlet; - } - - // Add log portlets. - foreach ($config['logs'] as $log) { - $logSummary = Summary::factory($log['ident'], TRUE); - - if ($logSummary->getProcessStartRowId() === null) { continue; } - - $portlets[] = array( - 'class' => 'XDMoD.Log.SummaryPortlet', - 'config' => array( - 'ident' => $log['ident'], - 'title' => $log['title'], - 'linkPath' => array( - 'log-tab-panel', - $log['ident'] . '-log-panel', - ), - ), - ); - } - - $returnData = array( - 'success' => true, - 'response' => $portlets, - ); - - $returnData['count'] = count($returnData['response']); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/controllers/user.php b/html/internal_dashboard/controllers/user.php deleted file mode 100644 index 26488847f7..0000000000 --- a/html/internal_dashboard/controllers/user.php +++ /dev/null @@ -1,10 +0,0 @@ -registerOperation('get_summary'); -$controller->invoke('REQUEST', 'xdDashboardUser'); - diff --git a/html/internal_dashboard/controllers/user/get_summary.php b/html/internal_dashboard/controllers/user/get_summary.php deleted file mode 100644 index 35d2ccbb98..0000000000 --- a/html/internal_dashboard/controllers/user/get_summary.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ - -use CCR\DB; - -try { - - $pdo = DB::factory('database'); - - $sql = 'SELECT COUNT(*) AS count FROM moddb.Users'; - list($userCountRow) = $pdo->query($sql); - - // TODO: Refactor these queries. - $sql = ' - SELECT COUNT(DISTINCT user_id) AS count - FROM moddb.SessionManager - WHERE DATEDIFF(NOW(), FROM_UNIXTIME(init_time)) < 7 - '; - list($last7DaysRow) = $pdo->query($sql); - - $sql = ' - SELECT COUNT(DISTINCT user_id) AS count - FROM moddb.SessionManager - WHERE DATEDIFF(NOW(), FROM_UNIXTIME(init_time)) < 30 - '; - list($last30DaysRow) = $pdo->query($sql); - - $returnData = array( - 'success' => true, - 'response' => array( - array( - 'user_count' => $userCountRow['count'], - 'logged_in_last_7_days' => $last7DaysRow['count'], - 'logged_in_last_30_days' => $last30DaysRow['count'], - ) - ), - 'count' => 1, - ); - -} catch (Exception $e) { - $returnData = array( - 'success' => false, - 'message' => $e->getMessage(), - ); -} - -echo json_encode($returnData); - diff --git a/html/internal_dashboard/css/management.css b/html/internal_dashboard/css/management.css deleted file mode 100644 index da60049dcd..0000000000 --- a/html/internal_dashboard/css/management.css +++ /dev/null @@ -1,72 +0,0 @@ -.dashboard_user_stats_timeframe .x-form-check-wrap { - padding-left: 10px; -} - -.btn_refresh -{ - background-image: url('../images/icon_refresh.png') !important; -} - -.btn_delete, -.general_btn_close -{ - background-image: url('../images/icon_delete.png') !important; -} - -.btn_edit -{ - background-image: url('../images/icon_edit.png') !important; -} - -.btn_init_dialog -{ - background-image: url('../images/icon_dialog.png') !important; -} - -.update_highlight -{ - background-color: #eaf945; -} - -.btn_login_as -{ - background-image: url('../images/icon_login.png') !important; -} - -/* ------ Current Users Section Stylings ------- */ - -.btn_email -{ - background-image: url('../images/icon_email.png') !important; -} - -/* --------------------------------------------- */ - -.btn_group -{ - background-image: url('../images/icon_group.png') !important; -} - - -.btn_role -{ - background-image: url('../images/icon_role.png') !important; -} - -.selected_menu_item -{ - color: #00f; -} - -/* ------ Recipient Verification Window Stylings ------- */ - -.btn_email_send -{ - background-image: url('../images/icon_email_send.png') !important; -} - -.btn_email_cancel -{ - background-image: url('../images/icon_email_cancel.png') !important; -} - diff --git a/html/internal_dashboard/index.php b/html/internal_dashboard/index.php deleted file mode 100644 index f287cc199e..0000000000 --- a/html/internal_dashboard/index.php +++ /dev/null @@ -1,224 +0,0 @@ - - - - - - "."\n"; - } - ?> - - - XDMoD Internal Dashboard - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Ext.onReady(function () { - new XDMoD.AppKernel.InstanceWindow({instanceId:$instance_id}).show(); -}, window, true); - -END; - } - } - ?> - - - - diff --git a/html/internal_dashboard/splash.php b/html/internal_dashboard/splash.php deleted file mode 100644 index ba62bb6863..0000000000 --- a/html/internal_dashboard/splash.php +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - XDMoD Internal Dashboard - - - - - - - -
- - '.$reject_response.''; - } - ?> - -

-

- -
- - - - - - - - - - - - - - - - - - - - -
Please Sign In Below
Username: - -
Password: - -
- -
- -
- -
- - - diff --git a/html/internal_dashboard/user_check.php b/html/internal_dashboard/user_check.php deleted file mode 100644 index 2614374c06..0000000000 --- a/html/internal_dashboard/user_check.php +++ /dev/null @@ -1,63 +0,0 @@ -postLogin(); - - $_SESSION['xdDashboardUser'] = $user->getUserID(); -} - -// Check that the user has been set in the session. -if (!isset($_SESSION['xdDashboardUser'])){ - denyWithMessage(''); - exit; -} - -// Retrieve user data. -try { - $user = XDUser::getUserByID($_SESSION['xdDashboardUser']); -} catch(Exception $e) { - denyWithMessage('There was a problem initializing your account.'); - exit; -} - -// Check that the user exists. -if (!isset($user)) { - - // There is an issue with the account (most likely deleted while the - // user was logged in, and the user refreshed the entire site) - session_destroy(); - header("Location: splash.php"); - exit; -} - -// Check that the user has access to the internal dashboard. -if ($user->isManager() == false) { - denyWithMessage('You are not allowed access to this resource.'); - exit; -} - -/** - * Deny the user access and display a message. - * - * @param string $message - */ -function denyWithMessage($message) -{ - $reject_response = $message; - - include 'splash.php'; - exit; -} diff --git a/html/password_reset.php b/html/password_reset.php deleted file mode 100644 index d87fae054c..0000000000 --- a/html/password_reset.php +++ /dev/null @@ -1,185 +0,0 @@ - - array('regexp' => RESTRICTION_RID))); - -if ($rid === false) { - $validationCheck = array( - 'status' => INVALID, - 'user_first_name' => 'INVALID', - 'user_id' => INVALID - ); -} else { - $validationCheck = XDUser::validateRID($rid); -} - - - // ------------------------------- - - if ($validationCheck['status'] == INVALID) { - -?> - - - - - - - - - <?php print $page_title; ?> - - - - - - - -
- -
- -

- - The page you are trying to access has already expired.

- If you still need to reset your password, visit the login page and click on Problem Logging In? below the login prompt. -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - <?php print "$page_title: ".ucfirst($mode); ?> Password - - - - - - - - - - - - -
- -
- -

- Welcome, . To your password, supply a new password below and click on Update.

- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
Your Password
Password: - - 5 characters min.
  - - - -
password not specified
-
Password Again: - - 5 characters min.
- -
-
- -
- -
- - - - diff --git a/html/report_image_renderer.php b/html/report_image_renderer.php deleted file mode 100644 index 4aba5716de..0000000000 --- a/html/report_image_renderer.php +++ /dev/null @@ -1,164 +0,0 @@ - array( - 'filter' => FILTER_VALIDATE_REGEXP, - 'options' => array('regexp' => ReportGenerator::REPORT_CHART_TYPE_REGEX) - ), - 'ref' => array( - 'filter' => FILTER_VALIDATE_REGEXP, - 'options' => array('regexp' => ReportGenerator::REPORT_CHART_REF_REGEX) - ), - 'did' => array( - 'filter' => FILTER_VALIDATE_REGEXP, - 'options' => array('regexp' => ReportGenerator::REPORT_CHART_DID_REGEX) - ), - 'start' => array( - 'filter' => FILTER_VALIDATE_REGEXP, - 'options' => array('regexp' => ReportGenerator::REPORT_DATE_REGEX) - ), - 'end' => array( - 'filter' => FILTER_VALIDATE_REGEXP, - 'options' => array('regexp' => ReportGenerator::REPORT_DATE_REGEX) - ), -); - -try { - $request = Request::createFromGlobals(); - $user = Authentication::authenticateUser($request); - - $request = filter_var_array($_REQUEST, $filters, false); - - if ($user === null) { - throw new AccessDeniedHttpException('User not authenticated'); - } - - if (!isset($request['type'])) { - throw new Exception("Thumbnail type not set"); - } - - if (!isset($request['ref'])) { - throw new Exception("Thumbnail reference not set"); - } - - switch ($request['type']) { - case 'chart_pool': - case 'volatile': - $num_matches = preg_match('/^(\d+);(\d+)$/', $request['ref'], $matches); - - if ($num_matches == 0) { - throw new Exception("Invalid thumbnail reference set"); - } - - $user_id = $matches[1]; - - if (isset($request['start']) && isset($request['end'])) { - $insertion_rank = array( - 'rank' => $matches[2], - 'start_date' => $request['start'], - 'end_date' => $request['end'], - 'did' => isset($request['did']) ? $request['did'] : '', - ); - } else { - $insertion_rank = array( - 'rank' => $matches[2], - 'did' => isset($request['did']) ? $request['did'] : '', - ); - } - - break; - - case 'report': - $num_matches = preg_match('/^((\d+)-(.+));(\d+)$/', $request['ref'], $matches); - - if ($num_matches == 0) { - throw new Exception("Invalid thumbnail reference set"); - } - - $user_id = $matches[2]; - $insertion_rank = array('report_id' => $matches[1], 'ordering' => $matches[4]); - break; - - case 'cached': - $num_matches = preg_match('/^((\d+)-(.+));(\d+)$/', $request['ref'], $matches); - - if ($num_matches == 0) { - throw new Exception("Invalid thumbnail reference set"); - } - - if (!isset($request['start']) || !isset($request['end'])) { - throw new Exception("Start and end dates not set"); - } - - $valid_start = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $request['start']); - $valid_end = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $request['end']); - - if (($valid_start * $valid_end) == 0) { - throw new Exception("Invalid start and/or end date supplied"); - } - - $user_id = $matches[2]; - - $insertion_rank = array( - 'report_id' => $matches[1], - 'ordering' => $matches[4], - 'start_date' => $request['start'], - 'end_date' => $request['end'], - ); - break; - - default: - throw new Exception("Invalid thumbnail type value supplied: " . $request['type']); - break; - - } // switch($request['type']) - - if ($user_id !== $user->getUserID()) { - throw new AccessDeniedHttpException('Invalid user id'); - } - - $rm = new XDReportManager($user); - - header("Content-Type: image/png"); - - $blob = $rm->fetchChartBlob($request['type'], $insertion_rank); - - $image_data_header = substr($blob, 0, 8); - - if ($image_data_header != "\x89PNG\x0d\x0a\x1a\x0a") { - throw new Exception($blob); - } - - if (in_array(md5($blob), $emptyBlobs)) { - readfile(dirname(__FILE__) . '/gui/images/report_thumbnail_no_data.png'); - exit; - } - - print $blob; - -} catch (Exception $e) { - header("Content-Type: image/png"); - $unique_id = uniqid(); - $im = imagecreatefrompng(dirname(__FILE__) . '/gui/images/report_thumbnail_error.png'); - imagestring($im, 5, 20, 505, 'Error Code: ' . $unique_id, imagecolorallocate($im, 100, 100, 100)); - imagepng($im); - - // RE-throwing this exception will allow exceptions.log to record the exception message - throw new UniqueException($unique_id, $e); -} diff --git a/html/rest/index.php b/html/rest/index.php deleted file mode 100644 index 3a89990507..0000000000 --- a/html/rest/index.php +++ /dev/null @@ -1,25 +0,0 @@ -run(); diff --git a/html/rest/maintenance.php b/html/rest/maintenance.php deleted file mode 100644 index 38bb92e646..0000000000 --- a/html/rest/maintenance.php +++ /dev/null @@ -1,9 +0,0 @@ ->> 0; - - if (len === 0) { - return false; - } - - var n = fromIndex | 0; - - var k = Math.max(n >= 0 ? n : len - Math.abs(n), 0); - - function sameValueZero(x, y) { - return x === y || (typeof x === 'number' && typeof y === 'number' && isNaN(x) && isNaN(y)); - } - - while (k < len) { - if (sameValueZero(o[k], valueToFind)) { - return true; - } - k++; - } - - return false; - } - }); -} diff --git a/html/unit_tests/coverage.html b/html/unit_tests/coverage.html deleted file mode 100644 index 25135854ae..0000000000 --- a/html/unit_tests/coverage.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - Mocha Tests - - - - -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/html/unit_tests/index.html b/html/unit_tests/index.html deleted file mode 100644 index cce0a15919..0000000000 --- a/html/unit_tests/index.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - Mocha Tests - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/html/unit_tests/phantom.js b/html/unit_tests/phantom.js deleted file mode 100644 index 2ab79b36b8..0000000000 --- a/html/unit_tests/phantom.js +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint no-console: "off" */ -var page = require('webpage').create(); - -page.onError = function (msg, trace) { - var msgStack = ['PHANTOM ERROR: ' + msg]; - if (trace && trace.length) { - msgStack.push('TRACE:'); - trace.forEach(function (t) { - msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : '')); - }); - } - console.log(msgStack.join('\n')); - phantom.exit(1); -}; - -page.onResourceError = function (resourceError) { - console.log('Unable to load resource (#' + resourceError.id + ' URL: ' + resourceError.url + ')'); - console.log('Error code: ' + resourceError.errorCode + '. Description: ' + resourceError.errorString); -}; - -page.onConsoleMessage = function (msg) { - console.log(msg); -}; - -page.open('file://' + phantom.libraryPath + '/index.html', function (status) { - var failures = -1; - if (status === 'success') { - failures = page.evaluate(function () { - return mocha.run().failures; - }); - } - console.log('Javascript Unit Test Failures: ' + failures); - phantom.exit(failures); -}); - diff --git a/html/unit_tests/spec/.eslintrc.json b/html/unit_tests/spec/.eslintrc.json deleted file mode 100644 index 4ed08dc660..0000000000 --- a/html/unit_tests/spec/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "env": { - "mocha": true - }, - "globals": { - "expect": false - }, - "rules": { - "no-unused-expressions": "off" - } -} - diff --git a/html/unit_tests/spec/CCRTokenizeSpec.js b/html/unit_tests/spec/CCRTokenizeSpec.js deleted file mode 100644 index c5bf57340c..0000000000 --- a/html/unit_tests/spec/CCRTokenizeSpec.js +++ /dev/null @@ -1,71 +0,0 @@ -describe('XDMoD.Viewer', function () { - describe('Various Successful Tokenizations', function () { - it('tab panel / tab', function () { - var token = CCR.tokenize('#main_tab_panel:tg_summary'); - - expect(token).to.deep.equal({ - raw: '#main_tab_panel:tg_summary', - content: 'main_tab_panel:tg_summary', - root: 'main_tab_panel', - tab: 'tg_summary', - subtab: '', - params: '' - }); - }); - - it('tab only', function () { - var token = CCR.tokenize('#tg_summary'); - - expect(token).to.deep.equal({ - raw: '#tg_summary', - content: 'tg_summary', - root: '', - tab: 'tg_summary', - subtab: '', - params: '' - }); - }); - - it('tab only params', function () { - var content = 'tg_usage?node=statistic_Jobs_none_total_cpu_hours'; - var token = CCR.tokenize('#' + content); - - expect(token).to.deep.equal({ - raw: '#' + content, - content: content, - root: '', - tab: 'tg_usage', - subtab: '', - params: 'node=statistic_Jobs_none_total_cpu_hours' - }); - }); - - it('tab panel / tab w/ params', function () { - var content = 'main_tab_panel:job_viewer?realm=SUPREMM&recordid=29&jobid=7193418&infoid=0'; - var token = CCR.tokenize('#' + content); - - expect(token).to.deep.equal({ - raw: '#' + content, - content: content, - root: 'main_tab_panel', - tab: 'job_viewer', - subtab: '', - params: 'realm=SUPREMM&recordid=29&jobid=7193418&infoid=0' - }); - }); - - it('tab panel / tab / subtab w/ params', function () { - var content = 'main_tab_panel:app_kernels:app_kernel_viewer?kernel=29&start=2017-03-01&end=2017-03-31'; - var token = CCR.tokenize('#' + content); - - expect(token).to.deep.equal({ - raw: '#' + content, - content: content, - root: 'main_tab_panel', - tab: 'app_kernels', - subtab: 'app_kernel_viewer', - params: 'kernel=29&start=2017-03-01&end=2017-03-31' - }); - }); - }); -}); diff --git a/html/unit_tests/spec/ChangeStackSpec.js b/html/unit_tests/spec/ChangeStackSpec.js deleted file mode 100644 index 57e3197e78..0000000000 --- a/html/unit_tests/spec/ChangeStackSpec.js +++ /dev/null @@ -1,141 +0,0 @@ -describe("XDMoD.ChangeStack", function() { - var spy = chai.spy(); - - describe("Object Initialization", function() { - - it("empty config", function() { - - var cs = new XDMoD.ChangeStack({}); - - expect(cs.canUndo()).to.be.false; - expect(cs.canRedo()).to.be.false; - expect(cs.isMarked()).to.be.false; - expect(cs.canRevert()).to.be.false; - expect(cs.empty()).to.be.true; - - expect(function() { cs.mark(); }).to.throw(Error); - expect(function() { cs.undo(); }).to.throw(Error); - expect(function() { cs.redo(); }).to.throw(Error); - expect(function() { cs.revertToMarked(); }).to.throw(Error); - expect(function() { cs.add(); }).to.throw(Error); - }); - - it("baseParams", function() { - - var entry = {test: 1}; - - var cs = new XDMoD.ChangeStack({baseParams: entry}); - - expect(cs.canUndo()).to.be.false; - expect(cs.canRedo()).to.be.false; - expect(cs.isMarked()).to.be.false; - expect(cs.canRevert()).to.be.false; - expect(cs.empty()).to.be.false; - - cs.on('update', spy); - - cs.mark(); - expect(spy).to.have.been.called.with(cs, {test: 1}, 'mark'); - - expect(cs.isMarked()).to.be.true; - }); - }); - - describe("Auto commit", function() { - - var cs = new XDMoD.ChangeStack({}); - cs.on('update', spy); - - it("add some changes", function() { - - cs.disableAutocommit(); - - cs.add({test: 1}); - expect(spy).to.have.been.called.with(cs, {test: 1}, 'add'); - - expect(cs.canUndo()).to.be.false; - expect(cs.canRedo()).to.be.false; - expect(cs.empty()).to.be.true; - - cs.add({test: 2}); - expect(spy).to.have.been.called.with(cs, {test: 2}, 'add'); - - cs.commit() - expect(spy).to.have.been.called.with(cs, {test: 2}, 'commit'); - expect(cs.empty()).to.be.false; - - cs.enableAutocommit(); - - cs.commit() - expect(spy).to.have.not.been.called; - - cs.add({test: 3}); - expect(spy).to.have.been.called.with(cs, {test: 3}, 'add'); - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 2}, 'undo'); - - expect(cs.canUndo()).to.be.false; - }); - }); - - describe("Stack Operations", function() { - - var cs = new XDMoD.ChangeStack({}); - cs.on('update', spy); - - it("linear push pop", function() { - - var i; - for(i = 0; i < 10; i++) { - cs.add({test: i}); - } - expect(cs.canRedo()).to.be.false; - expect(cs.canUndo()).to.be.true; - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 8}, 'undo'); - - expect(cs.canRedo()).to.be.true; - expect(cs.canUndo()).to.be.true; - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 7}, 'undo'); - - cs.redo(); - expect(spy).to.have.been.called.with(cs, {test: 8}, 'redo'); - }); - - it("save state", function() { - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 7}, 'undo'); - - expect(cs.canRevert()).to.be.false; - - cs.mark(); - expect(spy).to.have.been.called.with(cs, {test: 7}, 'mark'); - expect(cs.isMarked()).to.be.true; - expect(cs.canRevert()).to.be.false; - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 6}, 'undo'); - expect(cs.isMarked()).to.be.false; - expect(cs.canRevert()).to.be.true; - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 5}, 'undo'); - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 4}, 'undo'); - - cs.revertToMarked(); - expect(spy).to.have.been.called.with(cs, {test: 7}, 'reverttomarked'); - expect(cs.isMarked()).to.be.true; - - expect(cs.canRedo()).to.be.false; - - cs.undo(); - expect(spy).to.have.been.called.with(cs, {test: 4}, 'undo'); - }); - }); -}); diff --git a/html/unit_tests/spec/JobViewerSpec.js b/html/unit_tests/spec/JobViewerSpec.js deleted file mode 100644 index 8a135cbcc9..0000000000 --- a/html/unit_tests/spec/JobViewerSpec.js +++ /dev/null @@ -1,91 +0,0 @@ -describe('XDMoD.JobViewer', function () { - var jv = new XDMoD.Module.JobViewer(); - - describe('compareNodePath tests', function () { - it('matching', function () { - var node = { - attributes: { - dtype: 'b', - b: 2 - }, - parentNode: { - attributes: { - dtype: 'a', - a: 1 - }, - parentNode: {} - } - }; - - var path = [{ dtype: 'a', value: '1' }, { dtype: 'b', value: '2' }]; - - expect(jv.compareNodePath(node, path)).to.be.true; - }); - - it('diff dtype', function () { - var node = { - attributes: { - dtype: 'b', - b: 2 - }, - parentNode: { - attributes: { - dtype: 'z', - z: 1 - }, - parentNode: {} - } - }; - - var path = [{ dtype: 'a', value: '1' }, { dtype: 'b', value: '2' }]; - - expect(jv.compareNodePath(node, path)).to.be.false; - }); - - it('diff array longer', function () { - var node = { - attributes: { - dtype: 'b', - b: 2 - }, - parentNode: { - attributes: { - dtype: 'a', - a: 1 - }, - parentNode: {} - } - }; - - var path = [{ dtype: 'a', value: '1' }, { dtype: 'b', value: '2' }, { dtype: 'c', value: '3' }]; - - expect(jv.compareNodePath(node, path)).to.be.false; - }); - - it('diff node path longer', function () { - var node = { - attributes: { - dtype: 'b', - b: 2 - }, - parentNode: { - attributes: { - dtype: 'a', - a: 1 - }, - parentNode: {} - } - }; - - var path = [{ dtype: 'b', value: '2' }]; - - expect(jv.compareNodePath(node, path)).to.be.false; - }); - - it('data format functions', function () { - expect(jv.formatData(60, 'seconds')).to.equal('1 minute '); - expect(jv.formatData(10240, 'B/s')).to.equal('10.00 KiB/s'); - expect(jv.formatData(11100000000, '1')).to.equal('11.1 G'); - }); - }); -}); diff --git a/html/unit_tests/spec/XDMoDFormatSpec.js b/html/unit_tests/spec/XDMoDFormatSpec.js deleted file mode 100644 index d97b900d9e..0000000000 --- a/html/unit_tests/spec/XDMoDFormatSpec.js +++ /dev/null @@ -1,89 +0,0 @@ -describe('XDMoD.Format', function () { - describe('Check Format functions', function () { - it('SI formatting', function () { - var test_cases = [ - [100001, 'B', 3, '100 kB'], - [10100001, 'B', 3, '10.1 MB'], - [0.0001, 'B', 2, '0.0001 B'], - [0.00033, 'B', 2, '0.00033 B'], - [1.00001, 'B', 1, '1 B'], - [1, '', 2, '1 '], - [10, '', 2, '10 '], - [100, '', 2, '100 '], - [1000, '', 2, '1 k'], - [10000, '', 2, '10 k'], - [100000, '', 2, '100 k'], - [1000000, '', 2, '1 M'], - [10000000, '', 2, '10 M'], - [100000000, '', 2, '100 M'], - [1000000000, '', 2, '1 G'], - [9, '', 2, '9 '], - [99, '', 2, '99 '], - [999, '', 2, '1 k'], - [9999, '', 2, '10 k'], - [99999, '', 2, '100 k'], - [999999, '', 2, '1 M'], - [9999999, '', 2, '10 M'], - [99999999, '', 2, '100 M'], - [999999999, '', 2, '1 G'], - [9999999999, '', 2, '10 G'], - [1, '', 4, '1 '], - [10, '', 4, '10 '], - [100, '', 4, '100 '], - [1000, '', 4, '1 k'], - [10000, '', 4, '10 k'], - [100000, '', 4, '100 k'], - [1000000, '', 4, '1 M'], - [10000000, '', 4, '10 M'], - [100000000, '', 4, '100 M'], - [1000000000, '', 4, '1 G'], - [9, '', 4, '9 '], - [99, '', 4, '99 '], - [999, '', 4, '999 '], - [9999, '', 4, '9.999 k'], - [99999, '', 4, '100 k'], - [999999, '', 4, '1 M'], - [9999999, '', 4, '10 M'], - [99999999, '', 4, '100 M'], - [999999999, '', 4, '1 G'], - [9999999999, '', 4, '10 G'] - ]; - - var i; - for (i = 0; i < test_cases.length; i++) { - expect(XDMoD.utils.format.convertToSiPrefix(test_cases[i][0], test_cases[i][1], test_cases[i][2])).to.equal(test_cases[i][3]); - } - }); - - it('Binary formatting', function () { - var test_cases = [ - [1025, 'B', 3, '1.00 KiB'], - [10100001, 'B', 3, '9.63 MiB'], - [0.0001, 'B', 2, '0.00010 B'], - [1.00001, 'B', 1, '1 B'] - ]; - - var i; - for (i = 0; i < test_cases.length; i++) { - expect(XDMoD.utils.format.convertToBinaryPrefix(test_cases[i][0], test_cases[i][1], test_cases[i][2])).to.equal(test_cases[i][3]); - } - }); - - it('Elapsed time', function () { - var test_cases = [ - [1, '1 second '], - [2, '2 seconds '], - [3600, '1 hour 0.0 minute '], - [3601, '1 hour 0.0 minute '], - [3600 + (5 * 60), '1 hour 5.0 minutes '], - [24 * 3600, '1 day 0.0 hour '], - [(3 * 24 * 3600) + 3600, '3 days 1.0 hour '] - ]; - - var i; - for (i = 0; i < test_cases.length; i++) { - expect(XDMoD.utils.format.humanTime(test_cases[i][0])).to.equal(test_cases[i][1]); - } - }); - }); -}); diff --git a/libraries/security.php b/libraries/security.php index a33f88f973..3f4e79cc04 100644 --- a/libraries/security.php +++ b/libraries/security.php @@ -5,26 +5,81 @@ namespace xd_security; +use Egulias\EmailValidator\Validation\RFCValidation; +use Exception; +use SessionExpiredException; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\PhpBridgeSessionStorage; +use XDUser; + +class SessionSingleton +{ + + /** + * @var Session + */ + private static $session; + + /** + * @throws Exception + */ + public function __construct() + { + throw new Exception('No touchee! This is a static utility class, no instantiation please.'); + } + + /** + * @return Session + */ + public static function getSession(): Session + { + self::initSession(); + return self::$session; + } + + /** + * @return void + */ + public static function initSession(): void + { + if (!isset(self::$session)) { + @session_start(); + self::$session = new Session(new PhpBridgeSessionStorage()); + self::$session->start(); + } + } + + +} + /** * Wrapper for the session_start that ensures that the secure * cookie flag is set for the session cookie. */ function start_session() { - $cParams = session_get_cookie_params(); - session_set_cookie_params( - $cParams["lifetime"], - $cParams["path"], - $cParams['domain'], - true - ); - @session_start(); + switch (session_status()) { + case PHP_SESSION_NONE: + $cookieParams = session_get_cookie_params(); + session_set_cookie_params( + $cookieParams['lifetime'], + $cookieParams['path'], + $cookieParams['domain'], + true + ); + SessionSingleton::initSession(); + case PHP_SESSION_ACTIVE: + case PHP_SESSION_DISABLED: + default: + } + } /** * @param array $failover_methods * - * @return \XDUser + * @return XDUser + * @throws SessionExpiredException */ function detectUser($failover_methods = array()) { @@ -34,37 +89,37 @@ function detectUser($failover_methods = array()) // determine the next kind of user to fetch try { $user = getLoggedInUser(); - } catch (\Exception $e) { + } catch (Exception $e) { if (count($failover_methods) == 0) { // Previously: Exception with 'Session Expired', No Logged In User code - throw new \SessionExpiredException(); + throw new \SessionExpiredException(); } - + $session = SessionSingleton::getSession(); switch ($failover_methods[0]) { - case \XDUser::PUBLIC_USER: + case XDUser::PUBLIC_USER: if ( - isset($_REQUEST['public_user']) - && $_REQUEST['public_user'] === 'true' + (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || + ($session->has('public_session_token')) ) { - return \XDUser::getPublicUser(); + return XDUser::getPublicUser(); } else { // Previously: Exception with 'Session Expired', No Public User code - throw new \SessionExpiredException(); + throw new \SessionExpiredException($e->getMessage()); } break; - case \XDUser::INTERNAL_USER: + case XDUser::INTERNAL_USER: try { return getInternalUser(); - } catch (\Exception $e) { + } catch (Exception $e) { if ( isset($failover_methods[1]) - && $failover_methods[1] == \XDUser::PUBLIC_USER + && $failover_methods[1] == XDUser::PUBLIC_USER ) { if ( - isset($_REQUEST['public_user']) - && $_REQUEST['public_user'] === 'true' + (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || + ($session->has('public_session_token')) ) { - return \XDUser::getPublicUser(); + return XDUser::getPublicUser(); } else { // Previously: Exception with 'Session Expired', No Public User code throw new \SessionExpiredException(); @@ -89,7 +144,8 @@ function detectUser($failover_methods = array()) * This is merely to check if a dashboard user has logged in (and not * make use of the respective XDUser object) * - * @return \XDUser + * @return XDUser + * @throws SessionExpiredException */ function assertDashboardUserLoggedIn() { @@ -99,20 +155,20 @@ function assertDashboardUserLoggedIn() // TODO: Refactor generic catch block below to handle specific exceptions, // which would allow this block to be removed. throw $see; - } catch (\Exception $e) { + } catch (Exception $e) { \xd_controller\returnJSON(array( 'success' => false, - 'status' => $e->getMessage(), + 'status' => $e->getMessage(), )); exit; } } /** - * @return \XDUser An instance of XDUser pertaining to the dashboard + * @return XDUser An instance of XDUser pertaining to the dashboard * user. * - * @throws \Exception If: + * @throws Exception If: * - The session variable pertaining to the dashboard user does not * exist. * - The user_id stored in the session variable does not map to a @@ -121,48 +177,52 @@ function assertDashboardUserLoggedIn() */ function getDashboardUser() { - if (!isset($_SESSION['xdDashboardUser'])) { + + $session = SessionSingleton::getSession(); + $dashboardUserId = $session->get('xdDashboardUser'); + if (!isset($dashboardUserId)) { throw new \SessionExpiredException('Dashboard session expired'); } - $user = \XDUser::getUserByID($_SESSION['xdDashboardUser']); + $user = XDUser::getUserByID($dashboardUserId); if ($user == NULL) { - throw new \Exception('User does not exist'); + throw new Exception('User does not exist'); } - if ($user->isManager() == false) { - throw new \Exception('Permissions do not allow you to access the dashboard'); + if (!$user->isManager()) { + throw new Exception('Permissions do not allow you to access the dashboard'); } return $user; } /** - * @return \XDUser + * @return XDUser * - * @throws \Exception + * @throws Exception */ function getLoggedInUser() { - - if (!isset($_SESSION['xdUser'])) { - throw new \SessionExpiredException(); + $session = SessionSingleton::getSession(); + // This is where the + $sessionUserId = $session->get('xdUser'); + if (empty($sessionUserId)) { + throw new Exception('Session Expired', 2); } - - $user = \XDUser::getUserByID($_SESSION['xdUser']); + $user = XDUser::getUserByID($sessionUserId); if ($user == NULL) { - throw new \Exception('User does not exist'); + throw new Exception('User does not exist'); } return $user; } /** - * @return \XDUser + * @return XDUser * - * @throws \Exception + * @throws Exception */ function getInternalUser() { @@ -172,13 +232,13 @@ function getInternalUser() && $_SERVER['REMOTE_ADDR'] == '127.0.0.1' && isset($_REQUEST['user_id']) ) { - $user = \XDUser::getUserByID($_REQUEST['user_id']); + $user = XDUser::getUserByID($_REQUEST['user_id']); if ($user == NULL) { - throw new \Exception('Internal user does not exist'); + throw new Exception('Internal user does not exist'); } } else { - throw new \Exception('Internal user not specified'); + throw new Exception('Internal user not specified'); } return $user; @@ -187,24 +247,27 @@ function getInternalUser() /** * @param array $requirements * @param string $session_variable + * @throws SessionExpiredException */ function enforceUserRequirements($requirements, $session_variable = 'xdUser') { $returnData = array(); + $session = SessionSingleton::getSession(); if (in_array(STATUS_LOGGED_IN, $requirements)) { - if (!isset($_SESSION[$session_variable])) { + $sessionUserId = $session->get($session_variable); + if (!isset($sessionUserId)) { throw new \SessionExpiredException(); } - $user = \XDUser::getUserByID($_SESSION[$session_variable]); + $user = XDUser::getUserByID($sessionUserId); if ($user == NULL) { - $returnData['status'] = 'user_does_not_exist'; - $returnData['success'] = false; + $returnData['status'] = 'user_does_not_exist'; + $returnData['success'] = false; $returnData['totalCount'] = 0; - $returnData['message'] = 'user_does_not_exist'; - $returnData['data'] = array(); + $returnData['message'] = 'user_does_not_exist'; + $returnData['data'] = array(); \xd_controller\returnJSON($returnData); } @@ -217,33 +280,33 @@ function enforceUserRequirements($requirements, $session_variable = 'xdUser') // This user must be a member of the Science Advisory Board if (!$user->hasAcl('sab')) { - $returnData['status'] = 'not_sab_member'; - $returnData['success'] = false; + $returnData['status'] = 'not_sab_member'; + $returnData['success'] = false; $returnData['totalCount'] = 0; - $returnData['message'] = 'not_sab_member'; - $returnData['data'] = array(); + $returnData['message'] = 'not_sab_member'; + $returnData['data'] = array(); \xd_controller\returnJSON($returnData); } } if (in_array(STATUS_MANAGER_ROLE, $requirements)) { if (!($user->isManager())) { - $returnData['status'] = 'not_a_manager'; - $returnData['success'] = false; + $returnData['status'] = 'not_a_manager'; + $returnData['success'] = false; $returnData['totalCount'] = 0; - $returnData['message'] = 'not_a_manager'; - $returnData['data'] = array(); + $returnData['message'] = 'not_a_manager'; + $returnData['data'] = array(); \xd_controller\returnJSON($returnData); } } if (in_array(STATUS_CENTER_DIRECTOR_ROLE, $requirements)) { if (!$user->hasAcl(ROLE_ID_CENTER_DIRECTOR)) { - $returnData['status'] = 'not_a_center_director'; - $returnData['success'] = false; + $returnData['status'] = 'not_a_center_director'; + $returnData['success'] = false; $returnData['totalCount'] = 0; - $returnData['message'] = 'not_a_center_director'; - $returnData['data'] = array(); + $returnData['message'] = 'not_a_center_director'; + $returnData['data'] = array(); \xd_controller\returnJSON($returnData); } } @@ -272,29 +335,47 @@ function secureCheck(&$required_params, $m, $enforce_all = true) $qualifyingParams = 0; - if ($m == 'GET') { $param_array = $_GET; } - if ($m == 'POST') { $param_array = $_POST; } - if ($m == 'REQUEST') { $param_array = $_REQUEST; } + if ($m == 'GET') { + $param_array = $_GET; + } + if ($m == 'POST') { + $param_array = $_POST; + } + if ($m == 'REQUEST') { + $param_array = $_REQUEST; + } foreach ($required_params as $param => $pattern) { if (!isset($param_array[$param])) { - if ($enforce_all) { return false; } - if (!$enforce_all) { continue; } + if ($enforce_all) { + return false; + } + if (!$enforce_all) { + continue; + } } $param_array[$param] = preg_replace('/\s+/', ' ', $param_array[$param]); if (preg_match($pattern, $param_array[$param]) == 0) { - if ($enforce_all) { return false; } - if (!$enforce_all) { continue; } + if ($enforce_all) { + return false; + } + if (!$enforce_all) { + continue; + } } $qualifyingParams++; } - if ($enforce_all) { return true; } - if (!$enforce_all) { return $qualifyingParams; } + if ($enforce_all) { + return true; + } + if (!$enforce_all) { + return $qualifyingParams; + } } /** @@ -309,12 +390,12 @@ function assertParametersSet($requiredParams = array()) // $v represents the format of the value that param must conform // to (a regex) $param_name = $k; - $pattern = $v; + $pattern = $v; } else { // $v represents the name of the param $param_name = $v; - $pattern = '/.*/'; + $pattern = '/.*/'; } assertParameterSet($param_name, $pattern); @@ -337,7 +418,8 @@ function assertParameterSet( $param_name, $pattern = '/.*/', $compress_whitespace = true -) { +) +{ if (!isset($_REQUEST[$param_name])) { \xd_response\presentError("'$param_name' not specified."); } @@ -389,5 +471,5 @@ function assertEmailParameterSet($param_name) function isEmailValid($email) { $validator = new \Egulias\EmailValidator\EmailValidator(); - return $validator->isValid($email); + return $validator->isValid($email, new RFCValidation()); } diff --git a/libraries/utilities.php b/libraries/utilities.php index 111a06f00b..218e0beb89 100644 --- a/libraries/utilities.php +++ b/libraries/utilities.php @@ -397,11 +397,15 @@ function checkForCenterLogo($apply_css = true) * \filter_var($value, $filter, $options) */ -function filter_var($value, $filter = FILTER_DEFAULT, $options = null) +function filter_var($value, $filter = FILTER_DEFAULT, $options = null): mixed { - return ( FILTER_VALIDATE_BOOLEAN == $filter && false === $value - ? false - : \filter_var($value, $filter, $options) ); + if (FILTER_VALIDATE_BOOLEAN === $filter && false === $value) { + return false; + } + if (isset($options) && (is_int($options) || is_array($options))) { + return \filter_var($value, $filter, $options); + } + return \filter_var($value, $filter); } /** @@ -414,7 +418,7 @@ function filter_var($value, $filter = FILTER_DEFAULT, $options = null) * @return A fully qualified path, with the base path prepended to a relative path */ -function qualify_path($path, $base_path) +function qualify_path(string $path, string $base_path) { if ( 0 !== strpos($path, DIRECTORY_SEPARATOR) && null !== $base_path && "" != $base_path ) { $path = $base_path . DIRECTORY_SEPARATOR . $path; @@ -440,7 +444,10 @@ function resolve_path($path) // If we don't limit to filly qualified paths then relative paths such as "../../foo" // are not properly resolved. - if ( 0 !== strpos($path, DIRECTORY_SEPARATOR) ) { + if (!isset($path)) { + return null; + } + if (!str_starts_with($path, DIRECTORY_SEPARATOR)) { return $path; } diff --git a/open_xdmod/build_scripts/templates/install.template b/open_xdmod/build_scripts/templates/install.template index 246e0d52b1..80c22611ed 100755 --- a/open_xdmod/build_scripts/templates/install.template +++ b/open_xdmod/build_scripts/templates/install.template @@ -495,7 +495,7 @@ function substitutePaths($dirs) $fileDirRegexGroup = '(__DIR__|dirname\s*\(\s*__FILE__\s*\))'; substituteInDir($destDir . $dirs['bin'], array( - "#${fileDirRegexGroup}\s*\.\s*'/\.\./configuration/linker\.php'#" + "#{$fileDirRegexGroup}\s*\.\s*'/\.\./configuration/linker\.php'#" => "'" . $dirs['data'] . "/configuration/linker.php'", '/__XDMOD_SHARE_PATH__/' => $dirs['data'], '/__XDMOD_LIB_PATH__/' => $dirs['lib'], @@ -504,9 +504,9 @@ function substitutePaths($dirs) )); substituteInDir($destDir . $dirs['lib'], array( - "#${fileDirRegexGroup}\s*\.\s*'/\.\./html/tmp'#" + "#{$fileDirRegexGroup}\s*\.\s*'/\.\./html/tmp'#" => "'" . $dirs['data'] . "/html/tmp'", - "#${fileDirRegexGroup}\s*\.\s*'/\.\./configuration/linker\.php'#" + "#{$fileDirRegexGroup}\s*\.\s*'/\.\./configuration/linker\.php'#" => "'" . $dirs['data'] . "/configuration/linker.php'", )); diff --git a/open_xdmod/modules/xdmod/build.json b/open_xdmod/modules/xdmod/build.json index 22d6f344d5..e4b34712ad 100644 --- a/open_xdmod/modules/xdmod/build.json +++ b/open_xdmod/modules/xdmod/build.json @@ -27,24 +27,31 @@ "/user_manual_builder" ], "exclude_patterns": [ - "#/\\.#", + "#^\\/\\.(?!env).*#", + "#\\.eslintrc\\.json#", "#xdmod-.*\\.rpm$#", "#xdmod-.*\\.tar\\.gz$#", "#^\\/html\\/gui\\/lib\\/extjs\\/examples\\/[A-t,v-z].*#", "#^\\/html\\/gui\\/lib\\/extjs\\/resources\\/images\\/[a,h-z].*#", "#^\\/html\\/gui\\/lib\\/extjs\\/resources\\/.*\\.swf#", - "#^\\/configuration\\/.+\\..+\\.template$#" + "#^\\/configuration\\/.+\\..+\\.template$#", + "#\\/var\\/.*#" ] }, "file_maps": { "data": [ + {"bin/console": true }, + {".env": true}, "classes", "etl", - "html", "libraries", "templates", "tools", "vendor", + "src", + "html", + "config", + "var", { "configuration/constants.php": true }, { "configuration/linker.php" : true } ], @@ -106,11 +113,6 @@ "pre_build": [ "rm -rf vendor/", "composer install", - "sed -i 's/SimpleSAML_Error_Assertion::installHandler();//g' vendor/simplesamlphp/simplesamlphp/www/_include.php", - "patch vendor/simplesamlphp/simplesamlphp/www/errorreport.php < open_xdmod/modules/xdmod/assets/simplesamlphp-CVE-2020-5225.patch", - "patch vendor/simplesamlphp/simplesamlphp/www/module.php < open_xdmod/modules/xdmod/assets/simplesamlPHP-CVE-2020-5301.patch", - "patch vendor/simplesamlphp/simplesamlphp/lib/SimpleSAML/Utils/HTTP.php < open_xdmod/modules/xdmod/assets/simplesamlphp-SSPSA_201907-01_HTTP.patch", - "patch vendor/simplesamlphp/simplesamlphp/modules/core/www/postredirect.php < open_xdmod/modules/xdmod/assets/simplesamlphp-SSPSA_201907-01_postredirect.patch", "user_manual_builder/setup.sh", "user_manual_builder/build_user_manual.sh --builddir user_manual_builder/ --destdir html/user_manual/" ] diff --git a/open_xdmod/modules/xdmod/xdmod.spec.in b/open_xdmod/modules/xdmod/xdmod.spec.in index c37642cc27..615817a3d3 100644 --- a/open_xdmod/modules/xdmod/xdmod.spec.in +++ b/open_xdmod/modules/xdmod/xdmod.spec.in @@ -12,7 +12,7 @@ BuildRoot: %(mktemp -ud %{_tmppath}/%{name}-%{version}__PRERELEASE__-%{relea BuildArch: noarch BuildRequires: php-cli Requires: httpd mod_ssl -Requires: php >= 7.4 php-cli php-mysqlnd php-pdo php-gd php-xml php-mbstring php-zip php-posix +Requires: php >= 8.2 php-cli php-mysqlnd php-pdo php-gd php-xml php-mbstring php-zip php-posix Requires: php-pecl-apcu php-json Requires: libreoffice-writer Requires: chromium-headless >= 111 @@ -63,6 +63,10 @@ for file in exceptions.log query.log; do chown apache:xdmod %{_localstatedir}/log/%{name}/$file chmod 0660 %{_localstatedir}/log/%{name}/$file done + +# Ensure the var directory is owned by apache so it can be written to. +chown apache:xdmod %{_datadir}/%{name}/var + if [ "$1" -ge 2 ]; then echo "Run xdmod-upgrade to complete the Open XDMoD upgrade process." echo "Refer to http://open.xdmod.org/upgrade.html for more details." @@ -76,10 +80,13 @@ rm -rf $RPM_BUILD_ROOT %defattr(0750,root,xdmod,-) %{_bindir}/%{name}-* %{_bindir}/acl-* +%{_bindir}/console %defattr(-,root,root,-) %{_libdir}/%{name}/ %{_datadir}/%{name}/ +%{_datadir}/%{name}/.env + %{_docdir}/%{name}-%{version}__PRERELEASE__/ %dir %attr(0770,apache,xdmod) %{_localstatedir}/log/%{name} @@ -92,7 +99,6 @@ rm -rf $RPM_BUILD_ROOT %config(noreplace) %{_sysconfdir}/%{name}/etl/ %config(noreplace) %{_sysconfdir}/logrotate.d/%{name} %config(noreplace) %{_sysconfdir}/cron.d/%{name} -%config(noreplace) %{_datadir}/%{name}/html/robots.txt %dir %attr(0570,apache,xdmod) %{xdmod_export_dir} diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Controller/AboutController.php b/src/Controller/AboutController.php new file mode 100644 index 0000000000..9fc17bf79e --- /dev/null +++ b/src/Controller/AboutController.php @@ -0,0 +1,178 @@ +render('about/xdmod.html.twig', [ + 'xdmod_version' => \xd_versioning\getPortalVersion(true) + ]); + } + + /** + * @return Response + */ + #[Route('/open_xdmod', methods: ["GET"])] + #[Route('/openxd.html', methods: ["GET"])] + public function openXdmod(): Response + { + return $this->render('about/open_xdmod.html.twig'); + } + + /** + * @return Response + */ + #[Route('/supremm', methods: ['GET'])] + #[Route('/supremm.html', methods: ['GET'])] + public function supremm(): Response + { + return $this->render('about/supremm.html.twig'); + } + + /** + * @return Response + * @throws Exception if unable to retrieve a connection to the 'datawarehouse' DB. + */ + #[Route('/federated', methods: ["GET"])] + #[Route('/federated.php', methods: ["GET"])] + public function federated(): Response + { + $parameters = []; + $federatedRole = $this->getConfigValue('federated', 'role'); + $parameters['federated_role'] = $federatedRole; + + if ($federatedRole === 'instance') { + $parameters['hub_url'] = $this->getConfigValue('federated', 'huburl'); + } elseif ($federatedRole === 'hub') { + $db = DB::factory('datawarehouse'); + $instanceResults = $db->query('SELECT * FROM federation_instances;'); + + $instances = []; + $lastCloudQuery = []; + $derived = 1; + foreach ($instanceResults as $instance) { + $prefix = $instance['prefix']; + $extra = json_decode($instance['extra'], true); + $instances[$prefix] = [ + 'contact' => $extra['contact'], + 'url' => $extra['url'], + 'lastCloudEvent' => null, + 'lastJobTask' => null + ]; + unset($extra['contact']); + unset($extra['url']); + $instances[$prefix]['extra'] = $extra; + array_push( + $lastCloudQuery, + '(SELECT \'' . $prefix . '\' AS prefix, FROM_UNIXTIME(event_time_ts) as event_ts FROM `' . $prefix . '-modw_cloud`.`event` ORDER BY 2 DESC LIMIT 1) `A' . $derived . '`' + ); + $derived++; + } + $lastCloudResults = $db->query('SELECT * FROM ' . implode(' UNION ALL SELECT * FROM ', $lastCloudQuery)); + foreach ($lastCloudResults as $result) { + $instances[$result['prefix']]['lastCloudEvent'] = $result['event_ts']; + } + + $parameters['instances'] = $instances; + } + + return $this->render('about/federated.html.twig', $parameters); + } + + /** + * @return Response + */ + #[Route('/roadmap', methods: ['GET'])] + #[Route('/roadmap.php', methods: ["GET"])] + public function roadmap(): Response + { + return $this->render('about/roadmap.html.twig', [ + 'header' => $this->getConfigValue('roadmap', 'header'), + 'url' => $this->getConfigValue('roadmap', 'url') + ]); + } + + /** + * @return Response + */ + #[Route('/team', methods: ['GET'])] + #[Route('/team.html', methods: ['GET'])] + public function team(): Response + { + return $this->render('about/team.html.twig'); + } + + /** + * @return Response + */ + #[Route('/publications', methods: ['GET'])] + #[Route('/publications.html', methods: ['GET'])] + public function publications(): Response + { + return $this->render('about/publications.html.twig'); + } + + /** + * @return Response + */ + #[Route('/links', methods: ['GET'])] + #[Route('/links.html', methods: ['GET'])] + public function links(): Response + { + return $this->render('about/links.html.twig'); + } + + /** + * @param string $xdmodType + * @return Response + */ + #[Route('/release_notes/{xdmodType}', methods: ['GET'])] + public function releaseNotes(string $xdmodType): Response + { + if (str_contains($xdmodType, '.')) { + $parts = explode('.', $xdmodType); + $xdmodType = $parts[0]; + } + if (!in_array($xdmodType, ['xdmod', 'xsede'])) { + throw new BadRequestHttpException('Invalid XDMoD installation type specified.'); + } + + $xsedeInstall = $this->getConfigValue('features', 'xsede', false); + if (!$xsedeInstall && $xdmodType === 'xsede') { + throw new BadRequestHttpException('Invalid XDMoD installation type xsede specified.'); + } + + return $this->render("about/{$xdmodType}_release_notes.html.twig"); + } + + /** + * @param Request $request + * @return Response + */ + #[Route('/presentations', methods: ['GET'])] + #[Route('/presentations.html', methods: ['GET'])] + public function teamPresentations(Request $request): Response + { + return $this->render('about/presentations.html.twig'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php new file mode 100644 index 0000000000..a8f916808a --- /dev/null +++ b/src/Controller/AccountController.php @@ -0,0 +1,97 @@ + '.*'])] +class AccountController extends BaseController +{ + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route("/requests", methods: ["POST"])] + public function getRequests(Request $request): Response + { + $pdo = DB::factory('database'); + $md5Only = $this->getBooleanParam($request, 'md5only', false, false); + + $results = $pdo->query("SELECT id, first_name, last_name, organization, title, email_address, field_of_science, additional_information, time_submitted, status, comments FROM AccountRequests"); + + $response['success'] = true; + $response['count'] = count($results); + $response['response'] = $results; + + $response['md5'] = md5(json_encode($response)); + + if ($md5Only) { + unset($response['count']); + unset($response['response']); + } + + return $this->json($response); + } + + /** + * + * @param Request $request + * @param string $requestId + * @return Response + * @throws Exception + */ + #[Route("/{requestId}", methods: ["PUT"])] + public function updateRequest(Request $request, string $requestId): Response + { + $comments = $this->getStringParam($request, 'comments', true); + $pdo = DB::factory('database'); + + $results = $pdo->query('SELECT id FROM AccountRequests WHERE id=:id', ['id' => $requestId]); + + // Check to see if we have an AccountRequest that matches the provided $requestId before updating it. + if (count($results) == 1) { + $pdo->execute('UPDATE AccountRequests SET comments=:comments WHERE id=:id', ['comments' => $comments, 'id' => $requestId]); + $response['success'] = true; + } else { + $response['success'] = false; + $response['message'] = 'invalid id specified'; + } + + return $this->json($response); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route("", methods: ["DELETE"])] + public function deleteRequest(Request $request): Response + { + $requestIds = $this->getStringParam($request, 'id', true, null, '/^\d+(,\d+)*$/'); + $ids = array_map('intval', explode(',', $requestIds)); + + $queryPlaceholders = implode(', ', array_fill(0, count($ids), '?')); + $query = "DELETE FROM AccountRequests WHERE id IN ($queryPlaceholders)"; + + $pdo = DB::factory('database'); + $pdo->execute($query, $ids); + + return $this->json(['success' => true]); + } + + +} diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php new file mode 100644 index 0000000000..3cf72c9ad6 --- /dev/null +++ b/src/Controller/AdminController.php @@ -0,0 +1,78 @@ + '.*'], methods: ['POST'])] + public function resetUserTourViewed(Request $request): Response + { + $this->authorize($request, ['mgr']); + + $viewedTour = $this->getIntParam($request, 'viewedTour', true); + $selectedUser = XDUser::getUserByID( + $this->getIntParam($request, 'uid', true) + ); + + if (!isset($selectedUser)) { + throw new BadRequestHttpException('User not found'); + } + + if (!in_array($viewedTour, [0, 1])) { + throw new BadRequestHttpException('Invalid data parameter'); + } + + $storage = new \UserStorage($selectedUser, 'viewed_user_tour'); + $upserted = $storage->upsert(0, ['viewedTour' => $viewedTour]); + + if (!isset($upserted)) { + $this->logger->error( + sprintf( + 'reset_user_tour_viewed failed for %s (%s)', + $selectedUser->getUsername(), + $selectedUser->getUserID() + ) + ); + + return $this->json([ + [ + 'success' => false, + 'total' => 0, + 'message' => 'An error has occurred while updating this user, please contact support.' + ] + ]); + } + + return $this->json( + [ + 'success' => true, + 'total' => 1, + 'message' => 'This user will be now be prompted to view the New User Tour the next time they visit XDMoD' + ] + ); + } + +} diff --git a/src/Controller/AuthenticationController.php b/src/Controller/AuthenticationController.php new file mode 100644 index 0000000000..9fd26e3cad --- /dev/null +++ b/src/Controller/AuthenticationController.php @@ -0,0 +1,248 @@ +logger = $logger; + $this->parameters = $parameters; + $this->ssoUrl = $this->parameters->get('sso')['login_link']; + parent::__construct($logger, $twig, $tokenHelper); + } + + /** + * This route is here so that we make sure the XDUser::postLogin function is called and that the users token is set + * in the appropriate location for use throughout the users session. The actual "login" process is handled by + * `src/Authenticators/FormLoginAuthenticator` with configuration located in `config/packages/security.yaml`. + * @return Response + */ + #[Route('{prefix}/login', name: 'xdmod_login', requirements: ['prefix' => '.*'], methods: ['POST'])] + #[Route('/login', name: 'xdmod_new_login', methods: ['POST'])] + public function formLogin(): Response + { + $user = $this->getUser(); + + if (null === $user) { + $this->logger->error('No user found during login.'); + return $this->json([ + 'success' => false, + ], Response::HTTP_UNAUTHORIZED); + } + + // If for some reason we didn't get an \XDUser then fail fast. + // ( Honestly this is really here to make sure auto-complete works for $user ) + if (!($user instanceof \XDUser)) { + $this->logger->error('User instance type mismatched.'); + return $this->json([ + 'success' => false, + 'message' => 'User type mismatch' + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + try { + $user->postLogin(); + } catch (Exception $e) { + $this->logger->error( + sprintf( + 'An error has occurred during the post-login process for %s', + $user->getUsername() + ) + ); + return $this->json([ + 'success' => false, + 'message' => 'Error occurred during post login process.' + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $token = $user->getToken(); + $response = $this->json([ + 'success' => true, + 'results' => [ + 'token' => $token, + 'name' => $user->getFormalName() + ] + ]); + + // This cookie will tell the HomeController that we have a currently logged in user. + $response->headers->setCookie(new Cookie('xdmod_token', $token)); + + $this->logger->info(sprintf('Successful login by %s', $user->getUsername())); + + return $response; + } + + /** + * This route is responsible for any logic that may need to be executed when a user is logged out. Currently, the + * actual heavy lifting of logging out is done by the configuration in `config/packages/security.yaml`. + * + * + * + * @param Request $request + * @return Response + */ + #[Route('/rest/logout', name: 'xdmod_logout', methods: ['POST', 'GET'])] + #[Route('/logout', name: 'xdmod_new_logout', methods: ['POST'])] + #[Route('/rest/auth/logout', name: 'xdmod_rest_auth_logout', methods: ['POST'])] + public function formLogout(Request $request): Response + { + $this->logger->error('*** FormLogout ***'); + $token = $request->getSession()->get('xdmod_token'); + \XDSessionManager::logoutUser($token); + $request->getSession()->invalidate(); + + $response = $this->redirectToRoute('xdmod_home'); + $response->headers->removeCookie('xdmod_token'); + return $response; + } + + /** + * This route is responsible for logging API users in. The configuration for this route is located in + * `config/packages/security.yaml`. + * + * @param Request $request + * + * @return Response + * @throws Exception + */ + #[Route('{prefix}/api/login', name: 'api_login', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function login(Request $request): Response + { + $user = $this->getUser(); + + if (null === $user) { + return $this->json([ + 'message' => 'missing credentials' + ], Response::HTTP_UNAUTHORIZED); + } + + $xdUser = \XDUser::getUserByUserName($user->getUserIdentifier()); + + $xdUser->postLogin(); + + $request->getSession()->set('xdUser', $xdUser->getUserID()); + + + $response = $this->json([ + 'user' => $user->getUserIdentifier(), + 'token' => $xdUser->getToken() + ]); + + + // Make sure that we remove any xdmod_token cookie that already exists so that it can be set with the correct + // token. + $cookies = $response->headers->getCookies(); + foreach ($cookies as $cookie) { + if ($cookie->getName() === 'xdmod_token') { + $response->headers->removeCookie('xdmod_token'); + } + } + + $response->headers->setCookie(Cookie::create('xdmod_token', $xdUser->getToken(), 0, '/', '', true)); + + return $response; + } + + /** + * This Route is responsible for logging API Users out. + * + * @return Response + * + * @throws Exception since this should never be called. + */ + #[Route('{prefix}/api/logout', name: 'api_logout', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function logout(): Response + { + session_destroy(); + throw new Exception("Don't forget to activate logout."); + } + + /** + * @param Request $request + * + * @return Response + */ + #[Route('{prefix}/auth/idpredirect', name: 'idp_redirect', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function idpRedirect(Request $request): Response + { + $returnTo = $this->getStringParam($request, 'returnTo'); + $value = $this->ssoUrl; + if (!empty($returnTo)) { + $ssoUrl = $this->ssoUrl; + $returnTo = urlencode($returnTo); + $value = "{$ssoUrl}?ReturnTo=$returnTo"; + $request->getSession()->set('_security.main.target_path', $returnTo); + } + $this->logger->debug('IDP Redirect', [$value]); + return new Response($value, Response::HTTP_OK, ['Content-Type' => 'text/plain']); + } + + + #[Route('/jwt-redirect', methods: ['GET'])] + public function redirectWithJwt(Request $request): Response + { + try { + $jupyterhub_url = \xd_utilities\getConfiguration('jupyterhub', 'url'); + } catch (Exception $e) { + throw new HttpException(501, 'JupyterHub not configured.'); + } + try { + $user = $this->authorize($request); + } catch (UnauthorizedHttpException $e) { + return new RedirectResponse('/#jwt-redirect'); + } + list($jwt, $expiration) = JsonWebToken::encode($user->getUsername()); + $cookie = new Cookie( + 'xdmod_jwt', + $jwt, + $expiration, + '/', // path + null, // domain + true, // secure + true // httpOnly + ); + $response = new RedirectResponse($jupyterhub_url); + $response->headers->setCookie($cookie); + return $response; + } +} + diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php new file mode 100644 index 0000000000..3135846443 --- /dev/null +++ b/src/Controller/BaseController.php @@ -0,0 +1,695 @@ +logger = $logger; + $this->twig = $twig; + $this->tokenHelper = $tokenHelper; + } + + + /** + * Will attempt to authorize the provided users' roles against the provided array of role requirements. + * + * If the user is not authorized, an exception will be thrown. Otherwise, the function will simply return the + * authorized user. + * + * @param Request $request the current HTTP request object. + * @param array $requiredAcls either an array of Acl objects or their equivalent string representations that are + * required for access to a given feature. + * @param bool $anyAcl default false. If true then the requesting user will be considered authorized if there + * is any overlap in the requirements and the users currently assigned acls. If false, + * the requesting user will only be considered authorized if they have *all* of the + * specified $requiredAcls. + * + * @return XDUser the currently logged in, authorized user. + * + * @throws UnauthorizedHttpException if no requirements are provided and there is no currently logged in user or if + * requirements are provided but not met by the public user. + * @throws AccessDeniedHttpException if the currently logged in user is unable to fulfill the provided requirements. + * @throws Exception if any of the values supplied within $requirements are not valid Acls objects or string + * representations of Acl objects. + */ + public function authorize(Request $request, array $requiredAcls = [], bool $anyAcl = false): XDUser + { + + $user = $this->getXDUser($request->getSession()); + $this->logger->debug( + sprintf( + 'Attempting to authorize user: %s (%s) with requirements: %s', + $user->getUsername(), + var_export($user->getAclNames(), true), + var_export($requiredAcls, true) + ) + ); + // If role requirements were not given, then the only check to perform + // is that the user is not a public user. + $isPublicUser = $user->isPublicUser(); + if (empty($requiredAcls) && $isPublicUser) { + throw new UnauthorizedHttpException('xdmod', self::EXCEPTION_MESSAGE); + } + + if ($anyAcl) { + $authorized = count(array_intersect($user->getAclNames(), $requiredAcls)) > 0; + } else { + $authorized = $user->hasAcls($requiredAcls); + } + + if (!$authorized && !$isPublicUser) { + throw new AccessDeniedHttpException(self::EXCEPTION_MESSAGE); + } elseif (!$authorized && $isPublicUser) { + throw new UnauthorizedHttpException('xdmod', self::EXCEPTION_MESSAGE); + } + + // Return the successfully-authorized user. + return $user; + } + + /** + * Retrieve the XDMoD user from a request object. + * + * @param Request $request The request to retrieve a user from. + * @return XDUser The user who made the request. + */ + protected function getUserFromRequest(Request $request) + { + return $request->attributes->get(BaseController::USER_ATTRIBUTE_KEY); + } + + /** + * @param Session $session + * @return XDUser + * @throws Exception + */ + protected function getXDUser(Session $session): XDUser + { + $user = $this->getUser(); + if (!isset($user)) { + if ($session->has('xdUser')) { + $user = XDUser::getUserByID($session->get('xdUser')); + } elseif ($session->has('xdmod_token')) { + $user = XDUser::getUserByToken($session->get('xdmod_token')); + } else { + if (!$session->has('public_session_token')) { + $session->set('public_session_token', 'public-' . microtime(true) . '-' . uniqid()); + } + $user = XDUser::getPublicUser(); + } + + } else { + $user = XDUser::getUserByUserName($user->getUserIdentifier()); + } + return $user; + } + + protected function getDashboardUser(Session $session) + { + + } + + + /** + * Attempt to get a parameter value from a request and filter it. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory If true, an exception will be thrown if + * the parameter is missing from the request. + * @param mixed $default The value to return if the parameter was not + * specified and the parameter is not mandatory. + * @param int $filterId The ID of the filter to use. See filter_var. + * @param mixed $filterOptions The options to use with the filter. + * The filter should be configured so that + * it returns null if conversion is not + * successful. See filter_var. + * @param string $expectedValueType The expected type for the value. + * This is used purely for errors thrown + * when the parameter value is invalid. + * @return mixed If available and valid, the parameter value. + * Otherwise, if it is missing and not mandatory, + * the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value is not valid + * according to the given filter. + */ + private function getParam( + Request $request, + string $name, + bool $mandatory, + $default, + int $filterId, + $filterOptions, + string $expectedValueType, + bool $compressWhitespace = true + ) + { + // If the parameter was not present, throw an exception if it was + // mandatory and return the default if it was not. + // Attempt to extract the parameter value from the request. + $value = $request->get($name); + $originalValueType = get_debug_type($value); + + if ($value === null) { + if ($mandatory) { + throw new BadRequestHttpException("$name is a required parameter."); + } else { + return $default; + } + } + + + // This is to accommodate the functionality from \xd_security\assertParameterSet that wasn't already provided + // by this function. + if ($expectedValueType === 'string' && $compressWhitespace) { + $value = preg_replace('/\s+/', ' ', $value); + } + + // Run the found parameter value through the given filter. + $value = filter_var($value, $filterId, $filterOptions); + $valueType = get_debug_type($value); + + if ($value === null || + ($originalValueType === 'array' && $value === false) || + ($expectedValueType === 'string' && $valueType !== 'string' && $value !== false) || + ($expectedValueType === 'Unix timestamp' && $valueType !== 'DateTime' && $value !== false) || + ($expectedValueType === 'ISO 8601 Date' && $valueType !== 'DateTime' && $value !== false) || + ($expectedValueType === 'integer' && $valueType !== 'int' && $value !== false) || + ($expectedValueType === 'float' && $valueType !== 'float' && $value !== false) + ) { + throw new BadRequestHttpException("Invalid value for $name. Must be a(n) $expectedValueType."); + } + + // If the value is invalid, throw an exception. + if ($value === false && $expectedValueType !== 'boolean' && $originalValueType !== 'bool') { + // This happens when filtering a value doesn't match a regexp. + throw new BadRequestHttpException("Invalid $name"); + } + + // Return the filtered value. + return $value; + } + + /** + * Attempt to get an integer parameter value from a request. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as an integer. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value could not be + * converted to an integer. + */ + protected function getIntParam( + Request $request, + string $name, + bool $mandatory = false, + $default = null + ) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_VALIDATE_INT, + [ + 'options' => [ + 'default' => null, + ], + ], + 'integer' + ); + } + + /** + * Attempt to get a float parameter value from a request. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as a float. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value could not be + * converted to a float. + */ + protected function getFloatParam( + Request $request, + string $name, + bool $mandatory = false, + $default = null + ) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_VALIDATE_FLOAT, + [ + 'options' => [ + 'default' => null, + ], + ], + 'float' + ); + } + + /** + * Attempt to get a string parameter value from a request. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as a string. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory. + */ + protected function getStringParam( + Request $request, + string $name, + bool $mandatory = false, + $default = null, + string $pattern = null, + bool $compressWhitespace = true + ) + { + if (!isset($pattern)) { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_DEFAULT, + [], + 'string', + $compressWhitespace + ); + } else { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_VALIDATE_REGEXP, + ['options' => ['regexp' => $pattern]], + 'string', + $compressWhitespace + ); + } + } + + protected function getEmailParam(Request $request, string $name, bool $mandatory = false, $default = null) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_CALLBACK, + ['options' => function ($value) { + $validator = new EmailValidator(); + if ($validator->isValid($value, new RFCValidation())) { + return $value; + } + return null; + }], + 'email', + false + ); + } + + /** + * Attempt to get a boolean parameter value from a request. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as a boolean. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value could not be + * converted to a boolean. + */ + protected function getBooleanParam( + Request $request, + string $name, + bool $mandatory = false, + $default = null + ) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_CALLBACK, + [ + 'options' => function ($value) { + // Run the found parameter value through a boolean filter. + $filteredValue = filter_var( + $value, + FILTER_VALIDATE_BOOLEAN, + [ + 'flags' => FILTER_NULL_ON_FAILURE, + ] + ); + + // If the filter converted the string, return the boolean. + if ($filteredValue !== null) { + return $filteredValue; + } + + // Check the value against 'y' for true and 'n' for false. + $lowercaseValue = strtolower($value); + if ($lowercaseValue === 'y') { + return true; + } + if ($lowercaseValue === 'n') { + return false; + } + + // Return null if all conversion attempts failed. + return null; + }, + ], + 'boolean' + ); + } + + /** + * Attempt to get a date parameter value from a request where it is + * submitted as a Unix timestamp. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as a DateTime. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value could not be + * converted to a DateTime. + */ + protected function getDateTimeFromUnixParam( + Request $request, + string $name, + bool $mandatory = false, + $default = null + ) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_CALLBACK, + [ + 'options' => function ($value) { + $value_dt = \DateTime::createFromFormat('U', $value); + if ($value_dt === false) { + return null; + } + return $value_dt; + }, + ], + 'Unix timestamp' + ); + } + + /** + * Attempt to get a date parameter value from a request where it is + * submitted as a ISO 8601 (YYYY-MM-DD) date. + * + * @param Request $request The request to extract the parameter from. + * @param string $name The name of the parameter. + * @param bool $mandatory (Optional) If true, an exception will be + * thrown if the parameter is missing from the + * request. (Defaults to false.) + * @param mixed $default (Optional) The value to return if the + * parameter was not specified and the parameter + * is not mandatory. (Defaults to null.) + * @return mixed If available and valid, the parameter value + * as a DateTime. Otherwise, if it is missing + * and not mandatory, the given default. + * + * @throws BadRequestHttpException If the parameter was not available + * and the parameter was deemed mandatory, + * or if the parameter value could not be + * converted to a DateTime. + */ + protected function getDateFromISO8601Param( + Request $request, + string $name, + bool $mandatory = false, + $default = null + ) + { + return $this->getParam( + $request, + $name, + $mandatory, + $default, + FILTER_CALLBACK, + [ + 'options' => function ($value) { + return self::filterDate($value); + }, + ], + 'ISO 8601 Date' + ); + } + + /** + * @param Request $request + * @return void + */ + protected function verifyCaptcha(Request $request) + { + $captchaSiteKey = ''; + $captchaSecret = ''; + try { + $captchaSiteKey = \xd_utilities\getConfiguration('mailer', 'captcha_public_key'); + $captchaSecret = \xd_utilities\getConfiguration('mailer', 'captcha_private_key'); + } catch (\Exception $e) { + } + + $user = $this->getUserFromRequest($request); + + if ('' !== $captchaSiteKey && '' !== $captchaSecret && !isset($user)) { + $gCaptchaResponse = $request->get('g-recaptcha-response'); + if (!isset($gCaptchaResponse)) { + throw new BadRequestHttpException('Recaptcha information not specified'); + } + $recaptcha = new \ReCaptcha\ReCaptcha($captchaSecret); + $resp = $recaptcha->verify($gCaptchaResponse, $_SERVER['REMOTE_ADDR']); + if (!$resp->isSuccess()) { + $errors = $resp->getErrorCodes(); + throw new BadRequestHttpException(sprintf('You must enter the words in the Recaptcha box properly. %s', print_r($errors, true))); + } + } + } + + /** + * @param string $section + * @param string $key + * @param $default + * @return string|null + */ + protected function getConfigValue(string $section, string $key, $default = null): ?string + { + try { + $result = \xd_utilities\getConfiguration($section, $key); + } catch (\Exception $e) { + $result = $default; + } + return $result; + } + + protected function getFeatures() + { + $features = \xd_utilities\getConfigurationSection('features'); + + // Convert array values to boolean + array_walk($features, function (&$v) { + $v = ($v == 'on'); + }); + return $features; + } + + /** + * @param Request $request + * @return \XDUser + * @throws BadRequestHttpException if the provided token is empty, or there is not a provided token. + * @throws \Exception if the user's token from the db does not validate against the provided token. + */ + protected function authenticateToken($request) + { + // NOTE: While we prefer token's to be pulled from the 'Authorization' header, we also support a fallback lookup + // to the request's query params. + $authorizationHeader = $request->headers->get('Authorization'); + if (empty($authorizationHeader) || strpos($authorizationHeader, Tokens::HEADER_KEY) === false) { + $rawToken = $request->get(Tokens::HEADER_KEY); + } else { + $rawToken = substr($authorizationHeader, strpos($authorizationHeader, Tokens::HEADER_KEY) + strlen(Tokens::HEADER_KEY) + 1); + } + if (empty($rawToken)) { + throw new UnauthorizedHttpException( + Tokens::HEADER_KEY, + 'No token provided.', + null, + 0 + ); + } + + + // We expect the token to be in the form /^(\d+).(.*)$/ so just make sure it at least has the required delimiter. + $delimPosition = strpos($rawToken, Tokens::DELIMITER); + if ($delimPosition === false) { + throw new UnauthorizedHttpException( + Tokens::HEADER_KEY, + 'Invalid token.' + ); + } + + $userId = substr($rawToken, 0, $delimPosition); + $token = substr($rawToken, $delimPosition + 1); + + return $this->tokenHelper->authenticate($userId, $token); + } + + /** + * Attempts to convert the provided $value into an instance of DateTime by using the provided $format. If $value is + * unable to be converted into a valid DateTime or if warnings are generated during the process it will be filtered + * and null returned. + * + * @param string $value the date to be validated against the provided $format. Ex: 2027-08-15 + * @param string $format the format to be used when converting the string $value to an instance of DateTime + * + * @return DateTime|null If the creation of a DateTime was successful without warning then an instance of DateTime + * will be returned, else null; + */ + private static function filterDate(string $value, string $format = 'Y-m-d'): ?DateTime + { + $dateTime = DateTime::createFromFormat($format, $value); + + $lastErrors = DateTime::getLastErrors(); + + /* For PHP versions less than 8.2.0 $lastErrors will always be an array w/ the properties: + * warning_count, warnings, error_count, and errors. For versions >= 8.2.0, it will return false if + * there are no errors else it will return as it did pre-8.2.0. + * + * The below `if` statement takes this into account by ensuring that we specifically check for when + * $value_dt is not false ( i.e. is a DateTime object ) but we do have 1 or more warnings which + * indicates that the value of $value_dt is most likely not what it's expected to be. + * + * Example: parsing the date `2024-01-99` results in a $value_dt of: + * DateTime('2024-04-08') + * and a $lastError of: + * [ + * 'warning_count' => 1, + * 'warnings' => [ + * 10 => 'The parsed date was invalid' + * ], + * 'error_count' => 0, + * 'errors' => [] + * ] + */ + if ($dateTime === false || (is_array($lastErrors) && $lastErrors['warning_count'] > 0)) { + return null; + } + return $dateTime; + } +} diff --git a/src/Controller/ChartPoolController.php b/src/Controller/ChartPoolController.php new file mode 100644 index 0000000000..4c235f7055 --- /dev/null +++ b/src/Controller/ChartPoolController.php @@ -0,0 +1,107 @@ +authorize($request); + } catch (Exception $e) { + return $this->json(buildError(new \SessionExpiredException()), 401); + } + + $operation = $this->getStringParam($request, 'operation', true); + switch ($operation) { + case 'add_to_queue': + return $this->addToQueue($request, $user); + case 'remove_from_queue': + return $this->removeFromQueue($request, $user); + default: + throw new BadRequestHttpException('invalid operation specified'); + } + } + + /** + * @param Request $request + * @param XDUser $user + * @return Response + * @throws Exception + */ + private function addToQueue(Request $request, XDUser $user): Response + { + $chartTitle = $this->getStringParam($request, 'chart_title', false, 'Untitled Chart'); + $chartId = $this->getStringParam($request, 'chart_id'); + + /* this is freaking ugly, but it's here so that we can maintain the same expected test output. */ + if (is_null($chartId)) { + return $this->json(buildError("A chart identifier must be specified")); + } elseif ($chartId === '') { + return $this->json(buildError("Invalid value specified for 'chart_id'.")); + } elseif (empty($chartId)){ + return $this->json(buildError("A chart identifier must be specified")); + } + $chartDrillDetails = $this->getStringParam($request, 'chart_drill_details'); + $chartDateDesc = $this->getStringParam($request, 'chart_date_desc'); + + $chart_pool = new XDChartPool($user); + + try { + $chart_pool->addChartToQueue( + $chartId, + $chartTitle, + $chartDrillDetails, + $chartDateDesc + ); + } catch (Exception $e) { + return $this->json(buildError($e->getMessage())); + } + + return $this->json([ + 'success' => true, + 'action' => 'add' + ]); + } + + /** + * @param Request $request + * @param XDUser $user + * @return Response + * @throws Exception + */ + private function removeFromQueue(Request $request, XDUser $user): Response + { + $chart_pool = new XDChartPool($user); + + $chartTitle = $this->getStringParam($request, 'chart_title', false, 'Untitled Chart'); + $chartId = str_replace('title=' . $chartTitle, 'title=' . urlencode($chartTitle), $this->getStringParam($request, 'chart_id', true)); + + $chart_pool->removeChartFromQueue($chartId); + return $this->json([ + 'success' => true, + 'action' => 'remove' + ]); + } + +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php new file mode 100644 index 0000000000..e8f2622587 --- /dev/null +++ b/src/Controller/DashboardController.php @@ -0,0 +1,519 @@ + '.*'])] +class DashboardController extends BaseController +{ + /** + * The individual dashboard components have a namespace prefix to simplify + * the implementation of the algorithm that determines which + * components to display. There are two sources of configuration data for + * the components. The roles configuration file and the user configuration + * (in the database). The user configuration only contains chart components. + * The user configuration is handled via the "Show in Summary tab" checkbox + * in the metric explorer. + * + * Non-chart components and the full-width components are defined in the roles + * configuration file and are not overrideable. + * + * Chart components are handled as follows: + * - All user charts with "show in summary tab" checked will be displayed + * - If a user chart has the same name as a chart in the role configuration + * then its settings will be used in place of the role chart. + */ + private const TOP_COMPONENT = 't.'; + private const CHART_COMPONENT = 'c.'; + private const NON_CHART_COMPONENT = 'p.'; + + /** + * @param Request $request + * @return Response + * @throws Exception if the user for this request does not have a user id. + */ + #[Route('/components', methods: ['GET'])] + public function getComponents(Request $request): Response + { + $user = $this->getXDUser($request->getSession()); + + $dashboardComponents = []; + + $mostPrivilegedAcl = Acls::getMostPrivilegedAcl($user)->getName(); + + $layout = $this->getLayout($user); + + $roleConfig = \Configuration\XdmodConfiguration::assocArrayFactory( + 'roles.json', + CONFIG_DIR, + null, + ['config_variables' => $this->getConfigVariables($user)] + ); + + $presets = $roleConfig['roles'][$mostPrivilegedAcl]; + + if (isset($presets['dashboard_components'])) { + + foreach($presets['dashboard_components'] as $component) { + + $componentType = self::NON_CHART_COMPONENT; + + if (isset($component['region']) && $component['region'] === 'top') { + $componentType = self::TOP_COMPONENT; + $chartLocation = $componentType . $component['name']; + $column = -1; + } else { + if ($component['type'] === 'xdmod-dash-chart-cmp') { + $componentType = self::CHART_COMPONENT; + $component['config']['name'] = $component['name']; + $component['config']['chart']['featured'] = true; + } + + $defaultLayout = null; + if (isset($component['location']) && isset($component['location']['row']) && isset($component['location']['column'])) { + $defaultLayout = array($component['location']['row'], $component['location']['column']); + } + + list($chartLocation, $column) = $layout->getLocation($componentType . $component['name'], $defaultLayout); + } + + $dashboardComponents[$chartLocation] = array( + 'name' => $componentType . $component['name'], + 'type' => $component['type'], + 'config' => isset($component['config']) ? $component['config'] : array(), + 'column' => $column + ); + } + } + + if ($user->isPublicUser() === false) + { + $queryStore = new \UserStorage($user, 'queries_store'); + $queries = $queryStore->get(); + + if ($queries != null) { + foreach ($queries as $query) { + if (!isset($query['config']) || !isset($query['name'])) { + continue; + } + + $queryConfig = json_decode($query['config']); + + if (!isset($queryConfig->featured) || !$queryConfig->featured) { + continue; + } + + $name = self::CHART_COMPONENT . $query['name']; + + list($chartLocation, $column) = $layout->getLocation($name); + + $dashboardComponents[$chartLocation] = [ + 'name' => $name, + 'type' => 'xdmod-dash-chart-cmp', + 'config' => [ + 'name' => $query['name'], + 'chart' => $queryConfig + ], + 'column' => $column + ]; + } + } + } + + ksort($dashboardComponents); + + return $this->json([ + 'success' => true, + 'total' => count($dashboardComponents), + 'portalConfig' => ['columns' => $layout->getColumnCount()], + 'data' => array_values($dashboardComponents) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws BadRequestHttpException if the data parameter is not present and does not contain a layout and columns + * property. + * @throws Exception if there is a problem authorizing the current user. + */ + #[Route('/layout', methods: ['POST'])] + public function setLayout(Request $request): Response + { + $user = $this->authorize($request); + + $content = json_decode($this->getStringParam($request, 'data', true), true); + + if ($content === null || !isset($content['layout']) || !isset($content['columns'])) { + throw new BadRequestException('Invalid data parameter'); + } + + $storage = new \UserStorage($user, 'summary_layout'); + + return $this->json([ + 'success' => true, + 'total' => 1, + 'data' => $storage->upsert(0, $content) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception if there is a problem authorizing the current user. + */ + #[Route('/layout', methods: ['DELETE'])] + public function resetLayout(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = $this->authorize($request); + + $storage = new \UserStorage($user, 'summary_layout'); + + $storage->del(); + + return $this->json([ + 'success' => true, + 'total' => 1 + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception if there is a problem authorizing the current user. + */ + #[Route('/rolereport', methods: ['GET'])] + public function getRoleReport(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = $this->authorize($request); + + $role = $user->getMostPrivilegedRole()->getName(); + $report_id_suffix = 'autogenerated-' . $role; + $report_id = $user->getUserID() . '-' . $report_id_suffix; + $userReport = null; + $rm = new \XDReportManager($user); + $reports = $rm->fetchReportTable(); + foreach ($reports as &$report) { + if ($report['report_id'] === $report_id) { + $userReport = $report; + } + } + if (is_null($userReport)){ + $availTemplates = $rm::enumerateReportTemplates([$role], 'Dashboard Tab Report'); + if (empty($availTemplates)) { + throw new NotFoundHttpException("No dashboard tab report template available for $role"); + } + + $template = $rm::retrieveReportTemplate($user, $availTemplates[0]['id']); + $template->buildReportFromTemplate($_REQUEST, $report_id_suffix); + $reports = $rm->fetchReportTable(); + foreach ($reports as &$report) { + if ($report['report_id'] === $report_id) { + $userReport = $report; + } + } + } + $data = $rm->loadReportData($userReport['report_id']); + $count = 0; + foreach($data['queue'] as $queue) { + $chart_id = explode('&', $queue['chart_id']); + $chart_id_parsed = array(); + foreach($chart_id as $value) { + list($key, $value) = explode('=', $value); + $key = urldecode($key); + $value = urldecode($value); + $json = json_decode($value, true); + + if ($key === 'timeseries') { + $value = $value === 'y' || $value === 'true'; + } elseif ($json !== null) { + $value = $json; + } + $chart_id_parsed[$key] = $value; + } + $data['queue'][$count]['chart_id'] = $chart_id_parsed; + $count++; + } + return $this->json([ + 'success' => true, + 'total' => count($data), + 'data' => $data + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception if there is a problem authorizing the current user. + */ + #[Route('/savedchartsreports', methods: ['GET'])] + public function getSavedChartReports(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = $this->authorize($request); + // fetch charts + $queries = new \UserStorage($user, 'queries_store'); + $data = $queries->get(); + foreach ($data as &$query) { + $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); + $query['type'] = 'Chart'; + } + // fetch reports + $rm = new \XDReportManager($user); + $reports = $rm->fetchReportTable(); + foreach ($reports as &$report) { + $tmp = []; + $tmp['type'] = 'Report'; + $tmp['name'] = $report['report_name']; + $tmp['chart_count'] = $report['chart_count']; + $tmp['charts_per_page'] = $report['charts_per_page']; + $tmp['creation_method'] = $report['creation_method']; + $tmp['report_delivery'] = $report['report_delivery']; + $tmp['report_format'] = $report['report_format']; + $tmp['report_id'] = $report['report_id']; + $tmp['report_name'] = $report['report_name']; + $tmp['report_schedule'] = $report['report_schedule']; + $tmp['report_title'] = $report['report_title']; + $tmp['ts'] = $report['last_modified']; + $tmp['config'] = $report['report_id']; + $data[] = $tmp; + } + return $this->json([ + 'success' => true, + 'total' => count($data), + 'data' => $data + ]); + } + + /** + * @param Request $request + * @return Response + */ + #[Route('/viewedUserTour', methods: ['POST'])] + public function setViewedUserTour(Request $request): Response + { + $user = $this->authorize($request); + $viewedTour = $this->getIntParam($request, 'viewedTour', true); + + if (!in_array($viewedTour, [0,1])) { + throw new BadRequestHttpException('Invalid data parameter'); + } + + $storage = new \UserStorage($user, 'viewed_user_tour'); + + return $this->json([ + 'success' => true, + 'total' => 1, + 'msg' => $storage->upsert(0, ['viewedTour' => $viewedTour]) + ]); + } + + /** + * + * @param Request $request + * @return Response + */ + #[Route('/viewedUserTour', methods: ['GET'])] + public function getViewedUserTour(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = $this->authorize($request); + $storage = new \UserStorage($user, 'viewed_user_tour'); + return $this->json([ + 'success' => true, + 'total' => 1, + 'data' => $storage->get() + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/statistics', methods: ['GET'])] + public function getStatistics(Request $request): Response + { + try { + $user = $this->authorize($request); + } catch (Exception $e) { + $user = XDUser::getPublicUser(); + } + + $aggregationUnit = $request->get('aggregation_unit', 'auto'); + + $startDate = $this->getStringParam($request, 'start_date', true); + $endDate = $this->getStringParam($request, 'end_date', true); + + $this->checkDateRange($startDate, $endDate); + + $this->logger->debug('Date Range is Copacetic!'); + // This try/catch block is intended to replace the "Base table or + // view not found: 1146 Table 'modw_aggregates.jobfact_by_day' + // doesn't exist" error message with something more informative for + // Open XDMoD users. + try { + $this->logger->debug('Running Aggregate Query!'); + $query = new \DataWarehouse\Query\AggregateQuery( + 'Jobs', + $aggregationUnit, + $startDate, + $endDate, + 'none', + 'all' + ); + + $result = $query->execute(); + } catch (PDOException $e) { + $this->logger->debug('Exception while running query: %s', buildError($e)); + if ($e->getCode() === '42S02' && strpos($e->getMessage(), 'modw_aggregates.jobfact_by_') !== false) { + $msg = 'Aggregate table not found, have you ingested your data?'; + throw new Exception($msg); + } else { + throw $e; + } + } catch (Exception $e) { + $this->logger->debug('Exception while running query: %s', buildError($e)); + throw new BadRequestHttpException($e->getMessage()); + } + + $this->logger->debug('Successfully ran query!'); + $rawRoles = XdmodConfiguration::assocArrayFactory('roles.json', CONFIG_DIR); + + $mostPrivileged = $user->getMostPrivilegedRole()->getName(); + $formats = $rawRoles['roles'][$mostPrivileged]['statistics_formats']; + + $this->logger->debug('Returning Data'); + return $this->json( + [ + 'totalCount' => 1, + 'success' => true, + 'message' => '', + 'formats' => $formats, + 'data' => [$result] + ] + ); + } + + /* + * Get the column layout manager for the user + * + * @return \CCR\ColumnLayout + */ + /** + * @param XDUser $user + * @return ColumnLayout + */ + private function getLayout(XDUser $user): ColumnLayout + { + $defaultLayout = null; + $defaultColumnCount = 2; + + if ($user->isPublicUser() === false) { + $layoutStore = new \UserStorage($user, 'summary_layout'); + $record = $layoutStore->getById(0); + if ($record) { + $defaultLayout = $record['layout']; + $defaultColumnCount = $record['columns']; + } + } + + return new ColumnLayout($defaultColumnCount, $defaultLayout); + } + + /** + * Checks that the `$[start|end]Date` values are valid ( `Y-m-d` ) dates and that `$startDate` + * is before `$endDate`. + * + * @param string $startDate the beginning of the date range. + * @param string $endDate the end of the date range. + * @throws BadRequestHttpException if either start or end dates are not provided in the format + * `Y-m-d`, or if the start date is after the end date. + */ + protected function checkDateRange($startDate, $endDate) + { + $this->logger->debug('Checking Date Rage'); + $startTimestamp = $this->getTimestamp($startDate, 'start_date'); + $endTimestamp = $this->getTimestamp($endDate, 'end_date'); + + $this->logger->debug(sprintf('Start Timestamp: %s', $startTimestamp)); + $this->logger->debug(sprintf('End Timestamp: %s', $endTimestamp)); + + if ($startTimestamp > $endTimestamp) { + throw new BadRequestHttpException('Start Date must not be after End Date'); + } + } + + /** + * Attempt to convert the provided string $date value into an equivalent unix timestamp (int). + * + * @param string $date The value to be converted into a DateTime. + * @param string $paramName 'date', The name of the parameter to be included in the exception + * message if validation fails. + * @param string $format 'Y-m-d', The format that `$date` should be in. + * @return int created from the provided `$date` value. + * @throws BadRequestHttpException if the date is not in the form `Y-m-d`. + */ + protected function getTimestamp($date, $paramName = 'date', $format = 'Y-m-d') + { + $this->logger->debug(sprintf('Getting Timestamp for %s %s', $date, $format)); + + $parsed = date_parse_from_format($format, $date); + $this->logger->debug(sprintf('Parsed: %s', var_export($parsed, true))); + if ($parsed['year'] === false || $parsed['month'] === false || $parsed['day'] === false) { + $this->logger->debug(sprintf('Unable to parse %s', $paramName)); + throw new BadRequestHttpException("Unable to parse $paramName"); + } + $date = mktime( + $parsed['hour'] !== false ? $parsed['hour'] : 0, + $parsed['minute'] !== false ? $parsed['minute'] : 0, + $parsed['second'] !== false ? $parsed['second' ] : 0, + $parsed['month'], + $parsed['day'], + $parsed['year'] + ); + $this->logger->debug(sprintf('Date: %s', var_export($date, true))); + if ($date === false || $parsed['error_count'] > 0) { + $this->logger->debug('Unable to get timestamp!'); + throw new BadRequestHttpException("Unable to parse $paramName"); + } + + $this->logger->debug('Successfully made timestamp!'); + return $date; + } + + /** + * @param XDUser $user + * @return array + */ + private function getConfigVariables(XDUser $user): array + { + $person_id = $user->getPersonID(true); + $obj_warehouse = new \XDWarehouse(); + + return [ + 'PERSON_ID' => $person_id, + 'PERSON_NAME' => $obj_warehouse->resolveName($person_id) + ]; + } +} diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php new file mode 100644 index 0000000000..f9e95b57d9 --- /dev/null +++ b/src/Controller/HomeController.php @@ -0,0 +1,363 @@ + [ + 'entityId', + 'singleSignOnService' => [ + 'url', + 'binding' + ], + 'singleLogoutService' => [ + 'url', + 'binding' + ] + ], + 'sp' => [ + 'entityId', + 'assertionConsumerService' => [ + 'url', + 'binding' + ], + 'singleLogoutService' => [ + 'url', + 'binding' + ] + ] + ]; + private $parameters; + + public function __construct(LoggerInterface $logger, Environment $twig, Tokens $tokenHelper, ContainerBagInterface $parameters) + { + parent::__construct($logger, $twig, $tokenHelper); + $this->parameters = $parameters; + } + + /** + * This route serves XDMoD + * + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/', name: 'xdmod_home', methods: ['GET', 'OPTIONS'])] + public function index(Request $request): Response + { + + if ($request->getMethod() === 'OPTIONS') { + // We don't need to send anything back for a CORS pre-flight + return new Response(); + } + + $session = $request->getSession(); + $returnTo = $session->get('_security.main.target_path'); + if (!empty($returnTo)) { + $returnTo = urldecode($returnTo); + $url = $this->generateUrl('xdmod_home'); + $this->logger->warning('redirecting to', ["$returnTo"]); + $session->set('_security.main.target_path', null); + $response = new RedirectResponse("$returnTo"); + return $response; + } + $user = $this->getXDUser($session); + + $session->set('xdUser', $user->getUserID()); + + $realms = array_reduce(Realms::getRealms(), function ($carry, Realm $item) { + $carry [] = $item->getName(); + return $carry; + }, []); + + $features = $this->getFeatures(); + + $isSSOConfigured = false; + $ssoLoginLink = [ + 'organization' => [ + 'en' => 'Test Organization', + 'icon' => '' + ] + ]; + $ssoSettings = $this->getParameter('sso'); + try { + $auth = new XDSamlAuthentication(); + $ssoLoginLink = $auth->getLoginLink(); + $isSSOConfigured = true; + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), [$e]); + } + + try { + $db = DB::factory('database'); + $personInfo = $db->query( + 'SELECT first_name, last_name FROM modw.person p WHERE p.id = :person_id', + [':person_id' => $user->getPersonID()] + ); + } catch (\Exception $e) { + $personInfo = [ + [ + 'first_name' => 'Unknown', + 'last_name' => 'Unknown' + ] + ]; + } + + // JupyterHub Config + try { + $jupyterHubURL = \xd_utilities\getConfiguration('jupyterhub', 'url'); + $jupyterIsEnabled = !empty($jupyterHubURL); + } catch (\Exception $e) { + $jupyterIsEnabled = false; + $jupyterHubURL = ''; + } + + + $params = [ + 'user' => $user, + 'person_name' => sprintf('%s, %s', $personInfo[0]['last_name'], $personInfo[0]['first_name']), + 'title' => \xd_utilities\getConfiguration('general', 'title'), + 'keywords' => 'xdmod, xsede, analytics, metrics on demand, hpc, visualization, statistics, reporting, auditing, nsf, resources, resource providers', + 'description' => 'XSEDE Metrics on Demand (XDMoD) is a comprehensive auditing framework for XSEDE, the follow-on to NSF\'s TeraGrid program. XDMoD provides detailed information on resource utilization and performance across all resource providers.', + 'extjs_path' => 'gui/lib', + 'extjs_version' => 'extjs', + 'rest_token' => $user->getToken(), + 'colors' => json_encode(json_decode(file_get_contents(CONFIG_DIR . '/colors1.json'), true)), + 'rest_url' => sprintf( + '%s%s', + \xd_utilities\getConfiguration('rest', 'base'), + \xd_utilities\getConfiguration('rest', 'version') + ), + 'realms' => $realms, + 'tech_support_recipient' => \xd_utilities\getConfiguration('general', 'tech_support_recipient'), + 'xdmod_portal_version' => \xd_versioning\getPortalVersion(), + 'xdmod_portal_version_short' => \xd_versioning\getPortalVersion(true), + 'disabled_menus' => json_encode(Acls::getDisabledMenus($user, $realms)), + 'ORGANIZATION_NAME' => 'organization_name', + 'ORGANIZATION_NAME_ABBREV' => 'organization_abbrev', + 'captcha_site_key' => $this->getCaptchaSiteKey($user), + 'xdmod_features' => json_encode($features), + 'timezone' => date_default_timezone_get(), + 'isCenterDirector' => $user->hasAcl('cd'), + 'is_logged_in' => !$user->isPublicUser(), + 'is_public_user' => $user->isPublicUser(), + 'user_dashboard' => isset($features['user_dashboard']) && filter_var($features['user_dashboard'], FILTER_VALIDATE_BOOLEAN), + 'all_user_roles' => json_encode($user->enumAllAvailableRoles()), + 'raw_data_realms' => json_encode($this->getRawDataRealms($user)), + 'use_center_logo' => false, + 'asset_paths' => Assets::generateAssetTags('portal'), + 'profile_editor_init_flag' => $this->getProfileEditorInitFlag($user), + 'no_script_message' => $this->getNoScriptMessage('XDMoD requires JavaScript, which is currently disabled in your browser.'), + 'org_name' => ORGANIZATION_NAME, + 'is_sso_configured' => $isSSOConfigured, + 'sso_login_link' => json_encode($ssoLoginLink), + 'sso_show_local_login' => $ssoSettings['show_local_login'], + 'sso_direct_link' => $ssoSettings['direct_link'], + 'is_jupyter_configured' => $jupyterIsEnabled, + 'jupyter_hub_url' => $jupyterHubURL + ]; + + $logoData = $this->getLogoData(); + if ($logoData !== null) { + list($logoWidth, $imgData) = $logoData; + $params['use_center_logo'] = true; + $params['logo_width'] = $logoWidth; + $params['img_data'] = $imgData; + } + + return $this->render('index.html.twig', $params); + } + + + /** + * @param $user + * @return array + */ + private function getRawDataRealms($user): array + { + return array_map( + function ($item) { + return $item['name']; + }, + \DataWarehouse\Access\RawData::getRawDataRealms($user) + ); + } + + public function getCaptchaSiteKey(XDUser $user) + { + $result = ''; + + if ($user->isPublicUser()) { + $captchaSiteKey = \xd_utilities\getConfiguration('mailer', 'captcha_public_key'); + $captchaSecret = \xd_utilities\getConfiguration('mailer', 'captcha_private_key'); + if ('' !== $captchaSiteKey && '' !== $captchaSecret) { + $result = $captchaSiteKey; + } + } + + return $result; + } + + + public function getLogoData() + { + try { + $logo = \xd_utilities\getConfiguration('general', 'center_logo'); + $logo_width = \xd_utilities\getConfiguration('general', 'center_logo_width'); + + $logo_width = intval($logo_width); + + if (strlen($logo) > 0 && $logo[0] !== '/') { + $logo = __DIR__ . '/' . $logo; + } + + if (file_exists($logo)) { + $img_data = base64_encode(file_get_contents($logo)); + return [ + $logo_width, + $img_data + ]; + } + } catch (Exception $e) { + } + + return null; + } + + private function getProfileEditorInitFlag(XDUser $user) + { + $profile_editor_init_flag = ''; + $usersFirstLogin = ($user->getCreationTimestamp() == $user->getUpdateTimestamp() && !$user->isPublicUser()); + + // If the user logging in is an XSEDE/Single Sign On user, they may or may not have + // an e-mail address set. The logic below assists in presenting the Profile Editor + // with the appropriate (initial) view + $userEmail = $user->getEmailAddress(); + $userEmailSpecified = ($userEmail != NO_EMAIL_ADDRESS_SET && !empty($userEmail)); + if ($user->isSSOUser() === true || $usersFirstLogin) { + + // NOTE: $_SESSION['suppress_profile_autoload'] will be set only upon update of the user's profile (see respective REST call) + $session = SessionSingleton::getSession(); + $suppressProfileAutoload = $session->get('suppress_profile_autoload'); + if ($usersFirstLogin && $userEmailSpecified && (!isset($suppressProfileAutoload) && $user->getUserType() != 50)) { + // If the user is logging in for the first time and does have an e-mail address set + // (due to it being specified in the XDcDB), welcome the user and inform them they + // have an opportunity to update their e-mail address. + + $profile_editor_init_flag = 'XDMoD.ProfileEditorConstants.WELCOME_EMAIL_CHANGE'; + + } elseif ($usersFirstLogin && !$userEmailSpecified) { + // If the user is logging in for the first time and does *not* have an e-mail address set, + // welcome the user and inform them that he/she needs to set an e-mail address. + + $profile_editor_init_flag = 'XDMoD.ProfileEditorConstants.WELCOME_EMAIL_NEEDED'; + + } + } + if (!$userEmailSpecified) { + // Regardless of whether the user is logging in for the first time or not, the lack of + // an e-mail address requires attention + $profile_editor_init_flag = 'XDMoD.ProfileEditorConstants.EMAIL_NEEDED'; + } + + return $profile_editor_init_flag; + } + + public function getNoScriptMessage($message, $exception_message = '', $include_structure_tags = false) + { + + if (!empty($exception_message)) { + $exception_message = '

(' . $exception_message . ')'; + } + + $message = '
' . + '
' . + '' . + '

' . + $message . + $exception_message . + '
'; + + if ($include_structure_tags) { + $message = '' . $message . ''; + } + + return $message; + } + + /** + * SSO is considered setup + * @return bool + */ + private function isSSOSetup(array $ssoSettings): bool + { + return $this->validate( + self::REQUIRED_SAML_SETTINGS, + $ssoSettings + ); + } + + /** + * Validates the provided $settings against the $required structure. This function only validates that + * keys are present and have non-empty values. + * + * @param array $required + * @param array $settings + * @return bool + */ + private function validate(array $required, array $settings): bool + { + foreach ($required as $key => $values) { + // We need to account for PHP's wonderful dual-index arrays, and since $settings is expected + // to be indexed by string we translate the $required indexes to their string counterpart here. + if (is_numeric($key) && is_string($values)) { + $key = $values; + } + + // the following logic goes something like: + // If: + // - The required key exists in $settings + // - AND The required key is a string + // - AND The value for the given key in $settings is non-empty + // - OR - + // If: + // - The required key exists in $settings + // - AND the $required values are an array ( aka, we must go deeper ) + // - AND and it's value in $settings is non-empty + // - AND the validation of the levels below this one are valid + // THEN continue the validation + // ELSE it's invalid + if (array_key_exists($key, $settings) && is_string($values) && !empty($settings[$key]) || + (array_key_exists($key, $settings) && is_array($values) && !empty($settings[$key]) && $this->validate($values, $settings[$key]))) { + continue; + } + return false; + } + // If we've gotten this far then the settings must be valid. + return true; + } +} + diff --git a/src/Controller/InternalDashboard/InternalDashboardController.php b/src/Controller/InternalDashboard/InternalDashboardController.php new file mode 100644 index 0000000000..ff31d7cbff --- /dev/null +++ b/src/Controller/InternalDashboard/InternalDashboardController.php @@ -0,0 +1,469 @@ +getXDUser($request->getSession()); + + $hasAppKernels = false; + $instanceId = null; + if (\xd_utilities\getConfiguration('features', 'appkernels') == 'on') { + $op = $request->get('op'); + if ($op === 'ak_instance') { + $hasAppKernels = true; + $instanceId = $request->get('instance_id'); + } + } + + $parameters = [ + 'user' => $user, + 'has_app_kernels' => $hasAppKernels, + 'ak_instance_id' => $instanceId, + 'extjs_path' => 'gui/lib', + 'extjs_version' => 'extjs', + 'rest_token' => $user->getToken(), + 'rest_url' => sprintf( + '%s%s', + \xd_utilities\getConfiguration('rest', 'base'), + \xd_utilities\getConfiguration('rest', 'version') + ), + 'xdmod_features' => json_encode($this->getFeatures()), + 'is_logged_in' => !$user->isPublicUser(), + 'is_public_user' => $user->isPublicUser(), + 'asset_paths' => Assets::generateAssetTags('internal_dashboard'), + ]; + + if ($user->isPublicUser()) { + return $this->render('internal_dashboard_login.html.twig', $parameters); + } else { + if (!$user->hasAcl('mgr')) { + return $this->redirect($this->generateUrl('xdmod_home')); + } + return $this->render('internal_dashboard.html.twig', $parameters); + } + } + + /** + * + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/controllers/dashboard.php', methods: ['POST'])] + public function dashboardIndex(Request $request): Response + { + $operation = $request->get('operation'); + switch ($operation) { + case 'get_menu': + return $this->getMenus($request); + default: + throw new BadRequestHttpException(); + } + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/internal_dashboard/menus', methods: ['POST'])] + public function getMenus(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $config = \Configuration\XdmodConfiguration::assocArrayFactory( + 'internal_dashboard.json', + CONFIG_DIR + ); + + return $this->json([ + 'success' => true, + 'response' => $config['menu'], + 'count' => count($config['menu']) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/internal_dashboard/controllers/user.php', methods: ['POST'])] + public function userController(Request $request): Response + { + $operation = $request->get('operation'); + switch ($operation) { + case 'get_summary': + return $this->getUserSummary($request); + default: + throw new BadRequestHttpException(); + } + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/internal_dashboard/users/summary')] + public function getUserSummary(Request $request): Response + { + $pdo = DB::factory('database'); + + $sql = 'SELECT COUNT(*) AS count FROM moddb.Users'; + list($userCountRow) = $pdo->query($sql); + + // TODO: Refactor these queries. + $sql = ' + SELECT COUNT(DISTINCT user_id) AS count + FROM moddb.SessionManager + WHERE DATEDIFF(NOW(), FROM_UNIXTIME(init_time)) < 7 + '; + list($last7DaysRow) = $pdo->query($sql); + + $sql = ' + SELECT COUNT(DISTINCT user_id) AS count + FROM moddb.SessionManager + WHERE DATEDIFF(NOW(), FROM_UNIXTIME(init_time)) < 30 + '; + list($last30DaysRow) = $pdo->query($sql); + + $returnData = [ + 'success' => true, + 'response' => [ + [ + 'user_count' => $userCountRow['count'], + 'logged_in_last_7_days' => $last7DaysRow['count'], + 'logged_in_last_30_days' => $last30DaysRow['count'], + ] + ], + 'count' => 1, + ]; + return $this->json($returnData); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route("/internal_dashboard/controllers/controller.php", name: "legacy_internal_dashboard_controllers", methods: ['POST', 'GET'])] + public function controllers(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $operation = $this->getStringParam($request, 'operation', true); + switch ($operation) { + case 'enum_account_requests': + return $this->enumAccountRequests($request); + case 'update_request': + return $this->updateRequest($request); + case 'delete_request': + return $this->deleteRequest($request); + case 'enum_existing_users': + return $this->enumExistingUsers($request); + case 'enum_user_types_and_roles': + return $this->enumUserTypesAndRoles($request); + case 'enum_user_visits': + case 'enum_user_visits_export': + return $this->enumUserVisits($request, $operation); + case 'ak_rr': + return $this->akrr($request); + case 'logout': + return $this->redirectToRoute('xdmod_logout'); + } + + return $this->json([ + 'success' => false, + 'message' => 'operation not recognized' + ]); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * Enumerates the current Requests for an XDMoD Account. + * + * @param Request $request + * @return Response + * @throws Exception if unable to retrieve a connection to the database. + */ + private function enumAccountRequests(Request $request): Response + { + $md5Only = $this->getBooleanParam($request, 'md5only'); + + $pdo = DB::factory('database'); + $sql = <<query($sql); + + $data = [ + 'success' => true, + 'count' => count($results), + 'response' => $results + ]; + + if (isset($md5Only) && $md5Only) { + unset($data['count']); + unset($data['response']); + } + + return $this->json($data); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function updateRequest(Request $request): Response + { + $id = $this->getStringParam($request, 'id', true); + $comments = $this->getStringParam($request, 'comments', true); + + $pdo = DB::factory('database'); + + $data = ['success' => false, 'message' => 'invalid id specified']; + + $results = $pdo->query('SELECT id FROM AccountRequests WHERE id=:id', ['id' => $id]); + if (count($results) == 1) { + $pdo->execute('UPDATE AccountRequests SET comments=:comments WHERE id=:id', [ + 'comments' => $comments, + 'id' => $id + ]); + $data = ['success' => true]; + } + + return $this->json($data); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function deleteRequest(Request $request): Response + { + $idParam = $this->getStringParam($request, 'id', true, null, '/^\d+(,\d+)*$/'); + + $pdo = DB::factory('database'); + + $ids = array_map('intval', explode(',', $idParam)); + $idPlaceholders = implode(', ', array_fill(0, count($ids), '?')); + $pdo->execute("DELETE FROM AccountRequests WHERE id IN ($idPlaceholders)", $ids); + + return $this->json(['success' => true]); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * NOTE: there is a duplicate function UserAdminController::enumExistingUsers, this one can be removed when we are + * able to discontinue the old API layout. + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function enumExistingUsers(Request $request): Response + { + $groupFilter = $this->getStringParam($request, 'group_filter'); + $roleFilter = $this->getStringParam($request, 'role_filter'); + $contextFilter = $this->getStringParam($request, 'context_filter', false, ''); + + $results = Users::getUsers($groupFilter, $roleFilter, $contextFilter); + $filtered = []; + foreach ($results as $user) { + if ($user['username'] !== 'Public User') { + $filtered[] = $user; + } + } + $data = [ + 'success' => true, + 'count' => count($filtered), + 'response' => $filtered + ]; + /*return $this->json([ + 'success' => true, + 'count' => count($filtered), + 'response' => $filtered + + ]);*/ + return new Response(json_encode($data)); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function enumUserTypesAndRoles(Request $request): Response + { + $data = ['success' => true]; + $pdo = DB::factory('database'); + + $query = 'SELECT id, type, color FROM moddb.UserTypes'; + $userTypes = $pdo->query($query); + $data['user_types'] = $userTypes; + + $query = "SELECT display AS description, acl_id AS role_id FROM moddb.acls WHERE name != 'pub' ORDER BY description"; + $userRoles = $pdo->query($query); + $data['user_roles'] = $userRoles; + $response = new Response(json_encode($data)); + $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); + return $response; + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * @param Request $request + * @param string $operation + * @return Response + * @throws Exception + */ + private function enumUserVisits(Request $request, string $operation): Response + { + $timeframe = strtolower($this->getStringParam($request, 'timeframe')); + $userTypes = explode(',', $this->getStringParam($request, 'user_types')); + $logger = $this->logger; + if (!in_array($timeframe, ['year', 'month'])) { + return $this->json([ + 'success' => false, + 'message' => 'invalid value specified for the timeframe' + ]); + } + + $data = [ + 'success' => true, + 'stats' => \XDStatistics::getUserVisitStats($timeframe, $userTypes) + ]; + + if ($operation === 'enum_user_visits_export') { + $response = new StreamedResponse(function () use ($data, $logger) { + $outputStream = fopen('php://output', 'wb'); + + $content = array_map( + function ($item) { + return implode(',', $item); + }, + $data['stats'] + ); + + // Add the header row. + array_unshift($content, implode(',', UserVisitController::$columns)); + + $written = fwrite( + $outputStream, + sprintf("%s\n", implode("\n", $content)) + ); + if ($written === false) { + $logger->error('Unable to write bytes to output stream'); + exit(1); + } + + $flushed = fflush($outputStream); + if ($flushed === false) { + $logger->error('Unable to flush output stream'); + exit(1); + } + + $closed = fclose($outputStream); + if ($closed === false) { + $logger->error('Unable to close output stream'); + exit(1); + } + }); + + $response->headers->set('Content-Type', 'application/xls'); + $response->headers->set( + 'Content-Disposition', + HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + "xdmod_visitation_stats_by_$timeframe.csv" + ) + ); + + return $response; + } + + return new Response(json_encode($data)); + } + + /** + * Code Ported from `html/internal_dashboard/controllers/controller.php` + * + * TODO: Probable end up removing this function as it doesn't look like it's used. + * + * @param Request $request + * @return Response + */ + private function akrr(Request $request): Response + { + $data = ['success' => true]; + + $startDate = $this->getStringParam($request, 'start_date'); + $endDate = $this->getStringParam($request, 'end_date'); + + $testData = [['x' => [1, 2, 3], 'y' => [5, 2, 1]]]; + + $data['response'] = $testData; + $data['count'] = count($testData); + + return $this->json($data); + } + +} diff --git a/src/Controller/InternalDashboard/LogController.php b/src/Controller/InternalDashboard/LogController.php new file mode 100644 index 0000000000..a2543c3668 --- /dev/null +++ b/src/Controller/InternalDashboard/LogController.php @@ -0,0 +1,184 @@ +get('operation'); + switch ($operation) { + case 'get_levels': + return $this->getLevels($request); + case 'get_summary': + return $this->getSummary($request); + case 'get_messages': + return $this->getMessages($request); + default: + throw new BadRequestHttpException(); + } + } + + /** + * + * @param Request $request + * @return Response + */ + #[Route('{prefix}/internal_dashboard/logs/levels', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getLevels(Request $request): Response + { + $levels = [ + ['id' => \CCR\Log::EMERG, 'name' => 'Emergency'], + ['id' => \CCR\Log::ALERT, 'name' => 'Alert'], + ['id' => \CCR\Log::CRIT, 'name' => 'Critical'], + ['id' => \CCR\Log::ERR, 'name' => 'Error'], + ['id' => \CCR\Log::WARNING, 'name' => 'Warning'], + ['id' => \CCR\Log::NOTICE, 'name' => 'Notice'], + ['id' => \CCR\Log::INFO, 'name' => 'Info'], + ['id' => \CCR\Log::DEBUG, 'name' => 'Debug'], + ]; + + return $this->json([ + 'success' => true, + 'response' => $levels, + 'count' => count($levels) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/logs/messages', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getMessages(Request $request): Response + { + $pdo = DB::factory('logger'); + + $sql = ' + SELECT id, logtime, ident, priority, message + FROM log_table + '; + + $clauses = array(); + $params = array(); + + $ident = $this->getStringParam($request, 'ident'); + + if (isset($ident)) { + $clauses[] = 'ident = ?'; + $params[] = $ident; + } + + $logLevels = $this->getStringParam($request, 'logLevels'); + if (isset($logLevels) && is_array($logLevels)) { + $clauses[] = sprintf( + 'priority IN (%s)', + implode(',', array_pad([], count($logLevels), '?')) + ); + $params = array_merge($params, $logLevels); + } + + $onlyMostRecent = $this->getBooleanParam($request, 'only_most_recent'); + if (isset($onlyMostRecent) && $onlyMostRecent) { + if (!isset($ident)) { + throw new Exception('"ident" required'); + } + + $summary = \Log\Summary::factory($ident); + + if (null !== ($startRowId = $summary->getProcessStartRowId())) { + $clauses[] = 'id >= ?'; + $params[] = $startRowId; + } + + if (null !== ($endRowId = $summary->getProcessEndRowId())) { + $clauses[] = 'id <= ?'; + $params[] = $endRowId; + } + } else { + $startDate = $this->getStringParam($request, 'start_date'); + if (isset($startDate)) { + $clauses[] = 'logtime >= ?'; + $params[] = $startDate . ' 00:00:00'; + } + + $endDate = $this->getStringParam($request, 'end_date'); + if (isset($endDate)) { + $clauses[] = 'logtime <= ?'; + $params[] = $endDate . ' 23:59:59'; + } + } + + if (count($clauses)) { + $sql .= ' WHERE ' . implode(' AND ', $clauses); + } + + $sql .= ' ORDER BY id DESC'; + + $start = $this->getIntParam($request, 'start'); + $limit = $this->getIntParam($request, 'limit'); + if (isset($start) && isset($limit)) { + $sql .= sprintf( + ' LIMIT %d, %d', + $start, + $limit + ); + } + + $returnData = [ + 'success' => true, + 'response' => $pdo->query($sql, $params), + ]; + + $sql = 'SELECT COUNT(*) AS count FROM log_table'; + + if (count($clauses)) { + $sql .= ' WHERE ' . implode(' AND ', $clauses); + } + + list($countRow) = $pdo->query($sql, $params); + + $returnData['count'] = $countRow['count']; + + return $this->json($returnData); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/logs/summary', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getSummary(Request $request): Response + { + $ident = $this->getStringParam($request, 'ident', true); + $summary = \Log\Summary::factory($ident); + return $this->json([ + 'success' => true, + 'response' => [$summary->getData()], + 'count' => 1 + ]); + } +} diff --git a/src/Controller/InternalDashboard/MailerController.php b/src/Controller/InternalDashboard/MailerController.php new file mode 100644 index 0000000000..b72258bf93 --- /dev/null +++ b/src/Controller/InternalDashboard/MailerController.php @@ -0,0 +1,114 @@ +denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + $operation = $this->getStringParam($request, 'operation', true); + + switch ($operation) { + case 'enum_target_addresses': + return $this->enumTargetAddresses($request); + case 'send_plain_mail': + return $this->sendPlainMail($request); + default: + throw new BadRequestHttpException('Unknown operation.'); + } + } + + /** + * This is a straight port of `internal_dashboard/controllers/mailer.php` w/ enum_target_addresses operation. + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function enumTargetAddresses(Request $request): Response + { + $groupFilter = $this->getStringParam($request, 'group_filter'); + if (is_null($groupFilter)) { + $groupFilter = $this->getIntParam($request, 'group_filter'); + if (is_null($groupFilter)) { + return $this->json(buildError("'group_filter' not specified.")); + } + } + + $aclFilter = $this->getStringParam($request, 'role_filter'); + if (is_null($aclFilter)) { + $aclFilter = $this->getIntParam($request, 'role_filter'); + if (is_null($aclFilter)) { + return $this->json(buildError("'role_filter' not specified.")); + } + } + + + list($query, $params) = \xd_dashboard\listUserEmailsByGroupAndAcl($groupFilter, $aclFilter); + + $db = DB::factory('database'); + $results = $db->query($query, $params); + + $addresses = array(); + + foreach ($results as $r) { + $addresses[] = $r['email_address']; + } + + sort($addresses); + + return $this->json([ + 'success' => true, + 'count' => count($addresses), + 'response' => $addresses + ]); + } + + /** + * This is just a straight port of `internal_dashboard/controllers/mailer.php` w/ operation send_plain_mail. + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function sendPlainMail(Request $request): Response + { + $title = \xd_utilities\getConfiguration('general', 'title'); + $contactPageRecipient = \xd_utilities\getConfiguration('general', 'contact_page_recipient'); + + $message = $this->getStringParam($request, 'message', true, null, '/.*/', false); + $subject = $this->getStringParam($request, 'subject', true); + $targetAddresses = $this->getStringParam($request, 'target_addresses'); + + MailWrapper::sendMail([ + 'body' => $message, + 'subject' => "[$title] " . $subject, + 'toAddress' => $contactPageRecipient, + 'toName' => 'Undisclosed Recipients', + 'fromAddress' => $contactPageRecipient, + 'fromName' => $title, + 'bcc' => $targetAddresses + ]); + + return $this->json([ + 'success' => true + ]); + } +} diff --git a/src/Controller/InternalDashboard/SABUserController.php b/src/Controller/InternalDashboard/SABUserController.php new file mode 100644 index 0000000000..f53e1d3d96 --- /dev/null +++ b/src/Controller/InternalDashboard/SABUserController.php @@ -0,0 +1,112 @@ +denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $operation = $this->getStringParam($request, 'operation', true); + switch ($operation) { + case 'enum_tg_users': + return $this->enumTgUsers($request); + case 'assign_assumed_person': + case 'get_mapping': + /* these operations are not currently used. */ + break; + } + return $this->json(['success' => false, 'message' => 'invalid operation specified']); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + private function enumTgUsers(Request $request): Response + { + $start = $this->getIntParam($request, 'start', true); + $limit = $this->getIntParam($request, 'limit'); + $searchMode = $this->getStringParam($request, 'search_mode', true, null, RESTRICTION_SEARCH_MODE); + $piOnly = $this->getStringParam($request, 'pi_only', true, null, RESTRICTION_YES_NO); + $usePiFilter = $piOnly === 'y'; + + $query = $this->getStringParam($request, 'query'); + $userManagement = $this->getStringParam($request, 'userManagement'); + + $user = $this->getXDUser($request->getSession()); + + $universityId = null; + if ($user->hasAcl(ROLE_ID_CAMPUS_CHAMPION) && !isset($userManagement)) { + $universityId = Acls::getDescriptorParamValue($user, ROLE_ID_CAMPUS_CHAMPION, 'provider'); + } + + $searchMethod = null; + if ($searchMode === 'formal_name') { + $searchMethod = FORMAL_NAME_SEARCH; + } elseif ($searchMode === 'username') { + $searchMethod = USERNAME_SEARCH; + } + $xdw = new XDWarehouse(); + list($userCount, $users) = $xdw->enumerateGridUsers( + $searchMethod, + $start, + $limit, + $query, + $usePiFilter, + $universityId + ); + + $entry_id = 0; + + $userEntries = []; + foreach ($users as $currentUser) { + $entry_id++; + + if ($searchMethod == FORMAL_NAME_SEARCH) { + $personName = $currentUser['long_name']; + $personID = $currentUser['id']; + } elseif ($searchMethod == USERNAME_SEARCH) { + $personName = $currentUser['absusername']; + + // Append the absusername to the id so that each entry is guaranteed + // to have a unique identifier (needed for dependent ExtJS combobox + // (TGUserDropDown.js) to work properly regarding selections). + $personID = $currentUser['id'] . ';' . $currentUser['absusername']; + } + + $userEntries[] = [ + 'id' => $entry_id, + 'person_id' => $personID, + 'person_name' => $personName + ]; + } + + $data = [ + 'success' => true, + 'status' => 'success', + 'message' => 'success', + 'total_user_count' => $userCount, + 'users' => $userEntries, + ]; + return $this->json($data); + } +} diff --git a/src/Controller/InternalDashboard/SummaryController.php b/src/Controller/InternalDashboard/SummaryController.php new file mode 100644 index 0000000000..249a7cd50c --- /dev/null +++ b/src/Controller/InternalDashboard/SummaryController.php @@ -0,0 +1,307 @@ +getCharts($request); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/internal_dashboard/controllers/summary.php')] + public function index(Request $request): Response + { + $operation = $this->getStringParam($request, 'operation', true); + + switch ($operation) { + case 'get_config': + return $this->getConfig($request); + case 'get_portlets': + return $this->getPortlets($request); + default: + throw new NotFoundHttpException('Unknown Operation Provided'); + } + } + + /** + * @throws Exception + */ + #[Route('{prefix}/summary/configs', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getConfig(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $config = XdmodConfiguration::assocArrayFactory( + 'internal_dashboard.json', + CONFIG_DIR + ); + + $summaries = []; + + foreach ($config['summary'] as $summary) { + + // Add an empty config if none is found. + if (!isset($summary['config'])) { + $summary['config'] = []; + } + + // Add log config. + if ($summary['class'] === 'XDMoD.Log.TabPanel') { + $logList = []; + + foreach ($config['logs'] as $log) { + $logSummary = Summary::factory($log['ident']); + + if ($logSummary->getProcessStartRowId() === null) { + continue; + } + + $logList[] = [ + 'id' => $log['ident'] . '-log-panel', + 'ident' => $log['ident'], + 'title' => $log['title'], + ]; + } + + $summary['config']['logConfigList'] = $logList; + } + + $summaries[] = $summary; + } + + return $this->json([ + 'success' => true, + 'response' => $summaries, + 'count' => count($summaries) + ]); + } + + /** + * @throws Exception + */ + #[Route('{prefix}/summary/portlets', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getPortlets(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $config = XdmodConfiguration::assocArrayFactory( + 'internal_dashboard.json', + CONFIG_DIR + ); + + $portlets = []; + + foreach ($config['portlets'] as $portlet) { + + // Add an empty config if none is found. + if (!isset($portlet['config'])) { + $portlet['config'] = []; + } + + $portlets[] = $portlet; + } + + // Add log portlets. + foreach ($config['logs'] as $log) { + $logSummary = Summary::factory($log['ident'], true); + + if ($logSummary->getProcessStartRowId() === null) { + continue; + } + + $portlets[] = [ + 'class' => 'XDMoD.Log.SummaryPortlet', + 'config' => [ + 'ident' => $log['ident'], + 'title' => $log['title'], + 'linkPath' => [ + 'log-tab-panel', + $log['ident'] . '-log-panel', + ], + ], + ]; + } + + return $this->json([ + 'success' => true, + 'response' => $portlets, + 'count' => count($portlets) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/summary/charts', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getCharts(Request $request): Response + { + $user = $this->getUser(); + if (null === $user) { + $user = XDUser::getPublicUser(); + } else { + $user = XDUser::getUserByUserName($user->getUserIdentifier()); + } + + $debugLevel = abs($this->getIntParam($request, 'debug_level', false, 0)); + $startDate = $this->getStringParam($request, 'start_date', true); + $endDate = $this->getStringParam($request, 'end_date', true); + $aggregationUnit = lcfirst($this->getStringParam($request, 'aggregation_unit', false, 'auto')); + $rawFilters = $this->getStringParam($request, 'filters'); + $publicUser = $this->getBooleanParam($request, 'public_user'); + + $rawParameters = []; + if (isset($rawFilters)) { + $filters = json_decode($rawFilters); + foreach ($filters->data as $filter) { + $key = sprintf('%s_filter', $filter->dimension_id); + $valueId = $filter->value_id; + if (!isset($rawParameters[$key])) { + $rawParameters[$key] = $valueId; + } else { + $rawParameters[$key] .= ',' . $valueId; + } + } + } + + $enabledRealms = Realms::getEnabledRealms(); + if (in_array('Jobs', $enabledRealms)) { + $query_descripter = new \User\Elements\QueryDescripter('Jobs', 'none'); + + // This try/catch block is intended to replace the "Base table or + // view not found: 1146 Table 'modw_aggregates.jobfact_by_day' + // doesn't exist" error message with something more informative for + // Open XDMoD users. + + try { + $query = new \DataWarehouse\Query\AggregateQuery( + 'Jobs', + $aggregationUnit, + $startDate, + $endDate, + 'none', + 'all', + $query_descripter->pullQueryParameters($rawParameters) + ); + + // this is used later on down the function. + $result = $query->execute(); + } catch (PDOException $e) { + if ($e->getCode() === '42S02' && strpos($e->getMessage(), 'modw_aggregates.jobfact_by_') !== false) { + $msg = 'Aggregate table not found, have you ingested your data?'; + throw new Exception($msg); + } else { + throw $e; + } + } + } + + $mostPrivilegedAcl = Acls::getMostPrivilegedAcl($user); + + $rolesConfig = \Configuration\XdmodConfiguration::assocArrayFactory('roles.json', CONFIG_DIR); + $roles = $rolesConfig['roles']; + + $mostPrivilegedAclName = $mostPrivilegedAcl->getName(); + $mostPrivilegedAclSummaryCharts = $roles['default']['summary_charts']; + + if (isset($roles[$mostPrivilegedAclName]['summary_charts'])) { + $mostPrivilegedAclSummaryCharts = $roles[$mostPrivilegedAclName]['summary_charts']; + } + + $summaryCharts = []; + foreach ($mostPrivilegedAclSummaryCharts as $chart) { + $realm = $chart['data_series']['data'][0]['realm']; + if (!in_array($realm, $enabledRealms)) { + continue; + } + $chart['preset'] = true; + + $summaryCharts[] = json_encode($chart); + } + + if (!isset($publicUser) || !$publicUser) { + $queryStore = new \UserStorage($user, 'queries_store'); + $queries = $queryStore->get(); + + if ($queries != NULL) { + foreach ($queries as $i => $query) { + if (isset($query['config'])) { + + $queryConfig = json_decode($query['config']); + + $name = isset($query['name']) ? $query['name'] : null; + + if (isset($name)) { + if (preg_match('/summary_(?P\S+)/', $query['name'], $matches) > 0) { + $queryConfig->summary_index = $matches['index']; + } else { + $queryConfig->summary_index = $query['name']; + } + } + + if (property_exists($queryConfig, 'summary_index') + && isset($queryConfig->summary_index) + && isset($queryConfig->featured) + && $queryConfig->featured + ) { + if (isset($summaryCharts[$queryConfig->summary_index])) { + $queryConfig->preset = true; + } + $summaryCharts[$queryConfig->summary_index] = json_encode($queryConfig); + } + } + } + } + } + + foreach ($summaryCharts as $i => $summaryChart) { + $summaryChartObject = json_decode($summaryChart); + $summaryChartObject->index = $i; + $summaryCharts[$i] = json_encode($summaryChartObject); + } + ksort($summaryCharts, SORT_STRING); + + $result['charts'] = json_encode(array_values($summaryCharts)); + + return $this->json([ + 'totalCount' => 1, + 'success' => true, + 'message' => '', + 'data' => [$result] + ]); + } +} diff --git a/src/Controller/InternalDashboard/UserAdminController.php b/src/Controller/InternalDashboard/UserAdminController.php new file mode 100644 index 0000000000..31335aa9ca --- /dev/null +++ b/src/Controller/InternalDashboard/UserAdminController.php @@ -0,0 +1,981 @@ +denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $operation = $this->getStringParam($request, 'operation', true); + switch ($operation) { + case 'create_user': + return $this->createUser($request); + case 'delete_user': + return $this->deleteUser($request); + case 'empty_report_image_cache': + return $this->emptyReportImageCache($request); + case 'enum_institutions': + return $this->enumInstitutions($request); + case 'enum_exception_email_addresses': + return $this->enumExceptionEmailAddresses($request); + case 'enum_resource_providers': + return $this->enumResourceProviders($request); + case 'enum_user_types': + return $this->enumUserTypes($request); + case 'enum_roles': + return $this->enumRoles($request); + case 'get_user_details': + $userId = $this->getStringParam($request, 'uid', true, null, RESTRICTION_UID); + return $this->getUserDetails($request, $userId); + case 'list_users': + return $this->listUsers($request); + case 'pass_reset': + return $this->passwordReset($request); + case 'search_users': + return $this->searchForUsers($request); + case 'update_user': + return $this->updateUser($request); + } + throw new BadRequestHttpException('invalid operation specified'); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function listUsers(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + $xda = new XDAdmin(); + + $group = $this->getIntParam($request, 'group'); + $userListing = $xda->getUserListing($group); + + $users = []; + foreach ($userListing as $currentUser) { + + $userData = explode(';', $currentUser['username']); + if ($userData[0] !== 'Public User') { + $userEntry = [ + 'id' => $currentUser['id'], + 'username' => $userData[0], + 'first_name' => $currentUser['first_name'], + 'last_name' => $currentUser['last_name'], + 'account_is_active' => $currentUser['account_is_active'], + 'last_logged_in' => $this->parseMicrotime($currentUser['last_logged_in']) + ]; + + $users[] = $userEntry; + } + } + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'users' => $users + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/metadata', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getUserMetadata(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $pdo = DB::factory('database'); + + $userTypes = $pdo->query('SELECT id, type, color FROM moddb.UserTypes'); + $acls = $pdo->query("SELECT display AS description, acl_id AS role_id FROM moddb.acls WHERE name != 'pub' ORDER BY description"); + + return $this->json([ + 'success' => true, + 'user_types' => $userTypes, + 'user_roles' => $acls + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/create', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function createUser(Request $request): Response + { + $this->logger->warning('[start] Creating User'); + + try { + $userName = $this->getStringParam($request, 'username', true, null, RESTRICTION_USERNAME); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'username' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'username'."), 400); + } + } + + try { + $firstName = $this->getStringParam($request, 'first_name', true, null, RESTRICTION_FIRST_NAME); + }catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'first_name' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'first_name'."), 400); + } + } + + try { + $lastName = $this->getStringParam($request, 'last_name', true, null, RESTRICTION_LAST_NAME); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'last_name' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'last_name'."), 400); + } + } + + try { + $userType = intval($this->getStringParam($request, 'user_type', true, null, RESTRICTION_GROUP)); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'user_type' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'user_type'."), 400); + } + } + + try { + $institution = intval($this->getStringParam($request, 'institution', true, null, RESTRICTION_INSTITUTION)); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'institution' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'institution'."), 400); + } + } + + + try { + $personAssignment = intval($this->getStringParam($request, 'assignment', true, null, RESTRICTION_ASSIGNMENT)); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'assignment' not specified."), 400); + } else { + return $this->json(buildError("Invalid value specified for 'assignment'."), 400); + } + } + + try { + $emailAddress = $this->getEmailParam($request, 'email_address', true); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("'email_address' not specified."), 400); + } else { + return $this->json(buildError("Failed to assert 'email_address'."), 400); + } + } + + try { + $acls = json_decode($this->getStringParam($request, 'acls', true), true); + } catch (BadRequestHttpException $e) { + $message = $e->getMessage(); + if (str_contains($message, 'is a required parameter')) { + return $this->json(buildError("Acl information is required"), 400); + } else { + return $this->json(buildError("Invalid value specified for 'acls'."), 400); + } + } + + $sticky = $this->getBooleanParam($request, 'sticky', false, false); + + // Ensure that we have at least on acl for the new user. + if (empty($acls)) { + return $this->json(buildError('Acl information is required'), 400); + } + // Checking for an acl set that only contains feature acls. + // Feature acls are acls that only provide access to an XDMoD feature and + // are not used for data access. + if (!$this->hasDataAcls($acls)) { + return $this->json(buildError('Please include a non-feature acl ( i.e. User, PI etc. )'), 400); + } + + $tempPassword = $this->generateTempPassword(); + + $newUser = new \XDUser( + $userName, + $tempPassword, + $emailAddress, + $firstName, + '', + $lastName, + array_keys($acls), + ROLE_ID_USER, + $institution, + $personAssignment, + [], + $sticky + ); + $newUser->setUserType($userType); + $newUser->saveUser(); + + foreach ($acls as $acl => $centers) { + // Now that the user has been updated, We need to check if they have been assigned any + // 'center' acls. If they have and if an 'institution' has been provided ( it should have + // been ) then we need to call `setOrganizations` so that the user_acl_group_by_parameters + // table is updated accordingly. + if (in_array($acl, ['cd', 'cs'])) { + $newUser->setOrganizations( + [ + $institution => [ + 'primary' => 1, + 'active' => 1 + ] + ], + $acl + ); + } + } + + // 'institution' now corresponds to a Users organization and will always be present, not only + // when a user has been assigned the campus champion acl. This means we need to update the logic + // that gates the `setInstitution` function call to include a check if the user has been + // assigned the Campus Champion acl. + if (in_array(ROLE_ID_CAMPUS_CHAMPION, array_keys($acls))) { + $newUser->setInstitution($institution); + } + + list($subject, $emailBody) = $this->generateNewUserEmail($newUser); + MailWrapper::sendMail([ + 'body' => $emailBody, + 'subject' => $subject, + 'toAddress' => $emailAddress + ]); + $this->logger->warning('[done] Creating User'); + return $this->json([ + 'success' => true, + 'user_type' => $userType, + 'message' => sprintf('User %s created successfully', $userName) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/update', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function updateUser(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $currentUser = $this->authorize($request, ['mgr']); + + $userId = intval($this->getStringParam($request, 'uid', true, null, RESTRICTION_UID)); + $userToUpdate = \XDUser::getUserByID($userId); + if (!isset($userToUpdate)) { + return $this->json([ + 'success' => false, + 'status' => 'user_does_not_exist' + ]); + } + + + $potentialParameters = [ + 'first_name' => $this->getStringParam($request, 'first_name', false, null, RESTRICTION_FIRST_NAME), + 'last_name' => $this->getStringParam($request, 'last_name', false, null, RESTRICTION_LAST_NAME), + 'user_type' => $this->getIntParam($request, 'user_type'), + 'institution' => $this->getIntParam($request, 'institution'), + 'person' => $this->getIntParam($request, 'assigned_user'), + 'is_active' => $this->getBooleanParam($request, 'is_active') + ]; + + $qualifyingParameters = array_filter( + $potentialParameters, + function ($value) { + return isset($value); + } + ); + + $acls = null; + $aclsRaw = $this->getStringParam($request, 'acls'); + if (isset($aclsRaw)) { + $acls = json_decode($aclsRaw, true); + if (count($acls) < 1) { + return $this->json(buildError('Acl information is required')); + } + } + + // If we're updating ourselves we need to ensure a few things... + if ($currentUser->getUserID() === $userToUpdate->getUserID()) { + + // Make sure that we're not trying to disable ourselves. + if (isset($qualifyingParameters['is_active']) && !$qualifyingParameters['is_active']) { + return $this->json([ + 'success' => false, + 'status' => 'You are not allowed to disable your own account.' + ]); + } + + // Check to make sure that we're not trying to revoke our own manager access. + if (isset($acls)) { + if (!array_key_exists(ROLE_ID_MANAGER, $acls)) { + return $this->json([ + 'success' => false, + 'status' => 'You are not allowed to revoke manager access from yourself.' + ]); + } + } + } + + if (isset($qualifyingParameters['first_name'])) { + $userToUpdate->setFirstName($qualifyingParameters['first_name']); + } + + if (isset($qualifyingParameters['last_name'])) { + $userToUpdate->setLastName($qualifyingParameters['last_name']); + } + + $emailAddress = $this->getEmailParam($request, 'email_address', true); + + // Make sure that if we're anything other than an SSO User that we cannot remove our email address. + if ($userToUpdate->getUserType() !== SSO_USER_TYPE && strlen($emailAddress) < 1) { + return $this->json([ + 'success' => true, + 'status' => 'This XDMoD user must have an e-mail address set.' + ]); + } + $userToUpdate->setEmailAddress($emailAddress); + + if (isset($qualifyingParameters['person'])) { + $userToUpdate->setPersonID($qualifyingParameters['person']); + } + + if (isset($qualifyingParameters['is_active'])) { + $userToUpdate->setAccountStatus($qualifyingParameters['is_active']); + } + + // If we're trying to update the user's type, only non-SSO users can do so. + if (isset($qualifyingParameters['user_type'])) { + if ($userToUpdate->getUserType() !== SSO_USER_TYPE) { + $userToUpdate->setUserType($qualifyingParameters['user_type']); + } + } + + $sticky = $this->getBooleanParam($request, 'sticky'); + if (isset($sticky)) { + $userToUpdate->setSticky($sticky); + } + + $originalAcls = $userToUpdate->getAcls(true); + if (isset($acls)) { + if (!$this->hasDataAcls($acls)) { + return $this->json(buildError('Please include a non-feature acl ( i.e. User, PI etc. )')); + } + // first clear the updated user's acls + $userToUpdate->setAcls([]); + foreach ($acls as $aclName => $centers) { + $acl = Acls::getAclByName($aclName); + $userToUpdate->addAcl($acl); + } + } else { + return $this->json(buildError('Acl information is required.')); + } + + if (isset($qualifyingParameters['institution'])) { + $userToUpdate->setOrganizationID($qualifyingParameters['institution']); + $oldCampusChampion = in_array(ROLE_ID_CAMPUS_CHAMPION, $originalAcls); + $newCampusChampion = in_array(ROLE_ID_CAMPUS_CHAMPION, array_keys($acls)); + + if ($newCampusChampion && !$oldCampusChampion) { + $userToUpdate->setInstitution($qualifyingParameters['institution']); + } elseif (!$newCampusChampion && $oldCampusChampion) { + $userToUpdate->disassociateWithInstitution(); + } + } + + // We've updated everything that we need to, now we can save. + try { + $userToUpdate->saveUser(); + + // Now that the user has been saved, clear their organizations + $userToUpdate->setOrganizations([], ROLE_ID_CENTER_DIRECTOR); + $userToUpdate->setOrganizations([], ROLE_ID_CENTER_STAFF); + + // and add the new ones. + foreach ($acls as $aclName => $centers) { + if (in_array($aclName, ['cd', 'cs']) && isset($qualifyingParameters['institution'])) { + $userToUpdate->setOrganizations( + [ + $qualifyingParameters['institution'] => [ + 'primary' => 1, + 'active' => 1 + ] + ], + $aclName + ); + } + } + } catch (Exception $exception) { + return $this->json([ + 'success' => false, + 'status' => $exception->getMessage() + ]); + } + + $userName = $userToUpdate->getUsername(); + return $this->json([ + 'success' => true, + 'status' => sprintf( + '%sUser %s updated successfully', + $userToUpdate->isSSOUser() ? 'Single Sine On' : '', + $userName + ), + 'username' => $userName, + 'user_type' => $userToUpdate->getUserType() + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/search', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function searchForUsers(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $searchCriteria = json_decode($this->getStringParam($request, 'search_crit', true), true); + + $datawarehouse = new \XDWarehouse(); + $users = $datawarehouse->searchUsers($searchCriteria); + + return $this->json([ + 'success' => true, + 'data' => $users, + 'total' => count($users) + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/password', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function passwordReset(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $userId = $this->getStringParam($request, 'uid', true, null, RESTRICTION_UID); + + $userToContact = XDUser::getUserByID($userId); + if ($userToContact === null) { + return $this->json([ + 'success' => false, + 'status' => 'user_does_not_exist' + ]); + } + + $this->sendPasswordResetEmail($userToContact); + + $message = sprintf('Password reset e-mail sent to user %s', $userToContact->getUsername()); + return $this->json([ + 'success' => true, + 'message' => $message, + 'status' => $message + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/institutions', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumInstitutions(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $query = $this->getStringParam($request, 'query'); + $xdAdmin = new \XDAdmin(); + + $institutions = $xdAdmin->enumerateInstitutions($query); + + // If there are no organizations for the provided query, then by default retrieve / return the full list of + // organizations. + $institutionCount = count($institutions); + if (count($institutions) === 0) { + $institutions = $xdAdmin->enumerateInstitutions(); + } + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'total_institution_count' => $institutionCount, + 'institutions' => $institutions + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/roles', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumRoles(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $xdAdmin = new \XDAdmin(); + $roles = $xdAdmin->enumerateAcls(); + + $roleEntries = []; + foreach ($roles as $currentRole) { + // requiresCenter can only be true iff the current install supports + // multiple service providers. + if ($currentRole['name'] !== 'pub') { + $roleEntries[] = [ + 'acl' => $currentRole['display'], + 'acl_id' => $currentRole['name'], + 'include' => false, + 'primary' => false, + 'displays_center' => false, + 'requires_center' => false + ]; + } + } + return $this->json([ + 'success' => true, + 'status' => 'success', + 'acls' => $roleEntries + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/types', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumUserTypes(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $xdAdmin = new \XDAdmin(); + $userTypes = $xdAdmin->enumerateUserTypes(); + + $userTypeEntries = []; + foreach ($userTypes as $type) { + $userTypeEntries[] = [ + 'id' => $type['id'], + 'type' => $type['type'], + ]; + } + $data = [ + 'success' => true, + 'status' => 'success', + 'user_types' => $userTypeEntries + ]; + return $this->json($data); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/providers', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumResourceProviders(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $xdAdmin = new \XDAdmin(); + $resourceProviders = $xdAdmin->enumerateResourceProviders(); + + $providers = []; + foreach ($resourceProviders as $provider) { + $providers[] = [ + 'id' => $provider['id'], + 'organization' => $provider['organization'] . ' (' . $provider['name'] . ')', + 'include' => false + ]; + } + + return $this->json([ + 'status' => 'success', + 'success' => true, + 'providers' => $providers + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/emails/exceptions', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumExceptionEmailAddresses(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $xdAdmin = new \XDAdmin(); + $emailAddresses = $xdAdmin->enumerateExceptionEmailAddresses(); + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'email_addresses' => $emailAddresses + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/reports/images/cache', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function emptyReportImageCache(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $userId = $this->getStringParam($request, 'uid', true, null, RESTRICTION_UID); + $targetUser = XDUser::getUserByID($userId); + if (!isset($targetUser)) { + return $this->json(buildError('user_does_not_exist')); + } + + $chart_pool = new \XDChartPool($targetUser); + $chart_pool->emptyCache(); + + $report_manager = new \XDReportManager($targetUser); + $report_manager->emptyCache(); + $report_manager->flushReportImageCache(); + + return $this->json([ + 'success' => true, + 'message' => sprintf( + 'The report image cache for user %s has been emptied', + $targetUser->getUsername() + ) + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/delete', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function deleteUser(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $requestingUser = $this->authorize($request, ['mgr']); + + $userId = $this->getStringParam($request, 'uid', true, null, RESTRICTION_UID); + $targetUser = XDUser::getUserByID($userId); + if (!isset($targetUser)) { + return $this->json(buildError('user_does_not_exist')); + } + + if ($requestingUser->getUsername() === $targetUser->getUsername()) { + return $this->json(buildError('You are not allowed to delete your own account.')); + } + + // Remove all entries in this user's profile + $profile = $targetUser->getProfile(); + $profile->clear(); + + $statusPrefix = $targetUser->isSSOUser() ? 'Single Sign On ' : ''; + $displayUsername = $targetUser->getUsername(); + + $targetUser->removeUser(); + + return $this->json([ + 'success' => true, + 'message' => sprintf( + '%sUser %s deleted from the portal', + $statusPrefix, + $displayUsername + ) + ]); + } + + /** + * @param Request $request + * @param int|string $userId + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/{userId}', requirements: ['userId' => '\d+', 'prefix' => '.*'], methods: ['POST'])] + public function getUserDetails(Request $request, $userId): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->authorize($request, ['mgr']); + + $selected_user = XDUser::getUserByID($userId); + + if ($selected_user === NULL) { + return $this->json(buildError('user_does_not_exist')); + } + + // ----------------------------- + + $userDetails = []; + + $userDetails['username'] = $selected_user->getUsername(); + $userDetails['formal_name'] = $selected_user->getFormalName(); + + $userDetails['time_created'] = $selected_user->getCreationTimestamp(); + $userDetails['time_updated'] = $selected_user->getUpdateTimestamp(); + $userDetails['time_last_logged_in'] = $selected_user->getLastLoginTimestamp(); + + $userDetails['email_address'] = $selected_user->getEmailAddress(); + + if ($userDetails['email_address'] == NO_EMAIL_ADDRESS_SET) { + $userDetails['email_address'] = ''; + } + + $userDetails['assigned_user_id'] = $selected_user->getPersonID(TRUE); + + //$userDetails['provider'] = $selected_user->getOrganization(); + $userDetails['institution'] = $selected_user->getOrganizationID(); + + $userDetails['user_type'] = $selected_user->getUserType(); + + $obj_warehouse = new XDWarehouse(); + + $userDetails['institution_name'] = $obj_warehouse->resolveInstitutionName($userDetails['institution']); + + $userDetails['assigned_user_name'] = $obj_warehouse->resolveName($userDetails['assigned_user_id']); + + if ($userDetails['assigned_user_name'] == NO_MAPPING) { + $userDetails['assigned_user_name'] = ''; + } + + $userDetails['is_active'] = $selected_user->getAccountStatus() ? 'active' : 'disabled'; + $userDetails['sticky'] = $selected_user->isSticky(); + + $acls = Acls::listUserAcls($selected_user); + $populatedAcls = array_reduce( + $acls, + function ($carry, $item) use ($selected_user) { + $aclName = $item['name']; + $aclCenters = []; + if ($item['requires_center'] === true) { + $aclCenters = Acls::getDescriptorParamValues( + $selected_user, + $aclName, + 'provider' + ); + } + + $carry[$aclName] = $aclCenters; + + return $carry; + }, + [] + ); + + $userDetails['acls'] = $populatedAcls; + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'user_information' => $userDetails + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/internal_dashboard/users/existing', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function enumExistingUsers(Request $request): Response + { + $group_filter = $this->getStringParam($request, 'group_filter'); + $role_filter = $this->getStringParam($request, 'role_filter'); + $context_filter = $this->getStringParam($request, 'context_filter', false, ''); + + $results = Users::getUsers($group_filter, $role_filter, $context_filter); + $filtered = []; + foreach ($results as $user) { + if ($user['username'] !== 'Public User') { + $filtered[] = $user; + } + } + + return $this->json([ + 'success' => true, + 'count' => count($filtered), + 'response' => $filtered + ]); + } + + /** + * @throws RuntimeError + * @throws SyntaxError + * @throws LoaderError + * @throws Exception + */ + private function sendPasswordResetEmail(XDUser $user): void + { + $rid = $user->generateRID(); + + $subject = sprintf('%s: Password Reset', \xd_utilities\getConfiguration('general', 'title')); + $body = $this->twig->render( + 'emails/password_reset.html.twig', + [ + 'first_name' => $user->getFirstName(), + 'username' => $user->getUsername(), + 'reset_link' => sprintf( + '%spassword_reset.php?rid=%s', + \xd_utilities\getConfigurationUrlBase('general', 'site_address'), + $rid + ), + 'expiration' => strftime('%c %Z', explode('|', $rid)[1]), + 'maintainer_signature' => MailWrapper::getMaintainerSignature(), + ] + ); + + MailWrapper::sendMail([ + 'toAddress' => $user->getEmailAddress(), + 'subject' => $subject, + 'body' => $body + ]); + } + + /** + * @return string + */ + private function generateTempPassword(): string + { + $password_chars = 'abcdefghijklmnopqrstuvwxyz!@#$%-_=+ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; + $max_password_chars_index = strlen($password_chars) - 1; + $password = ''; + for ($i = 0; $i < CHARLIM_PASSWORD; $i++) { + $password .= $password_chars[mt_rand(0, $max_password_chars_index)]; + } + return $password; + } + + /** + * @param array $acls + * @return bool + */ + private function hasDataAcls(array $acls): bool + { + $aclNames = []; + $featureAcls = Acls::getAclsByTypeName('feature'); + $tabAcls = Acls::getAclsByTypeName('tab'); + $uiOnlyAcls = array_merge($featureAcls, $tabAcls); + if (count($uiOnlyAcls) > 0) { + $aclNames = array_reduce( + $uiOnlyAcls, + function ($carry, Acl $item) { + $carry [] = $item->getName(); + return $carry; + }, + [] + ); + } + $diff = array_diff(array_keys($acls), $aclNames); + return !empty($diff); + } + + /** + * @return array in the form [$subject, $emailBody] + * @throws SyntaxError + * @throws RuntimeError + * @throws LoaderError + * @throws Exception + */ + private function generateNewUserEmail(\XDUser $newUser): array + { + $pageTitle = \xd_utilities\getConfiguration('general', 'title'); + $siteAddress = \xd_utilities\getConfigurationUrlBase('general', 'site_address'); + $userName = $newUser->getUsername(); + $rid = $newUser->generateRID(); + + return [ + sprintf('%s: Account Created', $pageTitle), + $this->twig->render( + 'emails/new_user.html.twig', + [ + 'page_title' => $pageTitle, + 'site_address' => $siteAddress, + 'username' => $userName, + 'rid' => $rid + ] + ) + ]; + } + + private function parseMicrotime($mtime) + { + + $time_frags = explode('.', $mtime); + return $time_frags[0] * 1000; + + } +} diff --git a/src/Controller/InternalDashboard/UserVisitController.php b/src/Controller/InternalDashboard/UserVisitController.php new file mode 100644 index 0000000000..3876bf28bf --- /dev/null +++ b/src/Controller/InternalDashboard/UserVisitController.php @@ -0,0 +1,83 @@ + '.*'],)] +class UserVisitController extends BaseController +{ + public static $columns = [ + "Last Name", + "First Name", + "E-Mail", + "Roles", + "Visit Frequency", + "User Type", + "Date", + "Count" + ]; + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('', methods: ['POST'])] + public function getUserVisits(Request $request): Response + { + list($data,) = $this->getUserVisitData($request); + return $this->json([ + 'success' => true, + 'stats' => $data + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/export', methods: ['POST'])] + public function exportUserVisits(Request $request): Response + { + list($data, list($timeframe,)) = $this->getUserVisitData($request); + + $data = array_map(function($row) { + return implode(',', $row); + }, $data); + array_unshift($data, implode(',', self::$columns)); + + $content = sprintf("%s\n", implode("\n", $data)); + $this->logger->debug(sprintf("Export User Visits: Content: %s", $content)); + return new Response($content, 200, [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => sprintf('attachment;filename="xdmod_visitation_stats_by_%s.csv"', $timeframe) + ]); + } + + /** + * @return array in the form [userVisits, [timeframe, userTypes]] + * @throws Exception + */ + private function getUserVisitData(Request $request): array + { + $timeframe = $this->getStringParam($request, 'timeframe', true); + $userTypes = explode(',', $this->getStringParam($request, 'user_types', true)); + if (strtolower($timeframe) !== 'year' && strtolower($timeframe) !== 'month') { + throw new BadRequestHttpException('Invalid value specified for the timeframe'); + } + $results = \XDStatistics::getUserVisitStats($timeframe, $userTypes); + return [$results, [$timeframe, $userTypes]]; + } +} diff --git a/src/Controller/MailController.php b/src/Controller/MailController.php new file mode 100644 index 0000000000..b9b414bde8 --- /dev/null +++ b/src/Controller/MailController.php @@ -0,0 +1,225 @@ +getUserFromRequest($request); + $operation = $this->getStringParam($request, 'operation', true); + + switch ($operation) { + case 'contact': + return $this->contact($request, $user); + case 'sign_up': + return $this->signUp($request); + default: + throw new BadRequestHttpException('invalid operation specified'); + } + } + + /** + * Takes the place of the old html/controllers/mailer/contact.php + * + * @param Request $request + * @param ?XDUser $user + * @return Response + */ + private function contact(Request $request, ?XDUser $user): Response + { + if (!isset($user)) { + $user = XDUser::getPublicUser(); + } + + $name = $this->getStringParam($request, 'name', true, null, RESTRICTION_FIRST_NAME); + // This variable is overwritten before it is used. I'm leaving it here for now but it should be removed after + // the rest stack migration is complete. + $message = $this->getStringParam($request, 'message', true, null, RESTRICTION_NON_EMPTY); + $username = $this->getStringParam($request, 'username', true, null, RESTRICTION_NON_EMPTY); + $token = $this->getStringParam($request, 'token', true, null, RESTRICTION_NON_EMPTY); + $timestamp = $this->getStringParam($request, 'timestamp', true, null, RESTRICTION_NON_EMPTY); + $email = $this->getEmailParam($request, 'email', true); + $reason = $this->getStringParam($request, 'reason', false, 'contact'); + + $userInfo = $user->isPublicUser() ? 'Public Visitor' : "Username: $username"; + + $this->verifyCaptcha($request); + + switch ($reason) { + case 'wishlist': + $subject = '[WISHLIST] Feature request sent from a portal visitor'; + $message_type = 'feature request'; + break; + + default: + $subject = 'Message sent from a portal visitor'; + $message_type = 'message'; + break; + } + $timestamp = date('m/d/Y, g:i:s A', $timestamp); + $message = "Below is a $message_type from '$name' ($email):\n\n"; + $message .= $message; + $message .= "\n------------------------\n\nSession Tracking Data:\n\n "; + $message .= "$userInfo\n\n Token: $token\n Timestamp: $timestamp"; + + try { + //Original sender's e-mail must be in the 'fromAddress' field for the XDMoD Request Tracker to function + MailWrapper::sendMail(array( + 'body' => $message, + 'subject' => $subject, + 'toAddress' => \xd_utilities\getConfiguration('general', 'contact_page_recipient'), + 'fromAddress' => $_POST['email'], + 'fromName' => $_POST['name'] + ) + ); + } catch (Exception $e) { + return $this->json([ + 'success' => false, + 'message' => $message + ]); + } + + $message + = "Hello, $name\n\n" + . "This e-mail is to inform you that the XDMoD Portal Team has received your $message_type, and will\n" + . "be in touch with you as soon as possible.\n\n" + . MailWrapper::getMaintainerSignature(); + + try { + MailWrapper::sendMail(array( + 'body' => $message, + 'subject' => "Thank you for your $message_type.", + 'toAddress' => $_POST['email'] + ) + ); + } catch (Exception $e) { + return $this->json([ + 'success' => false, + 'message' => $message + ]); + } + return $this->json([ + 'success' => true + ]); + } + + /** + * Takes the place of the old html/controllers/mailer/sign_up.php + * @param Request $request + * @return Response + * @throws Exception if unable to contact the database. + */ + private function signUp(Request $request): Response + { + $firstName = $this->getStringParam($request, 'first_name', true, null, RESTRICTION_FIRST_NAME); + $lastName = $this->getStringParam($request, 'last_name', true, null, RESTRICTION_LAST_NAME); + $title = $this->getStringParam($request, 'title', true, null, RESTRICTION_NON_EMPTY); + $organization = $this->getStringParam($request, 'organization', true, null, RESTRICTION_NON_EMPTY); + $fieldOfScience = $this->getStringParam($request, 'field_of_science', true, null, RESTRICTION_NON_EMPTY); + $additionalInformation = $this->getStringParam($request, 'additional_information', true, null, RESTRICTION_NON_EMPTY); + $email = $this->getEmailParam($request, 'email', true); + + $this->verifyCaptcha($request); + + // Insert account request into database (so it appears in the internal + // dashboard under "XDMoD Account Requests"). + $pdo = DB::factory('database'); + + $pdo->execute( + " + INSERT INTO AccountRequests ( + first_name, + last_name, + organization, + title, + email_address, + field_of_science, + additional_information, + time_submitted, + status, + comments + ) VALUES ( + :first_name, + :last_name, + :organization, + :title, + :email_address, + :field_of_science, + :additional_information, + NOW(), + 'new', + '' + ) + ", + [ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'organization' => $organization, + 'title' => $title, + 'email_address' => $email, + 'field_of_science' => $fieldOfScience, + 'additional_information' => $additionalInformation + ] + ); + + // Create email. + + $time_requested = date('D, F j, Y \a\t g:i A'); + $organization = ORGANIZATION_NAME; + + $message = << $message, + 'subject' => '[' . \xd_utilities\getConfiguration('general', 'title') . '] A visitor has signed up', + 'toAddress' => \xd_utilities\getConfiguration('general', 'contact_page_recipient'), + 'fromAddress' => $_POST['email'], + 'fromName' => $_POST['last_name'] . ', ' . $_POST['first_name'] + ]); + $response['success'] = true; + } catch (Exception $e) { + $response['success'] = false; + $response['message'] = $e->getMessage(); + } + + return $this->json($response); + } +} diff --git a/src/Controller/MetricExplorerController.php b/src/Controller/MetricExplorerController.php new file mode 100644 index 0000000000..57c0cff39f --- /dev/null +++ b/src/Controller/MetricExplorerController.php @@ -0,0 +1,1017 @@ +getStringParam($request, 'operation', true); + + switch ($operation) { + case 'get_data': + return $this->getData($request); + case 'get_dimension': + return $this->getDimensionValues($request); + case 'get_dw_descripter': + return $this->getDwDescriptors($request); + case 'get_filters': + return $this->getFilters($request); + case 'get_rawdata': + return $this->getRawData($request); + } + + return $this->json([ + 'success' => false, + 'message' => 'Unknown Operation provided.' + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/metrics/explorer/queries', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getQueries(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $action = 'getQueries'; + $payload = [ + 'success' => false, + 'action' => $action + ]; + $statusCode = 401; + + try { + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if (isset($user) && $user instanceof XDUser) { + $queries = new \UserStorage($user, self::QUERIES_STORE); + $data = $queries->get(); + + foreach ($data as &$query) { + $this->removeRoleFromQuery($user, $query); + $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); + } + + $payload['data'] = $data; + $payload['success'] = true; + $statusCode = 200; + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } + } catch (BadRequestException|HttpException|Exception $exception) { + $payload['message'] = $exception->getMessage(); + $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } + + return $this->json($payload, $statusCode); + } + + /** + * + * @param Request $request + * @param string $queryId + * @return Response + */ + #[Route('{prefix}/metrics/explorer/queries/{queryId}', requirements: ["queryId"=>"\w+", 'prefix' => '.*'], methods: ['GET'])] + public function getQueryByid(Request $request, string $queryId): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $action = 'getQueryById'; + $payload = array( + 'success' => false, + 'action' => $action, + ); + $statusCode = 401; + + try { + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if (isset($user) && $user instanceof XDUser) { + $queries = new \UserStorage($user, self::QUERIES_STORE); + + $query = $queries->getById($queryId); + + if (isset($query)) { + $payload['data'] = $query; + $payload['data']['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); + $payload['success'] = true; + $statusCode = 200; + } else { + $payload['message'] = 'Unable to find the query identified by the provided id: ' . $queryId; + $statusCode = 404; + } + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } + } catch (BadRequestException|HttpException|Exception $exception) { + $payload['message'] = $exception->getMessage(); + $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } + + return $this->json($payload, $statusCode); + } + + /** + * + * @param Request $request + * @return Response + */ + #[Route('{prefix}/metrics/explorer/queries', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function createQuery(Request $request): Response + { + $action = 'creatQuery'; + $payload = array( + 'success' => false, + 'action' => $action, + ); + $statusCode = 401; + try { + $data = $request->get('data', null); + if ($data === null) { + throw new BadRequestHttpException('data is a required parameter.'); + } + if ($this->getUser() !== null) { + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if (isset($user) && $user instanceof XDUser) { + $queries = new \UserStorage($user, self::QUERIES_STORE); + if (!is_string($data)) { + throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); + } + $data = is_string($data) ? json_decode($data, true) : $data; + $success = $queries->insert($data) != null; + $payload['success'] = $success; + if ($success) { + $payload['success'] = true; + $payload['data'] = $data; + $statusCode = 200; + } else { + $payload['message'] = 'Error creating chart. User is over the chart limit.'; + $statusCode = 500; + } + } + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } + } catch (BadRequestException|HttpException|Exception $exception) { + $payload['message'] = $exception->getMessage(); + if (get_class($exception) === 'Exception') { + $statusCode = 500; + } elseif (method_exists($exception, 'getStatusCode')) { + $statusCode = $exception->getStatusCode(); + } + } + + return $this->json($payload, $statusCode); + } + + /** + * + * @param Request $request + * @param string $queryId + * @return Response + */ + #[Route('{prefix}/metrics/explorer/queries/{queryId}', requirements: ["queryId"=> "\w+", 'prefix' => '.*'], methods: ['PUT', "POST"])] + public function updateQueryById(Request $request, string $queryId): Response + { + $action = 'updateQuery'; + $payload = array( + 'success' => false, + 'action' => $action, + 'message' => 'success' + ); + $statusCode = 401; + + try { + if ($this->getUser() === null) { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } else { + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if (isset($user) && $user instanceof XDUser) { + $this->logger->error(sprintf("Updating Query for: %s",$user->getUsername())); + $queries = new \UserStorage($user, self::QUERIES_STORE); + + $query = $queries->getById($queryId); + if (isset($query)) { + + $data = $request->get('data'); + + if (isset($data)) { + if (!is_string($data)) { + throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); + } + $jsonData = json_decode($data, true); + $name = isset($jsonData['name']) ? $jsonData['name'] : null; + $config = isset($jsonData['config']) ? $jsonData['config'] : null; + $ts = isset($jsonData['ts']) ? $jsonData['ts'] : microtime(true); + } else { + $name = $this->getStringParam($request, 'name'); + $config = $this->getStringParam($request, 'config'); + $ts = $this->getDateTimeFromUnixParam($request, 'ts'); + } + + if (isset($name)) { + $query['name'] = $name; + } + + if (isset($config)) { + $query['config'] = $config; + } + if (isset($ts)) { + $query['ts'] = $ts; + } + + $queries->upsert($queryId, $query); + + // required for the UI to do it's thing. + $total = count($queries->get()); + + // make sure everything is in place for returning to the + // front end. + $payload['total'] = $total; + $payload['success'] = true; + $statusCode = 200; + } else { + $payload['message'] = 'There was no query found for the given id'; + $statusCode = 404; + } + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } + } + } catch (BadRequestException|HttpException|Exception $exception) { + $payload['message'] = $exception->getMessage(); + if (get_class($exception) === 'Exception') { + $statusCode = 500; + } elseif (method_exists($exception, 'getStatusCode')) { + $statusCode = $exception->getStatusCode(); + } + } + + return $this->json($payload, $statusCode); + } + + /** + * + * @param Request $request + * @param string $queryId + * @return Response + */ + #[Route('{prefix}/metrics/explorer/queries/{queryId}', requirements: ["queryId"=> "\w+", 'prefix' => '.*'], methods: ['DELETE'])] + public function deleteQueryById(Request $request, string $queryId): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $action = 'deleteQueryById'; + $payload = array( + 'success' => false, + 'action' => $action, + 'message' => 'success' + ); + $statusCode = 401; + + try { + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if (isset($user) and $user instanceof XDUser) { + $queries = new \UserStorage($user, self::QUERIES_STORE); + $query = $queries->getById($queryId); + + if (isset($query)) { + $before = count($queries->get()); + $after = $queries->delById($queryId); + $success = $before > $after; + $payload['success'] = $success; + $payload['message'] = $success ? $payload['message'] : 'There was an error removing the query identified by: ' . $queryId; + + $statusCode = $success ? 200 : 500; + } else { + $payload['message'] = 'There was no query found for the given id'; + $statusCode = 404; + } + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + } + } catch (BadRequestException|HttpException|Exception $exception) { + $payload['message'] = $exception->getMessage(); + $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } + + return $this->json($payload, $statusCode); + } + + + /** + * @param XDUser $user + * @param array $query + * @return void + * @throws Exception + */ + private function removeRoleFromQuery(XDUser $user, array &$query) + { + // If the query doesn't have a config, stop. + if (!array_key_exists('config', $query)) { + return; + } + + // If the query config doesn't have an active role, stop. + $queryConfig = json_decode($query['config']); + if (!property_exists($queryConfig, 'active_role')) { + return; + } + + // Remove the active role from the query config. + $activeRoleId = $queryConfig->active_role; + unset($queryConfig->active_role); + + // Check whether or not $activeRoleId is an acl name or acl display value. + // ( Old queries may utilize the `display` property). + $activeRole = Acls::getAclByName($activeRoleId); + if ($activeRole === null) { + $activeRole = Acls::getAclByDisplay($activeRoleId); + if ($activeRole !== null) { + $activeRoleId = $activeRole->getName(); + } + } + // Convert the active role into global filters. + MetricExplorer::convertActiveRoleToGlobalFilters($user, $activeRoleId, $queryConfig->global_filters); + + // Store the updated config in the query. + $query['config'] = json_encode($queryConfig); + } + + /** + * + * @param Request $request + * @return Response + * @throws SessionExpiredException if unable to successfully retrieve the currently logged in user. + * @throws Exception if there is a problem with the processing of the get_data function. + */ + #[Route('{prefix}/metrics/explorer/data', requirements: ['prefix' => '.*'], methods: ['POST', 'GET'])] + public function getData(Request $request): Response + { + $user = \xd_security\detectUser([XDUser::INTERNAL_USER, XDUser::PUBLIC_USER]); + + $m = new \DataWarehouse\Access\MetricExplorer($_REQUEST); + try { + $result = $m->get_data($user); + } catch (Exception $e) { + return $this->json( + [ + 'success' => false, + 'message' => $e->getMessage() + ], + 400 + ); + } + + + $format = $this->getStringParam($request, 'format'); + if ($format === 'png' + || $format === 'pdf' + || $format === 'svg' + || $format === 'png_inline' + || $format === 'svg_inline' + || $format === '_internal' + || $format === 'csv' + || $format === 'xml' + || $format === 'json') { + $response = new Response($result['results']); + } else { + $response = $this->json(json_decode($result['results'])); + } + + $response->headers->add($result['headers']); + + return $response; + } + + + /** + * + * @param Request $request + * @return Response + * @throws SessionExpiredException + * @throws AccessDeniedException + * @throws UnknownGroupByException + */ + #[Route('{prefix}/metrics/explorer/dimension/values', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getDimensionValues(Request $request): Response + { + try { + $user = $this->tokenHelper->authenticateToken($request); + + // If token authentication failed then fallback to the standard session based authentication method. + if ($user === null) { + $user = \xd_security\detectUser(array(\XDUser::PUBLIC_USER)); + } + } catch (Exception $e) { + return $this->json( + buildError(new Exception('Session Expired', 2)), + 401 + ); + } + $this->logger->warning('User retrieved ', [$user->getUserIdentifier()]); + + $dimensionId = $this->getStringParam($request, 'dimension_id', true); + $offset = $this->getStringParam($request ,'start'); + if (empty($offset)) { + $offset = 0; + } + $limit = $this->getIntParam($request, 'limit'); + $searchText = $this->getStringParam($request, 'search_text'); + + $selectedFilterIds = $this->getStringParam($request, 'selectedFilterIds', false, []); + if (!is_array($selectedFilterIds)) { + $selectedFilterIds = explode(',', $selectedFilterIds); + } + + $realms = $this->getStringParam($request, 'realm', false); + if ($realms !== null) { + $realms = preg_split('/,\s*/', trim($realms), null, PREG_SPLIT_NO_EMPTY); + } + + return $this->json(MetricExplorer::getDimensionValues( + $user, + $dimensionId, + $realms, + $offset, + $limit, + $searchText, + $selectedFilterIds + )); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception if unable to get the currently logged in user. + */ + #[Route('{prefix}/metrics/explorer/get_dw_descripter',requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getDwDescriptors(Request $request): Response + { + try { + $user = $this->tokenHelper->authenticateToken($request); + + // If token authentication failed then fallback to the standard session based authentication method. + if ($user === null) { + $user = \xd_security\getLoggedInUser(); + } + } catch (Exception $e) { + return $this->json( + buildError(new Exception('Session Expired', 2)), + 401 + ); + } + + + $roles = $user->getAllRoles(true); + + $roleDescriptors = array(); + foreach ($roles as $activeRole) { + $shortRole = $activeRole; + $us_pos = strpos($shortRole, '_'); + if ($us_pos > 0) { + $shortRole = substr($shortRole, 0, $us_pos); + } + + if (array_key_exists($shortRole, $roleDescriptors)) { + continue; + } + + // If enabled, try to lookup answer in cache first. + $cache_enabled = \xd_utilities\getConfiguration('internal', 'dw_desc_cache') === 'on'; + $cache_data_found = false; + if ($cache_enabled) { + $db = \CCR\DB::factory('database'); + $db->execute('create table if not exists dw_desc_cache (role char(5), response mediumtext, index (role) ) '); + $cachedResults = $db->query('select response from dw_desc_cache where role=:role', array('role' => $shortRole)); + if (count($cachedResults) > 0) { + $roleDescriptors[$shortRole] = unserialize($cachedResults[0]['response']); + $cache_data_found = true; + } + } + + // If the cache was not used or was not useful, get descriptors from code. + if (!$cache_data_found) { + $realms = []; + // NOTE: this variable is never utilized after being updated. can probably be removed. + $groupByObjects = []; + + $realmObjects = Realms::getRealmObjectsForUser($user); + $query_descriptor_realms = Acls::getQueryDescripters($user); + + foreach ($query_descriptor_realms as $query_descriptor_realm => $query_descriptor_groups) { + $category = DataWarehouse::getCategoryForRealm($query_descriptor_realm); + if ($category === null) { + continue; + } + $seenStats = []; + + $realmObject = $realmObjects[$query_descriptor_realm]; + $realmDisplay = $realmObject->getDisplay(); + $realms[$query_descriptor_realm] = [ + 'text' => $query_descriptor_realm, + 'category' => $realmDisplay, + 'dimensions' => [], + 'metrics' => [], + ]; + foreach ($query_descriptor_groups as $query_descriptor_group) { + foreach ($query_descriptor_group as $query_descriptor) { + if ($query_descriptor->getDisableMenu()) { + continue; + } + + $groupByName = $query_descriptor->getGroupByName(); + $group_by_object = $query_descriptor->getGroupByInstance(); + $permittedStatistics = $group_by_object->getRealm()->getStatisticIds(); + + $groupByObjects[$query_descriptor_realm . '_' . $groupByName] = [ + 'object' => $group_by_object, + 'permittedStats' => $permittedStatistics + ]; + $realms[$query_descriptor_realm]['dimensions'][$groupByName] = [ + 'text' => $groupByName == 'none' ? 'None' : $group_by_object->getName(), + 'info' => $group_by_object->getHtmlDescription() + ]; + + $stats = array_diff($permittedStatistics, $seenStats); + if (empty($stats)) { + continue; + } + + $statsObjects = $query_descriptor->getStatisticsClasses($stats); + foreach ($statsObjects as $realm_group_by_statistic => $statistic_object) { + + if (!$statistic_object->showInMetricCatalog()) { + continue; + } + + $semStatId = \Realm\Realm::getStandardErrorStatisticFromStatistic( + $realm_group_by_statistic + ); + $realms[$query_descriptor_realm]['metrics'][$realm_group_by_statistic] = + [ + 'text' => $statistic_object->getName(), + 'info' => $statistic_object->getHtmlDescription(), + 'std_err' => in_array($semStatId, $permittedStatistics), + 'hidden_groupbys' => $statistic_object->getHiddenGroupBys() + ]; + $seenStats[] = $realm_group_by_statistic; + } + } + } + $texts = []; + foreach ($realms[$query_descriptor_realm]['metrics'] as $key => $row) { + $texts[$key] = $row['text']; + } + array_multisort($texts, SORT_ASC, $realms[$query_descriptor_realm]['metrics']); + } + $texts = []; + foreach ($realms as $key => $row) { + $texts[$key] = $row['text']; + } + array_multisort($texts, SORT_ASC, $realms); + + $roleDescriptors[$shortRole] = ['totalCount' => 1, 'data' => [['realms' => $realms]]]; + + // Cache the results if the cache is enabled. + if ($cache_enabled) { + $db->execute('insert into dw_desc_cache (role, response) values (:role, :response)', [ + 'role' => $shortRole, + 'response' => serialize($roleDescriptors[$shortRole]) + ]); + } + } + } + + $combinedRealmDescriptors = []; + foreach ($roleDescriptors as $roleDescriptor) { + foreach ($roleDescriptor['data'][0]['realms'] as $realm => $realmDescriptor) { + if (!isset($combinedRealmDescriptors[$realm])) { + $combinedRealmDescriptors[$realm] = [ + 'metrics' => [], + 'dimensions' => [], + 'text' => $realmDescriptor['text'], + 'category' => $realmDescriptor['category'], + ]; + } + + $combinedRealmDescriptors[$realm]['metrics'] += $realmDescriptor['metrics']; + $combinedRealmDescriptors[$realm]['dimensions'] += $realmDescriptor['dimensions']; + } + } + + return $this->json([ + 'totalCount' => 1, + 'data' => [ + [ + 'realms' => $combinedRealmDescriptors + ] + ] + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception if unable to retrieve the currently logged in user. + */ + #[Route('{prefix}/metrics/explorer/filters', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getFilters(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $returnData = []; + + try { + $user = \xd_security\getLoggedInUser(); + + $userProfile = $user->getProfile(); + $filters = $userProfile->fetchValue('filters'); + if ($filters != null) { + $filtersArray = json_decode($filters); + $returnData = [ + 'totalCount' => count($filtersArray), + 'message' => 'success', + 'data' => $filtersArray, + 'success' => true + ]; + } else { + $returnData = [ + 'totalCount' => 0, + 'message' => 'success', + 'data' => [], + 'success' => true + ]; + } + + } catch (SessionExpiredException $see) { + // TODO: Refactor generic catch block below to handle specific exceptions, + // which would allow this block to be removed. + throw $see; + } catch (Exception $ex) { + $returnData = [ + 'totalCount' => 0, + 'message' => $ex->getMessage(), + 'data' => [], + 'success' => false + ]; + } + + return $this->json($returnData); + } + + /** + * @param Request $request + * @return Response + * @throws SessionExpiredException|AccessDeniedException + */ + #[Route('{prefix}/metrics/explorer/raw_data', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getRawData(Request $request): Response + { + $user = \xd_security\detectUser(array(XDUser::INTERNAL_USER, XDUser::PUBLIC_USER)); + + try { + $config = []; + foreach ($request->request->all() as $key => $value) { + $config[$key] = $value; + } + + $configParam = $this->getStringParam($request, 'config'); + if (!empty($configParam)) { + $configJson = json_decode($configParam, true); + $config = array_merge($config, $configJson); + } + + $requestedFormat = $this->getStringParam($request, 'format'); + $format = DataWarehouse\ExportBuilder::validateFormat($requestedFormat, 'jsonstore', ['jsonstore']); + $inline = $this->getBooleanParam($request, 'inline', false, true); + $dataSetId = $this->getStringParam($request, 'datasetId', true); + $datapoint = $this->getStringParam($request, 'datapoint', true); + $showContextMenu = $this->getBooleanParam($request, 'showContextMenu', false, false); + $requestedStartDate = $this->getDateFromISO8601Param($request, 'start_date', true); + $requestedStartDateTs = date_timestamp_get($requestedStartDate); + + $requestedEndDate = $this->getDateFromISO8601Param($request, 'end_date', true); + $requestedEndDateTs = date_timestamp_get($requestedEndDate); + + + if ($requestedStartDateTs > $requestedEndDateTs) { + throw new BadRequestHttpException('End date must be greater than or equal to start date'); + } + + $startDate = $requestedStartDate->format('Y-m-d'); + $endDate = $requestedEndDate->format('Y-m-d'); + $isTimeseries = $this->getBooleanParam($request, 'timeseries', false, false); + + if ($isTimeseries) { + // For timeseries data the date range is set to be only the data-point that was + // selected. Therefore we adjust the start and end date appropriately + $aggregationUnit = $this->getStringParam($request, 'aggregation_unit', false, 'auto'); + $time_period = TimeAggregationUnit::deriveAggregationUnitName($aggregationUnit, $startDate, $endDate); + $time_point = $datapoint / 1000; + + list($startDate, $endDate) = TimeAggregationUnit::getRawTimePeriod($time_point, $time_period); + } + + $title = $this->getStringParam($request, 'title'); + + $requestedGlobalFilters = $this->getStringParam($request, 'global_filters'); + + $globalFilters = (object)['data' => [], 'total' => 0]; + if (!empty($requestedGlobalFilters)) { + $globalFiltersDecoded = urldecode($requestedGlobalFilters); + $globalFiltersJson = json_decode($globalFiltersDecoded, true); + $this->logger->warning('Global Filters Decoded', [var_export($globalFiltersDecoded, true)]); + $this->logger->warning('Global FIlters Json', [json_encode($globalFiltersJson)]); + + if (!empty($globalFiltersJson) && isset($globalFiltersJson['data']) && is_array($globalFiltersJson['data'])) { + foreach ($globalFiltersJson['data'] as $datum) { + $globalFilters->data[] = (object)$datum; + $globalFilters->total++; + } + } + } + + $dataset_classname = '\DataWarehouse\Data\SimpleDataset'; + + try { + $all_data_series = $this->getDataSeries($request); + } catch (Exception $e) { + return $this->json( + [ + 'success' => false, + 'message' => $e->getMessage() + ], + 400 + ); + } + + // find requested dataset. + $data_description = null; + foreach ($all_data_series as $data_description_index => $data_series) { + // NOTE: this only works if the id's are not floats. + if ("{$data_series->id}" == "$dataSetId") { + $data_description = $data_series; + break; + } + } + + if ($data_description === null) { + return $this->json( + [ + 'success'=> false, + 'message' => 'Invalid data_series provided.' + ], + 400 + ); + } + + // Check that the user has at least one role authorized to view this data. + MetricExplorer::checkDataAccess( + $user, + $data_description->realm, + 'none', + $data_description->metric + ); + + if ($format === 'jsonstore') { + + $query_classname = '\\DataWarehouse\\Query\\' . $data_description->realm . '\\RawData'; + + $query = new $query_classname( + $data_description->realm, + 'day', + $startDate, + $endDate, + null, + $data_description->metric, + [] + ); + + $groupedRoleParameters = []; + foreach ($globalFilters->data as $global_filter) { + if ($global_filter->checked == 1) { + if ( + !isset( + $groupedRoleParameters[$global_filter->dimension_id] + ) + ) { + $groupedRoleParameters[$global_filter->dimension_id] + = []; + } + + $groupedRoleParameters[$global_filter->dimension_id][] + = $global_filter->value_id; + } + } + + $query->setMultipleRoleParameters($user->getAllRoles(), $user); + + $query->setRoleParameters($groupedRoleParameters); + + $query->setFilters($data_description->filters); + + $dataset = new $dataset_classname($query); + + $filterOpts = array('options' => array('default' => null, 'min_range' => 0)); + + $limit = null; + $limitParam = $this->getStringParam($request, 'limit'); + if (!empty($limitParam)) { + try { + $limit = $this->getIntParam($request, 'limit'); + if ($limit < 0) { + $limit = null; + } + } catch (Exception $e) { + // NOOP + } + } + + $offset = 0; + $offsetParam = $this->getStringParam($request, 'start'); + if (!empty($offsetParam)) { + try { + $offset = intval($offsetParam); + } catch (Exception $e) { + // NOOP + } + } + $offset = max($offset, 0); + $totalCount = $dataset->getTotalPossibleCount(); + + $ret = array(); + + // As a small optimization only compute the total count the first time (ie when the offset is 0) + if ($offset === null or $offset == 0) { + $privquery = new $query_classname( + $data_description->realm, + 'day', + $startDate, + $endDate, + null, + $data_description->metric, + array() + ); + $privquery->setRoleParameters($groupedRoleParameters); + $privquery->setFilters($data_description->filters); + + $query = $privquery->getQueryString(); + + $privdataset = new $dataset_classname($privquery); + + $ret['totalAvailable'] = $privdataset->getTotalPossibleCount(); + } + // This is so that the behavior of this endpoint matches get_rawdata.php + if ($offsetParam === null && !empty($limit)) { + $offset = null; + } + $ret['data'] = $dataset->getResults($limit, $offset,false, false, null, null, $this->logger); + $ret['totalCount'] = $totalCount; + + return $this->json($ret); + } + } catch (SessionExpiredException $see) { + // TODO: Refactor generic catch block below to handle specific exceptions, + // which would allow this block to be removed. + return $this->json(buildError($see)); + } catch (Exception $ex) { + return $this->json(buildError($ex)); + } + + return $this->json([ + 'success' => false, + 'message' => 'An unexpected error has occurred. Please contact support.' + ]); + } + + + private function getDataSeries(Request $request): array + { + $requestedDataSeries = null; + try { + $dataSeriesParam = $this->getStringParam($request, 'data_series', false, '[]'); + $requestedDataSeries = json_decode(urldecode($dataSeriesParam), true); + } catch (Exception $e) { + // NOOP + } + if (is_array($requestedDataSeries) && isset($requestedDataSeries['data']) && is_array($requestedDataSeries['data'])) { + return $this->getDataSeriesFromArray($requestedDataSeries); + } else { + return $this->getDataSeriesFromJsonString($this->getStringParam($request, 'data_series')); + } + } + + private function getDataSeriesFromArray(array $dataSeries): array + { + $results = []; + foreach ($dataSeries['data'] as $datum) { + $y = (object)$datum; + + for ($i = 0, $b = count($y->filters['data']); $i < $b; $i++) { + $y->filters['data'][$i] = (object)$y->filters['data'][$i]; + } + + $y->filters = (object)$y->filters; + + // Set values of new attribs for backward compatibility. + if (empty($y->line_type)) { + $y->line_type = 'Solid'; + } + + if ( + empty($y->line_width) + || !is_numeric($y->line_width) + ) { + $y->line_width = 2; + } + + if (empty($y->color)) { + $y->color = 'auto'; + } + + if (empty($y->shadow)) { + $y->shadow = false; + } + + $results[] = $y; + } + return $results; + } + + /** + * + * @param string $dataSeries + * @return array + */ + private function getDataSeriesFromJsonString(string $dataSeries): array + { + $jsonDataSeries = json_decode(urldecode($dataSeries)); + if (null === $jsonDataSeries) { + throw new BadRequestHttpException('Invalid data_series specified'); + } + foreach ($jsonDataSeries as &$y) { + // Set values of new attribs for backward compatibility. + if (empty($y->line_type)) { + $y->line_type = 'Solid'; + } + + if (empty($y->line_width) || !is_numeric($y->line_width)) { + $y->line_width = 2; + } + + if (empty($y->color)) { + $y->color = 'auto'; + } + + if (empty($y->shadow)) { + $y->shadow = false; + } + } + + return $jsonDataSeries; + } +} diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php new file mode 100644 index 0000000000..ac1109fdbe --- /dev/null +++ b/src/Controller/OrganizationController.php @@ -0,0 +1,277 @@ +getStringParam($request, 'operation', true); + # Note: this is here so that we get the same error messages for the same tests as previously. + # Once we deprecate the old routes this should go away. + if (in_array($operation, ['upgrade_member', 'downgrade_member'])) { + try { + $user = $this->authorize($request, [ROLE_ID_CENTER_DIRECTOR], true); + } catch (Exception $e) { + return $this->json( + [ + "status" => "not_a_center_director", + "success" => false, + "totalCount" => 0, + "message" => "not_a_center_director", + "data" => [] + ] + ); + } + } + + try { + $memberId = $this->getStringParam($request, 'member_id',false, null, RESTRICTION_UID ); + } catch (Exception $e) { + return $this->json(buildError("Invalid value specified for 'member_id'.")); + } + + if (is_null($memberId)) { + return $this->json(buildError("'member_id' not specified.")); + } + + switch($operation) { + case 'downgrade_member': + return $this->downgradeMember($request, $memberId); + case 'enum_center_staff_members': + return $this->getMembers($request); + case 'get_member_status': + return $this->getMemberStatus($request, $memberId); + case 'upgrade_member': + return $this->upgradeMember($request, $memberId); + } + + return $this->json(buildError('Unknown operation provided.')); + + } + + /** + * Retrieve the other members associated with the requesting user's organization. + * + * + + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/organizations/members', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getMembers(Request $request): Response + { + $user = $this->authorize($request, $this->getParameter('center_related_acls'), true); + $members = Users::getUsersAssociatedWithCenter($user->getUserID()); + + return $this->json([ + 'success' => true, + 'count' => count($members), + 'members' => $members + ]); + } + + /** + * + * @param Request $request + * @param ?string $memberId + * @return Response + * @throws Exception + */ + #[Route('{prefix}/organizations/members/{memberId}/status', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getMemberStatus(Request $request, ?string $memberId): Response + { + $user = $this->authorize($request, $this->getParameter('center_related_acls'), true); + + if (empty($memberId)) { + return $this->json(buildError("Invalid value specified for 'member_id'.")); + } + + $member = XDUser::getUserByID($memberId); + if ($member === null) { + return $this->json(\xd_response\buildError('user_does_not_exist')); + } + + $returnData = [ + 'success' => true, + 'message' => '', + 'eligible' => true + ]; + + $organization = $user->getOrganizationID(); + $memberUserId = $member->getUserID(); + + // An eligible user must be associated with the currently logged in users center. + if (!Users::userIsAssociatedWithCenter($memberUserId, $organization)) { + throw new BadRequestHttpException('center_mismatch_between_member_and_director'); + } + + // They must not already be a Center Director for the organization. + if (Centers::hasCenterRelation($memberUserId, $organization, ROLE_ID_CENTER_DIRECTOR)) { + $returnData['success'] = false; + $returnData['message'] = 'is a Center Director'; + return $this->json($returnData); + } + + // This makes them ineligible for promotion, but eligible for demotion. + if (Centers::hasCenterRelation($memberUserId, $organization, ROLE_ID_CENTER_STAFF)) { + $returnData['eligible'] = false; + } + + // They must be active + if (!$member->getAccountStatus()) { + $returnData['success'] = false; + $returnData['message'] = 'User is disabled'; + return $this->json($returnData); + } + + return $this->json($returnData); + } + + /** + * @param Request $request + * @param ?string $memberId + * @return Response + * @throws Exception + */ + #[Route('{prefix}/organizations/members/{memberId}/upgrade', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function upgradeMember(Request $request, ?string $memberId): Response + { + $this->logger->error('Upgrading Member Id: ' . var_export($memberId, true)); + try { + $user = $this->authorize($request, [ROLE_ID_CENTER_DIRECTOR], true); + $this->logger->error('Successfully Authenticated requesting user has CD'); + } catch (Exception $e) { + return $this->json( + [ + "status" => "not_a_center_director", + "success" => false, + "totalCount" => 0, + "message" => "not_a_center_director", + "data" => [] + ] + ); + } + $this->logger->error('Checking member id next.'); + if (empty($memberId)) { + return $this->json(buildError("Invalid value specified for 'member_id'.")); + } + $member = XDUser::getUserByID($memberId); + if ($member === null) { + return $this->json(\xd_response\buildError('user_does_not_exist')); + } + $returnData = []; + + // Ensure that the user performing this operation is authorized + if (!$user->hasAcl(ROLE_ID_CENTER_DIRECTOR) || !$user->getAccountStatus()) { + return $this->json([ + 'success' => false, + 'message' => 'You are not authorized to perform this action' + ]); + } + $organization = $user->getActiveOrganization(); + $memberUserId = $member->getUserID(); + + // An eligible user must be associated with the currently logged in users center. + if (!Users::userIsAssociatedWithCenter($memberUserId, $organization)) { + $this->json(\xd_response\buildError('center_mismatch_between_member_and_director')); + } + + // They must not already be a Center Director for the organization. + if (Centers::hasCenterRelation($memberUserId, $organization, ROLE_ID_CENTER_DIRECTOR)) { + $returnData['success'] = false; + $returnData['message'] = 'is a Center Director'; + return $this->json($returnData); + } + + // They must not be a Center Staff for the organization. + // Although this makes them eligible for demotion. + if (Centers::hasCenterRelation($memberUserId, $organization, ROLE_ID_CENTER_STAFF)) { + $returnData['success'] = false; + $returnData['message'] = 'is already a Center Staff'; + return $this->json($returnData); + } + + Users::promoteUserToCenterStaff($member, $organization); + $returnData['success'] = true; + $returnData['message'] = "has been upgraded to Center Staff
(promoted by {$user->getFormalName()})"; + + return $this->json($returnData); + } + + /** + * @param Request $request + * @param ?string $memberId + * @return Response + * @throws Exception + */ + #[Route('{prefix}/organizations/members/{memberId}/downgrade', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function downgradeMember(Request $request, ?string $memberId): Response + { + try { + $user = $this->authorize($request, [ROLE_ID_CENTER_DIRECTOR], true); + } catch (Exception $e) { + return $this->json( + [ + "status" => "not_a_center_director", + "success" => false, + "totalCount" => 0, + "message" => "not_a_center_director", + "data" => [] + ] + ); + } + + if (empty($memberId)) { + return $this->json(buildError("Invalid value specified for 'member_id'.")); + } + + try { + $memberId = $this->getStringParam($request, 'member_id', false, null, RESTRICTION_UID); + } catch (Exception $e) { + return $this->json(buildError("Invalid value specified for 'member_id'.")); + } + + $member = XDUser::getUserByID($memberId); + if ($member === null) { + return $this->json(\xd_response\buildError('user_does_not_exist')); + } + + $organization = $user->getOrganizationID(); + $memberUserId = $member->getUserID(); + + // An eligible user must be associated with the currently logged in users center. + if (!Users::userIsAssociatedWithCenter($memberUserId, $organization)) { + return $this->json(\xd_response\buildError('center_mismatch_between_member_and_director')); + } + + Users::demoteUserFromCenterStaff($member, $organization); + + return $this->json(['success' => true]); + } + +} diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php new file mode 100644 index 0000000000..2e2185fe99 --- /dev/null +++ b/src/Controller/PasswordResetController.php @@ -0,0 +1,63 @@ + '.*'])] +class PasswordResetController extends BaseController +{ + private static $validModes = ['new', 'reset']; + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('', methods: ['GET'])] + public function index(Request $request): Response + { + $validationCheck = [ + 'status' => INVALID, + 'user_first_name' => 'INVALID', + 'user_id' => INVALID + ]; + + $mode = $this->getStringParam($request, 'mode', false, 'reset'); + $rid = $this->getStringParam($request, 'rid', false, null, RESTRICTION_RID); + if (isset($rid)) { + $validationCheck = \XDUser::validateRID($rid); + } + + + if ($validationCheck['status'] === INVALID || !in_array($mode, self::$validModes)) { + return $this->render( + '::password_reset_expired.html.twig', + [ + 'site_address' => $site_address = \xd_utilities\getConfigurationUrlBase('general', 'site_address') + ] + ); + } + + return $this->render( + '::password_reset.html.twig', + [ + 'rid' => $rid, + 'mode' => $mode, + 'first_name' => $validationCheck['user_first_name'], + 'password_max' => CHARLIM_PASSWORD, + 'extjs_path' => 'gui/lib', + 'extjs_version' => 'extjs' + ] + ); + } +} diff --git a/src/Controller/PersonController.php b/src/Controller/PersonController.php new file mode 100644 index 0000000000..2476b8aa7d --- /dev/null +++ b/src/Controller/PersonController.php @@ -0,0 +1,37 @@ + '.*'])] +class PersonController extends BaseController +{ + + /** + * + * @param Request $request + * @param int $id + * @return Response + * @throws Exception + */ + #[Route('/{id}/organization', requirements: ["id" => "(-)?\d+"], methods: ['GET'])] + public function getOrganizationForPerson(Request $request, int $id): Response + { + $this->authorize($request, ['mgr']); + + return $this->json([ + 'success' => true, + 'results' => [ + 'id' => Organizations::getOrganizationIdForPerson($id) + ] + ]); + } +} diff --git a/src/Controller/ReportBuilderController.php b/src/Controller/ReportBuilderController.php new file mode 100644 index 0000000000..51a2894144 --- /dev/null +++ b/src/Controller/ReportBuilderController.php @@ -0,0 +1,703 @@ +getStringParam($request, 'operation'); + + switch ($operation) { + case 'build_from_template': + $templateId = $this->getStringParam($request, 'template_id'); + return $this->getReportFromTemplate($request, $templateId); + case 'download_report': + return $this->downloadReport($request); + case 'enum_available_charts': + return $this->getAvailableCharts($request); + case 'enum_reports': + return $this->getReports($request); + case 'enum_templates': + return $this->getTemplates($request); + case 'fetch_report_data': + $reportId = $this->getStringParam($request, 'selected_report', true); + return $this->getReportData($request, $reportId); + case 'get_new_report_name': + return $this->getNewReportName($request); + case 'get_preview_data': + return $this->getPreviewData($request); + case 'remove_chart_from_pool': + return $this->removeChartFromPool($request); + case 'remove_report_by_id': + return $this->removeReportsById($request); + case 'save_report': + return $this->saveReport($request); + case 'send_report': + return $this->sendReport($request); + } + + return $this->json([]); + } + + /** + * + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/list', methods: ['GET'])] + public function getReports(Request $request): Response + { + try { + $user = \xd_security\detectUser([XDUser::PUBLIC_USER]); + } catch(Exception $e) { + return $this->json(buildError($e), 401); + } + + $reportManager = new \XDReportManager($user); + + return $this->json([ + 'status' => 'success', + 'queue' => $reportManager->fetchReportTable() + ]); + } + + /** + * + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/charts', methods: ['POST'])] + public function getAvailableCharts(Request $request): Response + { + try { + $user = \xd_security\detectUser([XDUser::PUBLIC_USER]); + } catch(Exception $e) { + return $this->json(buildError($e), 401); + } + + $reportManager = new \XDReportManager($user); + return $this->json([ + 'status' => 'success', + 'queue' => $reportManager->fetchChartPool() + ]); + } + + /** + * + * @param Request $request + * @param string $templateId + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/templates/{templateId}', methods: ['POST'])] + public function getReportFromTemplate(Request $request, string $templateId): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $template = \XDReportManager::retrieveReportTemplate($user, $templateId); + $parameters = $request->request->all(); + $template->buildReportFromTemplate($parameters); + return $this->json(['success' => true]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/send', methods: ['POST'])] + public function sendReport(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + + $buildOnly = $this->getBooleanParam($request, 'build_only'); + $reportId = $this->getStringParam($request, 'report_id', false, null, ReportGenerator::REPORT_ID_REGEX); + $exportFormat = $this->getStringParam($request, 'export_format', false, \XDReportManager::DEFAULT_FORMAT); + + $buildResponse = $reportManager->buildReport($reportId, $exportFormat); + $workingDir = $buildResponse['template_path']; + $reportFileName = $buildResponse['report_file']; + $responseData = [ + 'action' => 'send_report', + 'build_only' => $buildOnly + ]; + + if ($buildOnly) { + $responseData['report_loc'] = basename($workingDir); + $responseData['message'] = 'Report built successfully
'; + $responseData['success'] = true; + $responseData['report_name'] = sprintf('%s.%s', $reportManager->getReportName($reportId, true), $exportFormat); + return $this->json($responseData); + } + + $mailStatus = $reportManager->mailReport($reportId, $reportFileName, '', $buildResponse); + $destinationAddress = $reportManager->getReportUserEmailAddress($reportId); + $message = $mailStatus ? sprintf('Report built and sent to
%s', $destinationAddress) : 'Problem mailing the report'; + + return $this->json([ + 'message' => $message, + 'success' => $mailStatus + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/download', methods: ['GET'])] + public function downloadReport(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $reportLoc = $this->getStringParam($request, 'report_loc'); + if (empty($reportLoc)) { + return $this->json([ + 'success' => false, + 'message' => 'report_loc is a required parameter.' + + ]); + } + $format = $this->getStringParam($request, 'format'); + if (empty($format)) { + return $this->json([ + 'success' => false, + 'message' => 'format is a required parameter.' + ]); + } + + $reportLoc = $this->getStringParam($request, 'report_loc', true, null, ReportGenerator::REPORT_TMPDIR_REGEX); + $format = $this->getStringParam($request, 'format', false, null, ReportGenerator::REPORT_FORMATS_REGEX); + + if (!\XDReportManager::isValidFormat($format)) { + throw new BadRequestHttpException('Invalid format specified'); + } + + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + + $reportId = preg_replace('/(.+)-(.+)-(.+)/', '$1-$2', $reportLoc); + $workingDirectory = sys_get_temp_dir() . '/' . $reportLoc; + + $reportFile = $workingDirectory . '/' . $reportId . '.' . $format; + if (!file_exists($reportFile)) { + throw new BadRequestHttpException('The report you are referring to does not exist.'); + } + + $reportName = $reportManager->getReportName($reportId, true) . '.' . $format; + $headers = [ + 'Content-Type' => \XDReportManager::resolveContentType($format), + 'Content-Disposition' => sprintf('inline;filename="%s"', $reportName) + ]; + $contents = file_get_contents($reportFile); + return new Response($contents, 200, $headers); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/preview', methods: ['POST'])] + public function getPreviewData(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + $reportId = $this->getStringParam($request, 'report_id', true); + $token = $this->getStringParam($request, 'token', true); + $chartsPerPage = $this->getIntParam($request, 'charts_per_page', true); + + $reportManager = new \XDReportManager($user); + $charts = $reportManager->getPreviewData($reportId, $token, $chartsPerPage); + + return $this->json([ + 'report_id' => $reportId, + 'success' => true, + 'charts' => $charts + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/name', methods: ['POST'])] + public function getNewReportName(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + return $this->json([ + 'success' => true, + 'report_name' => $reportManager->generateUniqueName() + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/save', methods: ['POST'])] + public function saveReport(Request $request): Response + { + $phase = $this->getStringParam($request, 'phase', true, null, '/^create|update$/'); + $map = []; + + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + switch ($phase) { + case 'create': + $reportId = sprintf('%s-%s', $user->getUserID(), time()); + break; + case 'update': + $reportId = $this->getStringParam($request, 'report_id', false, null, ReportGenerator::REPORT_ID_REGEX); + $reportManager->buildBlobMap($reportId, $map); + $reportManager->removeReportCharts($reportId); + break; + } + + $reportName = mb_convert_encoding($this->getStringParam($request, 'report_name', true), ReportGenerator::REPORT_CHAR_ENCODING); + $reportTitle = mb_convert_encoding($this->getStringParam($request, 'report_title', true), ReportGenerator::REPORT_CHAR_ENCODING); + $reportHeader = mb_convert_encoding($this->getStringParam($request, 'report_header', true), ReportGenerator::REPORT_CHAR_ENCODING); + $reportFooter = mb_convert_encoding($this->getStringParam($request, 'report_footer', true), ReportGenerator::REPORT_CHAR_ENCODING); + $reportFormat = $this->getStringParam($request, 'report_format', false, null, ReportGenerator::REPORT_FORMATS_REGEX . 'i'); + $chartsPerPage = max(1, $this->getIntParam($request, 'charts_per_page')); + $reportSchedule = $this->getStringParam($request, 'report_schedule', false, null, ReportGenerator::REPORT_SCHEDULE_REGEX); + $reportDelivery = $this->getStringParam($request, 'report_delivery', false, '', ReportGenerator::REPORT_DELIVERY_REGEX . 'i'); + + $reportManager->configureSelectedReport( + $reportId, + $reportName, + $reportTitle, + $reportHeader, + $reportFooter, + $reportFormat, + $chartsPerPage, + $reportSchedule, + $reportDelivery + ); + + if ($reportManager->isUniqueName($reportName, $reportId) === false) { + throw new BadRequestHttpException('Another report you have created is already using this name.'); + } + + switch ($phase) { + case 'create': + $reportManager->insertThisReport(); + break; + case 'update': + $reportManager->saveThisReport(); + break; + } + + foreach ($request->request->all() as $k => $v) { + if (preg_match('/chart_data_(\d+)/', $k, $m) > 0) { + $order = $m[1]; + + list($chart_id, $chart_title, $chart_drill_details, $chart_date_description, $timeframe_type, $entry_type) = explode(';', $v); + + $chart_title = str_replace('%3B', ';', $chart_title); + $chart_drill_details = str_replace('%3B', ';', $chart_drill_details); + + $cache_ref_variable = 'chart_cacheref_' . $order; + + // Transfer blobs residing in the directory used for temporary + // files into the database as necessary for each chart which + // comprises the report. + $cache_ref = $request->get($cache_ref_variable); + if (isset($cache_ref)) { + $cache_ref = filter_var( + $cache_ref, + FILTER_VALIDATE_REGEXP, + ['options' => ['regexp' => ReportGenerator::CHART_CACHEREF_REGEX]] + ); + + list($start_date, $end_date, $ref, $rank) = explode(';', $cache_ref); + + $location = sys_get_temp_dir() . "/{$ref}_{$rank}_{$start_date}_{$end_date}.png"; + + // Generate chart blob if it doesn't exist. This file should have already been create. + if (!is_file($location)) { + $insertion_rank = [ + 'rank' => $rank, + 'did' => '', + ]; + $this->logger->error('Saving Report', ['volatile', $insertion_rank, $start_date, $end_date]); + $cached_blob = $start_date . ',' . $end_date . ';' + . $reportManager->generateChartBlob('volatile', $insertion_rank, $start_date, $end_date, $this->logger); + } else { + $cached_blob = $start_date . ',' . $end_date . ';' . file_get_contents($location); + } + + $chart_id_found = false; + + foreach ($map as &$e) { + if ($e['chart_id'] == $chart_id) { + $e['image_data'] = $cached_blob; + $chart_id_found = true; + } + } + + if ($chart_id_found === false) { + $map[] = [ + 'chart_id' => $chart_id, + 'image_data' => $cached_blob + ]; + } + } + + $reportManager->saveCharttoReport($reportId, $chart_id, $chart_title, $chart_drill_details, $chart_date_description, $order, $timeframe_type, $entry_type, $map); + } + + } + + return $this->json([ + 'action' => 'save_report', + 'phase' => $phase, + 'report_id' => $reportId, + 'success' => true, + 'status' => 'success' + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/remove', methods: ['POST'])] + public function removeReportsById(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + + $reportIds = explode(';', $this->getStringParam($request, 'selected_report', true)); + foreach ($reportIds as $reportId) { + $reportManager->removeReportCharts($reportId); + $reportManager->removeReportbyID($reportId); + } + + return $this->json([ + 'action' => 'remove_report_by_id', + 'success' => true + ]); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/remove/chart', methods: ['POST'])] + public function removeChartFromPool(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $reportManager = new \XDReportManager($user); + $responseData = [ + 'action' => 'remove', + 'success' => true, + 'dropped_entries' => [] + ]; + + foreach ($request->request->all() as $k => $v) { + if (preg_match('/^selected_chart_/', $k) == 1) { + + $reportManager->removeChartFromChartPoolByID($v); + if (preg_match('/controller_module=(.+?)&/', $v, $m)) { + + $module_id = $m[1]; + if (!isset($responseData['dropped_entries'][$module_id])) { + $responseData['dropped_entries'][$module_id] = []; + } + $responseData['dropped_entries'][$module_id][] = $v; + } + } + } + + return $this->json($responseData); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/templates', methods: ['GET'])] + public function getTemplates(Request $request): Response + { + try { + $user = \xd_security\getLoggedInUser(); + } catch (Exception $e) { + return $this->json(buildError($e), 401); + } + + + $templates = \XDReportManager::enumerateReportTemplates($user->getRoles()); + // We do not want to show the "Dashboard Tab Reports" + foreach ($templates as $key => $value) { + if ($value['name'] === 'Dashboard Tab Report') { + unset($templates[$key]); + } + } + return $this->json([ + 'status' => 'success', + 'success' => true, + 'templates' => $templates, + 'count' => count($templates) + ]); + } + + /** + * + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/image', methods: ['GET'])] + #[Route('/report_image_renderer.php', name: 'report_image_renderer_legacy', methods: ['GET'])] + public function generateReportImage(Request $request): Response + { + $this->logger->warning('Generating a Report Image'); + + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $userId = null; + try { + $this->logger->warning('Report Image Authenticated'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + $type = $this->getStringParam($request, 'type', true, null, ReportGenerator::REPORT_CHART_TYPE_REGEX); + $ref = $this->getStringParam($request, 'ref', true, null, ReportGenerator::REPORT_CHART_REF_REGEX); + $did = $this->getStringParam($request, 'did', false, '', ReportGenerator::REPORT_CHART_DID_REGEX); + $start = $this->getStringParam($request, 'start', false, null, ReportGenerator::REPORT_DATE_REGEX); + $end = $this->getStringParam($request, 'end', false, null, ReportGenerator::REPORT_DATE_REGEX); + + + switch ($type) { + case 'chart_pool': + case 'volatile': + $this->logger->warning('Report Image Volatile / chart Pool'); + $numMatches = preg_match('/^(\d+);(\d+)$/', $ref, $matches); + + if ($numMatches === 0) { + throw new Exception('Invalid thumbnail reference set'); + } + + $userId = (int)$matches[1]; + + if (isset($start) && isset($end)) { + $insertionRank = [ + 'rank' => $matches[2], + 'start_date' => $start, + 'end_date' => $end, + 'did' => $did + ]; + } else { + $insertionRank = [ + 'rank' => $matches[2], + 'did' => $did + ]; + } + break; + case 'report': + $numMatches = preg_match('/^((\d+)-(.+));(\d+)$/', $ref, $matches); + + if ($numMatches == 0) { + throw new Exception('Invalid thumbnail reference set'); + } + + $userId = $matches[2]; + $insertionRank = ['report_id' => $matches[1], 'ordering' => $matches[4]]; + break; + case 'cached': + $numMatches = preg_match('/^((\d+)-(.+));(\d+)$/', $ref, $matches); + + if ($numMatches == 0) { + throw new Exception('Invalid thumbnail reference set'); + } + + if (!isset($start) || !isset($end)) { + throw new Exception('Start and end dates not set'); + } + + $validStart = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $start); + $validEnd = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $end); + + if (($validStart * $validEnd) == 0) { + throw new Exception('Invalid start and/or end date supplied'); + } + + $userId = $matches[2]; + + $insertionRank = [ + 'report_id' => $matches[1], + 'ordering' => $matches[4], + 'start_date' => $start, + 'end_date' => $end, + ]; + break; + default: + throw new Exception('Invalid thumbnail type value supplied: ' . $request['type']); + } + + if ($userId != $user->getUserID()) { + throw new AccessDeniedHttpException(sprintf('Invalid User Request. Expected %s, Actual: %s', $user->getUserID(), $userId)); + } + + $this->logger->warning('Valid User Request'); + + $reportManager = null; + try { + $this->logger->warning('Instantiating XDREportManager'); + $reportManager = new XDReportManager($user); + } catch (Exception $exception) { + $this->logger->error('Error instantiating Report Manager'); + } + + $this->logger->warning('After Report Manager.'); + + if (!empty($reportManager)) { + $this->logger->warning('Fetching Chart Blob', [$type, $insertionRank]); + $blob = $reportManager->fetchChartBlob($type, $insertionRank, null, $this->logger); + $this->logger->warning('Substringing Blob'); + $image_data_header = substr($blob, 0, 8); + $this->logger->warning('Chart BLob Fetched!'); + + if ($image_data_header != "\x89PNG\x0d\x0a\x1a\x0a") { + throw new Exception($blob); + } + $this->logger->warning('Blob is a png'); + // If the blob is empty, than substitute the image below to be returned to the user. + if (in_array(md5($blob), self::$emptyBlobs)) { + $blob = file_get_contents(dirname(__FILE__) . '/gui/images/report_thumbnail_no_data.png'); + } + + $headers = ['Content-Type' => 'image/png']; + $this->logger->warning('Returning PNG'); + $this->logger->warning('Headers: ', [$headers]); + return new Response($blob, 200, $headers); + } else { + $this->logger->error('Oops, we shouldnt be here.'); + } + + return $this->json(['message' => 'Unable to instantiate report manager'], 500); + } catch (Exception $e) { + /* There used to be some code here that generated a custom image but it didn't actually do anything with + * that image, just threw the exception so I have elected to not include it here. + */ + $uniqueId = uniqid(); + $this->logger->error('Image generation failed!'); + // The message format here is from classes/UniqueException.php + throw new HttpException(500, sprintf('[Unique ID %s] --> %s', $uniqueId, $e->getMessage())); + } + } + + /** + * + * @param Request $request + * @param string $reportId + * @return Response + * @throws Exception + */ + #[Route('/reports/builder/{reportId}', methods: ['GET'])] + public function getReportData(Request $request, string $reportId): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + $this->logger->warning('get Report Data Start'); + $reportManager = new \XDReportManager($user); + + $flushCache = $this->getBooleanParam($request, 'flush_cache'); + $basedOnAnother = $this->getBooleanParam($request, 'based_on_another'); + + if ($flushCache) { + $reportManager->flushReportImageCache(); + } + + $data = $reportManager->loadReportData($reportId); + + if ($basedOnAnother) { + // The report to be retrieved is to be the basis for a new report. + // In this case, overwrite the report_id and report name fields so when it comes time to save this + // report, a new report will be created instead of the original being overwritten / updated. + $data['report_id'] = ''; + $data['general']['name'] = $reportManager->generateUniqueName($data['general']['name']); + } else { + $data['report_id'] = $reportId; + } + + return $this->json([ + 'action' => 'fetch_report_data', + 'success' => true, + 'results' => $data + ]); + } + + /** + * + * + * @param Request $request + * @return Response + */ + #[Route('/img_placeholder.php', methods: ['GET'])] + public function imgPlaceholder(Request $request): Response + { + $filePath = tempnam(sys_get_temp_dir(), 'img-placeholder-'); + $src = imagecreatetruecolor(7, 12); + $background = imagecolorallocate($src, 255, 255, 255); + imagefill($src, 0, 0, $background); + imagepng($src, $filePath); + + return new BinaryFileResponse($filePath, 200, ['Content-Type: image/png']); + } +} diff --git a/src/Controller/ResourceController.php b/src/Controller/ResourceController.php new file mode 100644 index 0000000000..5ef1abcde2 --- /dev/null +++ b/src/Controller/ResourceController.php @@ -0,0 +1,9 @@ + '.*'])] +class UserController extends BaseController { /** @@ -27,12 +29,12 @@ class UserControllerProvider extends BaseControllerProvider * * @var array */ - private static $userSettableProperties = array( - 'first_name', - 'last_name', - 'email_address', - 'password', - ); + private static $userSettableProperties = [ + 'first_name' => 'string', + 'last_name' => 'string', + 'email_address' => 'string', + 'password' => 'string', + ]; /** * A mapping of user properties that can come in with a request to @@ -40,85 +42,155 @@ class UserControllerProvider extends BaseControllerProvider * * @var array */ - private static $propertySettingOptions = array( - 'first_name' => array( + private static $propertySettingOptions = [ + 'first_name' => [ 'setter' => 'setFirstName', - ), - 'last_name' => array( + ], + 'last_name' => [ 'setter' => 'setLastName', - ), - 'email_address' => array( + ], + 'email_address' => [ 'setter' => 'setEmailAddress', - ), - 'password' => array( + ], + 'password' => [ 'setter' => 'setPassword', - ), - ); + ], + ]; /** - * @see BaseControllerProvider::setupRoutes + * + * + * @param Request $request + * @return Response + * @throws Exception */ - public function setupRoutes(Application $app, \Silex\ControllerCollection $controller) + #[Route('', methods: ['POST'])] + #[Route('/controllers/sab_user.php', name: 'list_users_legacy', methods: ['GET'])] + public function listUsers(Request $request): Response { - $root = $this->prefix; + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $start = $this->getIntParam($request, 'start', true); + $limit = $this->getIntParam($request, 'limit', true); + $searchMode = $this->getStringParam($request, 'search_mode', true); + $piOnly = $this->getBooleanParam($request, 'pi_only', true); + $nameFilter = $this->getStringParam($request, 'query'); + $userManagement = $this->getBooleanParam($request, 'userManagement'); + + $universityId = null; + $searchMethod = null; + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + if ($user->hasAcl(ROLE_ID_CAMPUS_CHAMPION) && !isset($userManagement)) { + $universityId = Acls::getDescriptorParamValue($user, ROLE_ID_CAMPUS_CHAMPION, 'provider'); + } + + switch ($searchMode) { + case 'formal_name': + $searchMethod = FORMAL_NAME_SEARCH; + break; + case 'username': + $searchMethod = USERNAME_SEARCH; + } + + $dataWarehouse = new XDWarehouse(); + + list($userCount, $users) = $dataWarehouse->enumerateGridUsers( + $searchMethod, + $start, + $limit, + $nameFilter, + $piOnly, + $universityId + ); - $controller->get("$root/current", '\Rest\Controllers\UserControllerProvider::getCurrentUser'); - $controller->patch("$root/current", '\Rest\Controllers\UserControllerProvider::updateCurrentUser'); - $controller->get("$root/current/api/token", '\Rest\Controllers\UserControllerProvider::getCurrentAPIToken'); - $controller->post("$root/current/api/token", '\Rest\Controllers\UserControllerProvider::createAPIToken'); - $controller->delete("$root/current/api/token", '\Rest\Controllers\UserControllerProvider::revokeAPIToken'); + $entryId = 0; + $userEntries = []; + foreach ($users as $currentUser) { + $entryId++; + + $personName = 'Invalid'; + $personId = -666; + switch ($searchMode) { + case 'formal_name': + $personName = $currentUser['long_name']; + $personId = $currentUser['id']; + break; + case 'username': + $personName = $currentUser['abusername']; + $personId = sprintf('%s;%s', $currentUser['id'], $currentUser['abusername']); + break; + } + $userEntries[] = [ + 'id' => $entryId, + 'person_id' => $personId, + 'person_name' => $personName + ]; + } + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'message' => 'success', + 'total_user_count' => $userCount, + 'users' => $userEntries + ]); } /** - * Get details for the current user. * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return array Response data containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the current user. + * @param Request $request + * @return Response + * @throws Exception */ - public function getCurrentUser(Request $request, Application $app) + #[Route("/current", name: "get_current_user", methods: ["GET"])] + public function getCurrentUser(Request $request) { - // Ensure that the user is logged in. $this->authorize($request); - // Extract and return the information for the user. - return $app->json(array( + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + if ((!$user instanceof XDUser)) { + return $this->json([ + 'success' => false, + 'message' => 'Internal Error validating User' + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json([ 'success' => true, - 'results' => $this->extractUserData($this->getUserFromRequest($request)), - )); + 'results' => $this->extractUserData($user) + ]); } /** - * Update details about the current user. * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return array Response data containing the following info: - * success: A boolean indicating if the call was successful. - * message + * @param Request $request + * @return Response + * @throws Exception if unable to look up an XDUser by the currently logged in user's id. */ - public function updateCurrentUser(Request $request, Application $app) + #[Route("/current", name: "update_current_user", methods: ["PATCH"])] + public function updateCurrentUser(Request $request) { - // Ensure that the user is logged in. - $this->authorize($request); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + if ((!$user instanceof XDUser)) { + return $this->json([ + 'success' => false, + 'message' => 'Internal Error validating User' + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } - // Attempt to update the user's profile with the given information. $this->updateUser( - $this->getUserFromRequest($request), + $user, $this->extractUserSettableProperties($request) ); - // If the last step completed successfully, hide the welcome message - // for first-time XSEDE users and return a success message. - $_SESSION['suppress_profile_autoload'] = true; - - return $app->json(array( + return $this->json([ 'success' => true, - 'message' => 'User profile updated successfully', - )); + 'message' => 'User profile updated successfully' + ]); } /** @@ -126,14 +198,15 @@ public function updateCurrentUser(Request $request, Application $app) * included in the data returned. To receive a successful response from this endpoint a user must fulfill the * following conditions: * - They just have authenticated to XDMoD via one of the supported methods. - * - THey must have an active API Token. + * - They must have an active API Token. + * * * @param Request $request - * @param Application $app - * @return mixed - * @throws \Exception + * @return Response + * @throws Exception */ - public function getCurrentAPIToken(Request $request, Application $app) + #[Route('/current/api/token', methods: ['GET'])] + public function getCurrentAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -143,11 +216,10 @@ public function getCurrentAPIToken(Request $request, Application $app) $tokenData = $this->getCurrentAPITokenMetaData($user); - return $app->json(array( - 'success' => true, - 'data' => $tokenData - ) - ); + return $this->json([ + 'success' => true, + 'data' => $tokenData + ]); } /** @@ -156,12 +228,13 @@ public function getCurrentAPIToken(Request $request, Application $app) * - They just have authenticated to XDMoD via one of the supported methods. * - They must not have an existing API Token. * + * * @param Request $request - * @param Application $app * @return Response - * @throws \Exception if there is a problem retrieving a database connection. + * @throws Exception if there is a problem retrieving a database connection. */ - public function createAPIToken(Request $request, Application $app) + #[Route('/current/api/token', methods: ['POST'])] + public function createAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -169,7 +242,7 @@ public function createAPIToken(Request $request, Application $app) throw new ConflictHttpException('Token already exists.'); } - return $app->json(array( + return $this->json(array( 'success' => true, 'data' => $this->createToken($user) )); @@ -182,12 +255,13 @@ public function createAPIToken(Request $request, Application $app) * - They must have authenticated to XDMoD via one of the supported methods. * - They must have an active API Token * + * * @param Request $request - * @param Application $app * @return Response - * @throws \Exception + * @throws Exception */ - public function revokeAPIToken(Request $request, Application $app) + #[Route('/current/api/token', methods: ['DELETE'])] + public function revokeAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -198,7 +272,7 @@ public function revokeAPIToken(Request $request, Application $app) // Attempt to revoke the requesting users token. if ($this->revokeToken($user)) { - return $app->json(array( + return $this->json(array( 'success' => true, 'message' => 'Token successfully revoked.' )); @@ -208,6 +282,7 @@ public function revokeAPIToken(Request $request, Application $app) throw new Exception('Unable to revoke API token.'); } + /** * Extract information from a user object. * @@ -215,6 +290,7 @@ public function revokeAPIToken(Request $request, Application $app) * * @param XDUser $user The user object to extract data from. * @return array An associative array of data for the user. + * @throws Exception */ private function extractUserData(XDUser $user) { @@ -236,19 +312,19 @@ function ($item) { $rawRealmConfig ); - return array( + return [ 'first_name' => $user->getFirstName(), 'last_name' => $user->getLastName(), 'email_address' => $emailAddress, 'is_sso_user' => $user->isSSOUser(), 'first_time_login' => $user->getCreationTimestamp() == $user->getLastLoginTimestamp(), - 'autoload_suppression' => isset($_SESSION['suppress_profile_autoload']), + 'autoload_suppression' => SessionSingleton::getSession()->get('suppress_profile_autoload', false), 'field_of_science' => $user->getFieldOfScience(), 'active_role' => $mostPrivilegedFormalName, 'most_privileged_role' => $mostPrivilegedFormalName, 'person_id' => $user->getPersonID(true), 'raw_data_allowed_realms' => $rawDataRealms - ); + ]; } /** @@ -261,14 +337,26 @@ function ($item) { private function extractUserSettableProperties(Request $request) { $requestProperties = array(); - foreach (self::$userSettableProperties as $propertyName) { - $propertyValue = $this->getStringParam($request, $propertyName); - + $this->logger->debug('Getting User Settable Properties'); + foreach (self::$userSettableProperties as $propertyName => $propertyType) { + $propertyValue = $request->get($propertyName); + $this->logger->debug('Checking Property', [$propertyName, $propertyValue, $propertyType]); if ($propertyValue === null) { continue; } + + // Check to make sure that the property value type is what we expect. + if (get_debug_type($propertyValue) !== $propertyType) { + throw new BadRequestHttpException( + sprintf( + "Invalid value for $propertyName. Must be a(n) %s.", + $propertyType + ) + ); + } $requestProperties[$propertyName] = $propertyValue; } + $this->logger->debug('Returning user settable properties', [var_export($requestProperties, true)]); return $requestProperties; } @@ -286,18 +374,19 @@ private function updateUser(XDUser $user, array $updatedProperties) // For each property that can be set, check if it is included in the // given set of properties. If so, invoke that property's setter on the // given user with the given property value. - $userType = $user->getUserType(); foreach ($updatedProperties as $propertyName => $propertyValue) { + $this->logger->debug('Checking Update Property', [$propertyName, !array_key_exists($propertyName, self::$propertySettingOptions)]); if (!array_key_exists($propertyName, self::$propertySettingOptions)) { continue; } $propertyOptions = self::$propertySettingOptions[$propertyName]; - + $this->logger->debug(sprintf('Calling %s w/ %s', $propertyOptions['setter'], $propertyValue)); $user->{$propertyOptions['setter']}($propertyValue); } - + $this->logger->debug('Saving User!'); // Attempt to save the user's new details. This will throw an exception // if an error occurs. + $this->logger->debug('Updating User', [$user->getUserId(), $user->getUsername(), var_export($updatedProperties, true)]); $user->saveUser(); } @@ -307,7 +396,7 @@ private function updateUser(XDUser $user, array $updatedProperties) * * @param XDUser $user * @return bool true if the user does not already have a valid API token. - * @throws \Exception if there is a problem retrieving a database connection. + * @throws Exception if there is a problem retrieving a database connection. */ private function canCreateToken(XDUser $user) { @@ -334,8 +423,8 @@ private function canCreateToken(XDUser $user) * * @param XDUser $user whose token data should be retrieved. * @return array in the format array('created_on' => createdOn, 'expiration_date' => expirationDate) - * @throws \Exception if there is a problem retrieving a db connection. - * @throws \Exception if there is a problem executing the SELECT statement. + * @throws Exception if there is a problem retrieving a db connection. + * @throws Exception if there is a problem executing the SELECT statement. */ private function getCurrentAPITokenMetaData(XDUser $user) { @@ -349,7 +438,7 @@ private function getCurrentAPITokenMetaData(XDUser $user) $rows = $db->query($query, array(':user_id' => $user->getUserID())); if (count($rows) !== 1) { - throw new \Exception('Invalid token data returned.'); + throw new Exception('Invalid token data returned.'); } return array( @@ -366,9 +455,9 @@ private function getCurrentAPITokenMetaData(XDUser $user) * * @return array in the format ('token' => newToken, 'expiration_date' => tokenExpirationDate) * - * @throws \Exception if unable to retrieve a database connection or if there is a problem generating a random token. - * @throws \Exception if the api_token.expiration_interval configuration value ( in portal_settings.ini ) is not set. - * @throws \Exception if inserting the newly generated token is unsuccessful. i.e. the number of rows inserted is < 1. + * @throws Exception if unable to retrieve a database connection or if there is a problem generating a random token. + * @throws Exception if the api_token.expiration_interval configuration value ( in portal_settings.ini ) is not set. + * @throws Exception if inserting the newly generated token is unsuccessful. i.e. the number of rows inserted is < 1. */ private function createToken(XDUser $user) { @@ -388,7 +477,7 @@ private function createToken(XDUser $user) $createdOn = date_create()->format('Y-m-d H:m:s'); $expirationInterval = \xd_utilities\getConfiguration('api_token', 'expiration_interval'); if (empty($expirationInterval)) { - throw new \Exception('Expiration Interval not provided.'); + throw new Exception('Expiration Interval not provided.'); } $dateInterval = date_interval_create_from_date_string($expirationInterval); $expirationDate = date_add(date_create(), $dateInterval)->format('Y-m-d H:m:s'); @@ -396,19 +485,19 @@ private function createToken(XDUser $user) $result = $db->execute( $query, array( - ':user_id' => $user->getUserID(), - ':token' => $hash, + ':user_id' => $user->getUserID(), + ':token' => $hash, ':created_on' => $createdOn, ':expires_on' => $expirationDate ) ); - if ($result != 1) { - throw new \Exception('Unable to create a new API token.'); + if ($result !== 1) { + throw new Exception('Unable to create a new API token.'); } return array( - 'token' => sprintf('%s.%s', $user->getUserID(), $password), + 'token' => sprintf('%s.%s', $user->getUserID(), $password), 'expiration_date' => $expirationDate, ); } @@ -418,8 +507,8 @@ private function createToken(XDUser $user) * * @param XDUser $user whose active token will be revoked. * @return bool true if 1 row was deleted else false. - * @throws \Exception if there was a problem retrieving a database connection. - * @throws \Exception if there was an error while executing the DELETE statement. + * @throws Exception if there was a problem retrieving a database connection. + * @throws Exception if there was an error while executing the DELETE statement. */ private function revokeToken(XDUser $user) { @@ -430,4 +519,5 @@ private function revokeToken(XDUser $user) return $rows === 1; } + } diff --git a/src/Controller/UserInterfaceController.php b/src/Controller/UserInterfaceController.php new file mode 100644 index 0000000000..3cc8f4d59e --- /dev/null +++ b/src/Controller/UserInterfaceController.php @@ -0,0 +1,459 @@ +getStringParam($request, 'operation', true); + switch ($operation) { + case 'get_charts': + return $this->getCharts($request); + case 'get_data': + return $this->getData($request); + case 'get_menus': + return $this->getMenus($request); + case 'get_param_descriptions': + return $this->getParamDescriptions($request); + case 'get_tabs': + return $this->getTabs($request); + } + + throw new NotFoundHttpException(); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/interfaces/user/tabs', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getTabs(Request $request): Response + { + $user = $this->getXDUser($request->getSession()); + + $tabs = Tabs::getTabs($user); + + $results = []; + foreach ($tabs as $tab) { + $results[] = [ + 'tab' => $tab['name'], + 'isDefault' => isset($tab['default']) ? $tab['default'] : false, + 'title' => $tab['title'], + 'pos' => $tab['position'], + 'permitted_modules' => isset($tab['permitted_modules']) ? $tab['permitted_modules'] : null, + 'javascriptClass' => $tab['javascriptClass'], + 'javascriptReference' => $tab['javascriptReference'], + 'tooltip' => isset($tab['tooltip']) ? $tab['tooltip'] : '', + 'userManualSectionName' => $tab['userManualSectionName'], + ]; + } + // Sort tabs + usort( + $results, + function ($a, $b) { + return ($a['pos'] < $b['pos']) ? -1 : 1; + } + ); + + return $this->json([ + 'success' => true, + 'totalCount' => 1, + 'message' => '', + 'data' => [ + ['tabs' => json_encode(array_values($results))] + ] + ]); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/interfaces/user/charts', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getCharts(Request $request): Response + { + $this->logger->debug('Calling Get Charts'); + try { + $user = $this->tokenHelper->authenticateToken($request); + + // If token authentication failed then fallback to the standard session based authentication method. + if ($user === null) { + $user = $this->getXDUser($request->getSession()); + } + } catch (Exception $e) { + return $this->json( + buildError(new Exception('Session Expired', 2)), + 401 + ); + } + + $allowPublicUser = $request->get('public_user', false); + if ($user->isPublicUser() && !$allowPublicUser) { + return $this->json(buildError(new Exception('Session Expired', 2)), 401); + } + + // Send the request and user to the Usage-to-Metric Explorer adapter. + $this->logger->debug('Instantiating Usage Object'); + $usageAdapter = new Usage($request->request->all()); + + $this->logger->debug('Calling Usage->getCharts'); + + try { + $chartResponse = $usageAdapter->getCharts($user); + } catch (Exception $e) { + $message = $e->getMessage(); + $statusCode = 400; + if (str_starts_with($message, 'Your user account does not have permission to view the requested data.')) { + $statusCode = 403; + } elseif ($message === 'One or more realms must be specified.') { + $statusCode = 500; + } + return $this->json(buildError($e), $statusCode); + } + + $newHeaders = []; + foreach ($chartResponse['headers'] as $headerName => $headerValue) { + $newHeaders [] = sprintf('%s: %s', $headerName, $headerValue); + } + + $format = $this->getStringParam($request, 'format'); + $this->logger->debug(sprintf('Requested Format %s', var_export($format, true))); + if (isset($format)) { + switch ($format) { + case 'pdf': + $newHeaders['Content-Type'] = 'application/pdf'; + break; + case 'png': + $newHeaders['Content-Type'] = 'image/png'; + break; + case 'csv': + $newHeaders['Content-Type'] = 'application/xls'; + break; + case 'svg': + $newHeaders['Content-Type'] = 'image/svg+xml'; + break; + case 'xml': + $newHeaders['Content-Type'] = 'text/xml;charset=UTF-8'; + break; + } + } + $this->logger->debug(sprintf('Adding Headers: %s', var_export($newHeaders, true))); + + return new Response($chartResponse['results'], 200, $newHeaders); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/interfaces/user/data', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getData(Request $request): Response + { + $this->logger->debug('GetData Called'); + return $this->getCharts($request); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/interfaces/user/menus', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getMenus(Request $request): Response + { + $returnData = []; + + $user = $this->getXDUser($request->getSession()); + + $node = $this->getStringParam($request, 'node'); + $this->logger->debug('Getting Menus for ', [$node]); + if (isset($node) && $node === 'realms') { + $this->logger->debug('Getting Menus for realms'); + $queryGroupName = $this->getStringParam($request, 'query_group', false, 'tg_usage'); + + $realms = Realms::getRealmsForUser($user); + + foreach ($realms as $realm) { + $returnData[] = [ + 'text' => $realm, + 'id' => 'realm_' . $realm, + 'realm' => $realm, + 'query_group' => $queryGroupName, + 'node_type' => 'realm', + 'iconCls' => 'realm', + 'description' => $realm, + 'leaf' => false, + ]; + } + } elseif (isset($node) && \xd_utilities\string_begins_with($node, 'category_')) { + $this->logger->debug('Getting Menus for category_'); + $queryGroupName = $this->getStringParam($request, 'query_group', false, 'tg_usage'); + + // Get the categories ( realms ) that XDMoD knows about. + $categories = DataWarehouse::getCategories(); + + // Retrieve the realms that the user has access to + $realms = Realms::getRealmIdsForUser($user); + + // Filter the categories by those that the user has access to. + $categories = array_map(function ($category) use ($realms) { + return array_filter($category, function ($realm) use ($realms) { + return in_array($realm, $realms); + }); + }, $categories); + $categories = array_filter($categories); + + // Ensure the categories are sorted as the realms were. + $categoryRealmIndices = []; + foreach ($categories as $categoryName => $category) { + foreach ($category as $realm) { + $realmIndex = array_search($realm, $realms); + if ( + !isset($categoryRealmIndices[$categoryName]) + || $categoryRealmIndices[$categoryName] > $realmIndex + ) { + $categoryRealmIndices[$categoryName] = $realmIndex; + } + } + } + array_multisort($categoryRealmIndices, $categories); + + // If the user requested certain categories, ensure those categories + // are valid. + $category = $this->getStringParam($request, 'category'); + if (isset($category)) { + $requestedCategories = explode(',', $category); + $missingCategories = array_diff($requestedCategories, array_keys($categories)); + if (!empty($missingCategories)) { + throw new Exception("Invalid categories: " . implode(', ', $missingCategories)); + } + $categories = array_map(function ($categoryName) use ($categories) { + return $categories[$categoryName]; + }, $requestedCategories); + } + + foreach ($categories as $categoryName => $category) { + $hasItems = false; + $categoryReturnData = []; + foreach ($category as $realm_name) { + + // retrieve the query descripters this user is authorized to view for this realm. + $queryDescriptorGroups = Acls::getQueryDescripters( + $user, + $realm_name + ); + foreach ($queryDescriptorGroups as $groupByName => $queryDescriptorData) { + $queryDescriptor = $queryDescriptorData['all']; + + if ($queryDescriptor->getShowMenu() !== true) { + continue; + } + + $nodeId = ( + 'node=group_by&realm=' + . $categoryName + . '&group_by=' + . $queryDescriptor->getGroupByName() + ); + + // Make sure that the nodeText, derived from the query descripters menu + // label, has each instance of $realm_name replaced with $categoryName. + $nodeText = preg_replace( + '/' . preg_quote($realm_name, '/') . '/', + $categoryName, + $queryDescriptor->getMenuLabel() + ); + + // If this $nodeId has been seen before but for a different realm. Update + // the list of realms associated with this $nodeId + $nodeRealms = ( + isset($categoryReturnData[$nodeId]) + ? $categoryReturnData[$nodeId]['realm'] . ",{$realm_name}" + : $realm_name + ); + + $categoryReturnData[$nodeId] = [ + 'text' => $nodeText, + 'id' => $nodeId, + 'group_by' => $queryDescriptor->getGroupByName(), + 'query_group' => $queryGroupName, + 'category' => $categoryName, + 'realm' => $nodeRealms, + 'defaultChartSettings' => $queryDescriptor->getChartSettings(true), + 'chartSettings' => $queryDescriptor->getChartSettings(true), + 'node_type' => 'group_by', + 'iconCls' => 'menu', + 'description' => $queryDescriptor->getGroupByLabel(), + 'leaf' => false + ]; + + $hasItems = true; + } + } + + if ($hasItems) { + $returnData = array_merge( + $returnData, + array_values($categoryReturnData) + ); + + $returnData[] = [ + 'text' => '', + 'id' => '-111', + 'node_type' => 'separator', + 'iconCls' => 'blank', + 'leaf' => true, + 'disabled' => true + ]; + } + } + } elseif ( + isset($_REQUEST['node']) + && substr($_REQUEST['node'], 0, 13) == 'node=group_by' + ) { + $this->logger->debug('Getting Menus for group_by'); + $category = $this->getStringParam($request, 'category'); + if ($category) { + $categoryName = $category; + $groupByName = $this->getStringParam($request, 'group_by'); + if (isset($groupByName)) { + $queryGroupName = $this->getStringParam($request, 'query_group', false, 'tg_usage'); + + // Get the categories. If the requested one does not exist, + // throw an exception. + $categories = DataWarehouse::getCategories(); + if (!isset($categories[$categoryName])) { + throw new Exception('Category not found.'); + } + + foreach ($categories[$categoryName] as $realm_name) { + $queryDescriptor = Acls::getQueryDescripters($user, $realm_name, $groupByName); + if (empty($queryDescriptor)) { + continue; + } + + $group_by = $queryDescriptor->getGroupByInstance(); + + foreach ($queryDescriptor->getPermittedStatistics() as $realm_group_by_statistic) { + $statistic = $queryDescriptor->getStatistic($realm_group_by_statistic); + + if (!$statistic->showInMetricCatalog()) { + continue; + } + + $statName = $statistic->getId(); + $chartSettings = $queryDescriptor->getChartSettings(); + if (!$statistic->usesTimePeriodTablesForAggregate()) { + $chartSettingsArray = json_decode($chartSettings, true); + $chartSettingsArray['dataset_type'] = 'timeseries'; + $chartSettingsArray['display_type'] = 'line'; + $chartSettingsArray['swap_xy'] = false; + $chartSettings = json_encode($chartSettingsArray); + } + $returnData[] = [ + 'text' => $statistic->getName(false), + 'id' => 'node=statistic&realm=' + . $realm_name + . '&group_by=' + . $groupByName + . '&statistic=' + . $statName, + 'statistic' => $statName, + 'group_by' => $groupByName, + 'group_by_label' => $group_by->getName(), + 'query_group' => $queryGroupName, + 'category' => $categoryName, + 'realm' => $realm_name, + 'defaultChartSettings' => $chartSettings, + 'chartSettings' => $chartSettings, + 'node_type' => 'statistic', + 'iconCls' => 'chart', + 'description' => $statName, + 'leaf' => true, + 'supportsAggregate' => $statistic->usesTimePeriodTablesForAggregate() + ]; + } + } + + if (empty($returnData)) { + throw new Exception('Category not found.'); + } + + $texts = []; + foreach ($returnData as $key => $row) { + $texts[$key] = $row['text']; + } + array_multisort($texts, SORT_ASC, $returnData); + } + } + } + + return $this->json($returnData); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('{prefix}/interfaces/userparameters/descriptions', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function getParamDescriptions(Request $request): Response + { + $user = $this->getXDUser($request->getSession()); + + $queryBuilder = DataWarehouse\QueryBuilder::getInstance(); + $requestParams = $request->request->all(); + $parameterDescriptions = $queryBuilder->pullQueryParameterDescriptionsFromRequest($requestParams, $user); + + $keyValueParamDescriptions = []; + foreach ($parameterDescriptions as $param_desc) { + $kv = explode('=', $param_desc); + $keyValueParamDescriptions[] = ['key' => trim($kv[0], ' '), 'value' => trim($kv[1], ' ')]; + } + + return $this->json([ + 'totalCount' => count($keyValueParamDescriptions), + 'success' => true, + 'message' => 'success', + 'data' => $keyValueParamDescriptions + ]); + } + + +} diff --git a/classes/Rest/Controllers/WarehouseControllerProvider.php b/src/Controller/WarehouseController.php similarity index 55% rename from classes/Rest/Controllers/WarehouseControllerProvider.php rename to src/Controller/WarehouseController.php index 15251230c4..455c9b8671 100644 --- a/classes/Rest/Controllers/WarehouseControllerProvider.php +++ b/src/Controller/WarehouseController.php @@ -1,70 +1,54 @@ - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::ACCOUNTING, - "dtype" => "infoid", - "text" => "Accounting data", - "url" => "/rest/v1.0/warehouse/search/jobs/accounting", - "documentation" => "Shows information about the job that was obtained from the resource manager. + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::ACCOUNTING, + 'dtype' => 'infoid', + 'text' => 'Accounting data', + 'url' => '/warehouse/search/jobs/accounting', + 'documentation' => 'Shows information about the job that was obtained from the resource manager. This includes timing information such as the start and end time of the job as well as administrative information such as the user that submitted the job and - the account that was charged.", - "type" => "keyvaluedata", - "leaf" => true - ), + the account that was charged.', + 'type' => 'keyvaluedata', + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT, - "dtype" => "infoid", - "text" => "Job script", - "url" => "/rest/v1.0/warehouse/search/jobs/jobscript", - "documentation" => "Shows the job batch script that was passed to the resource manager when the - job was submitted. The script is displayed verbatim.", - "type" => "utf8-text", - "leaf" => true - ), + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT, + 'dtype' => 'infoid', + 'text' => 'Job script', + 'url' => '/warehouse/search/jobs/jobscript', + 'documentation' => 'Shows the job batch script that was passed to the resource manager when the + job was submitted. The script is displayed verbatim.', + 'type' => 'utf8-text', + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::EXECUTABLE => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::EXECUTABLE, - "dtype" => "infoid", - "text" => "Executable information", - "url" => "/rest/v1.0/warehouse/search/jobs/executable", - "documentation" => "Shows information about the processes that were run on the compute nodes during + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::EXECUTABLE, + 'dtype' => 'infoid', + 'text' => 'Executable information', + 'url' => '/warehouse/search/jobs/executable', + 'documentation' => 'Shows information about the processes that were run on the compute nodes during the job. This information includes the names of the various processes and may contain information about the linked libraries, loaded modules and process - environment.", - "type" => "nested", - "leaf" => true), + environment.', + 'type' => 'nested', + 'leaf' => true], \DataWarehouse\Query\RawQueryTypes::PEERS => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::PEERS, - "dtype" => "infoid", - "text" => "Peers", - 'url' => '/rest/v1.0/warehouse/search/jobs/peers', + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::PEERS, + 'dtype' => 'infoid', + 'text' => 'Peers', + 'url' => '/warehouse/search/jobs/peers', 'documentation' => 'Shows the list of other HPC jobs that ran concurrently using the same shared hardware resources.', 'type' => 'ganttchart', - "leaf" => true - ), + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS, - "dtype" => "infoid", - "text" => "Summary metrics", - "url" => "/rest/v1.0/warehouse/search/jobs/metrics", - "documentation" => "shows a table with the performance metrics collected during + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS, + 'dtype' => 'infoid', + 'text' => 'Summary metrics', + 'url' => '/warehouse/search/jobs/metrics', + 'documentation' => 'shows a table with the performance metrics collected during the job. These are typically average values over the job. The label for each row has a tooltip that describes the metric. The data are grouped into the following categories: @@ -169,178 +130,51 @@ class WarehouseControllerProvider extends BaseControllerProvider
  • Network I/O Statistics: information about the data transmitted and received over the network devices.
  • - ", - "type" => "metrics", - "leaf" => true - ), + ', + 'type' => 'metrics', + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS, - "dtype" => "infoid", - "text" => "Detailed metrics", - "url" => "/rest/v1.0/warehouse/search/jobs/detailedmetrics", - "documentation" => "shows the data generated by the job summarization software. Please + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS, + 'dtype' => 'infoid', + 'text' => 'Detailed metrics', + 'url' => '/warehouse/search/jobs/detailedmetrics', + 'documentation' => 'shows the data generated by the job summarization software. Please consult the relevant job summarization software documentation for details - about these metrics.", - "type" => "detailedmetrics", - "leaf" => true - ), + about these metrics.', + 'type' => 'detailedmetrics', + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::ANALYTICS => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::ANALYTICS, - "dtype" => "infoid", - "text" => "Job analytics", - "url" => "/rest/v1.0/warehouse/search/jobs/analytics", - "documentation" => "Click the help icon on each plot to show the description of the analytic", - "type" => "analytics", - "hidden" => true, - "leaf" => true - ), + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::ANALYTICS, + 'dtype' => 'infoid', + 'text' => 'Job analytics', + 'url' => '/warehouse/search/jobs/analytics', + 'documentation' => 'Click the help icon on each plot to show the description of the analytic', + 'type' => 'analytics', + 'hidden' => true, + 'leaf' => true + ], \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS, - "dtype" => "infoid", - "text" => "Timeseries", - "leaf" => false - ), + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS, + 'dtype' => 'infoid', + 'text' => 'Timeseries', + 'leaf' => false + ], \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE => - array( - "infoid" => \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE, - "dtype" => "infoid", - "text" => "VM State/Events", - "documentation" => "Show the lifecycle of a VM. Green signifies when a VM is active and red signifies when a VM is stopped.", - "url" => "/rest/v1.0/warehouse/search/cloud/vmstate", - "type" => "vmstate", - "leaf" => true - ) - ); - - /** - * This function is responsible for the setting up of any routes that this - * ControllerProvider is going to be managing. It *must* be overridden by - * a child class. - * - * @param Application $app - * @param ControllerCollection $controller - * @return null - */ - public function setupRoutes(Application $app, ControllerCollection $controller) - { - $root = $this->prefix; - - $current = get_class($this); - $conversions = '\Rest\Utilities\Conversions'; - // Search history routes - - $controller - ->get("$root/search/history", "$current::searchHistory"); - - $controller - ->post("$root/search/history", "$current::createHistory"); - - $controller - ->get("$root/search/history/{id}", "$current::getHistoryById") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - - $controller - ->post("$root/search/history/{id}", "$current::updateHistory") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - - $controller - ->put("$root/search/history/{id}", "$current::updateHistory") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - - $controller - ->delete("$root/search/history/{id}", "$current::deleteHistory") - ->assert('id', '\d+') - ->convert('id', "$conversions::toInt"); - - $controller - ->delete("$root/search/history", "$current::deleteAllHistory"); - - // Job search routes - - $controller - ->get("$root/search/jobs", "$current::searchJobs"); - - $controller - ->get("$root/search/jobs/{action}", "$current::searchJobsByAction") - ->assert('action', '(\w|_|-])+') - ->convert('action', "$conversions::toString"); - $controller - ->post("$root/search/jobs/{action}", "$current::searchJobsByAction") - ->assert('action', '(\w|_|-])+') - ->convert('action', "$conversions::toString"); - - $controller - ->get("$root/search/cloud/{action}", "$current::searchJobsByAction") - ->assert('action', '(\w|_|-])+') - ->convert('action', "$conversions::toString"); - $controller - ->post("$root/search/cloud/{action}", "$current::searchJobsByAction") - ->assert('action', '(\w|_|-])+') - ->convert('action', "$conversions::toString"); - - // Metrics routes - $controller - ->get("$root/resources", "$current::getResources"); - - $controller - ->get("$root/realms", "$current::getRealms"); - - $controller - ->get("$root/dimensions", "$current::getDimensions"); - - $controller - ->get("$root/dimensions/{dimension}", "$current::getDimensionValues") - ->assert('dimension', '(\w|_|-])+') - ->convert('dimension', "$conversions::toString"); - - $controller - ->get("$root/dimensions/{dimensionId}/name", "$current::getDimensionName") - ->assert('dimensionId', '(\w|_|-])+') - ->convert('dimensionId', "$conversions::toString"); - - $controller - ->get("$root/dimensions/{dimensionId}/values/{valueId}/name", "$current::getDimensionValueName") - ->assert('dimension', '(\w|_|-])+') - ->convert('dimension', "$conversions::toString"); - - $controller - ->get("$root/quick_filters", "$current::getQuickFilters"); - - $controller - ->get("$root/aggregation_units", "$current::getAggregationUnits"); - - $controller - ->get("$root/datasets/types", "$current::getDatasetTypes"); - - $controller - ->get("$root/datasets/output_formats", "$current::getDatasetOutputFormats"); - - $controller - ->get("$root/datasets", "$current::getDatasets"); - - $controller->get("$root/aggregatedata", "$current::getAggregateData"); - - $controller - ->get("$root/plots/formats/output", "$current::getPlotOutputFormats"); - - $controller - ->get("$root/plots/types/display", "$current::getPlotDisplayTypes"); - - $controller - ->get("$root/plots/types/combine", "$current::getPlotCombineTypes"); - - $controller - ->get("$root/plots", "$current::getPlots"); - - $controller - ->get("$root/raw-data", "$current::getRawData"); - } + [ + 'infoid' => \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE, + 'dtype' => 'infoid', + 'text' => 'VM State/Events', + 'documentation' => 'Show the lifecycle of a VM. Green signifies when a VM is active and red signifies when a VM is stopped.', + 'url' => '/warehouse/search/cloud/vmstate', + 'type' => 'vmstate', + 'leaf' => true + ] + ]; /** * Retrieves the Search History for the user making the request. @@ -362,16 +196,17 @@ public function setupRoutes(Application $app, ControllerCollection $controller) * total: ... number of records in 'data' ... * } * + * * @param Request $request - * @param Application $app - * @return array in the format array( boolean success, string message) + * @return Response * @throws AccessDeniedException * @throws BadRequestHttpException * @throws NotFoundHttpException */ - public function searchHistory(Request $request, Application $app) + #[Route('/warehouse/search/history', methods: ['GET'])] + #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function searchHistory(Request $request): Response { - $action = 'searchHistory'; $user = $this->authorize($request); @@ -384,57 +219,56 @@ public function searchHistory(Request $request, Application $app) $title = $this->getStringParam($request, 'title'); if ($nodeId !== null && $tsId !== null && $infoId !== null && $jobId !== null && $recordId !== null && $realm !== null) { - $result = $this->processJobNodeTimeSeriesRequest($app, $user, $realm, $jobId, $tsId, $nodeId, $infoId, $action); + $result = $this->processJobNodeTimeSeriesRequest($user, $realm, $jobId, $tsId, $nodeId, $infoId, $action); } elseif ($tsId !== null && $infoId !== null && $jobId !== null && $recordId !== null && $realm !== null) { - $result = $this->processJobTimeSeriesRequest($app, $user, $realm, $jobId, $tsId, $infoId, $action); + $result = $this->processJobTimeSeriesRequest($user, $realm, $jobId, $tsId, $infoId, $action); } elseif ($infoId !== null && $jobId !== null && $recordId !== null && $realm !== null) { - $result = $this->processJobRequest($app, $user, $realm, $jobId, $infoId, $action); + $result = $this->processJobRequest($user, $realm, $jobId, $infoId, $action); } elseif ($jobId !== null && $recordId !== null && $realm !== null) { - $result = $this->processJobByJobId($app, $user, $realm, $jobId, $action); + $result = $this->processJobByJobId($user, $realm, $jobId, $action); } elseif ($recordId !== null && $realm !== null) { - $result = $this->getHistoryById($request, $app, $recordId); + $result = $this->getHistoryById($request, $recordId); } elseif ($realm !== null && $title !== null) { - $result = $this->getHistoryByTitle($request, $app, $realm, $title); + $result = $this->getHistoryByTitle($user, $realm, $title); } elseif ($realm !== null) { - $result = $this->processHistoryRequest($app, $user, $realm, $action); + $result = $this->processHistoryRequest($user, $realm, $action); } else { - $result = $this->processHistoryDefaultRealmRequest($app, $user, $action); + $result = $this->processHistoryDefaultRealmRequest($user, $action); } return $result; } /** - * Attempts to retrieve the Search History record identified by the - * provided 'id' + * Attempts to retrieve the Search History record identified by the + * provided 'id' + * + * Example Response: + * { + * 'success': , + * 'action' : 'getHistoryById', + * 'results': [ + * { + * ... search history data ... + * } + * ], + * } * - * Example Response: - * { - * 'success': , - * 'action' : 'getHistoryById', - * 'results': [ - * { - * ... search history data ... - * } - * ], - * } + * @param Request $request + * @param int $id + * @return Response * - * @param Request $request that will be used to complete the operation. - * @param Application $app that will be used to complete the operation. - * @param int $id of the Search History record to be retrieved. - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws AccessDeniedException + * @throws UnauthorizedHttpException|AccessDeniedHttpException|Exception */ - public function getHistoryById(Request $request, Application $app, $id) + #[Route('/warehouse/search/history/{id}', requirements: ["id" => "\d+"], methods: ['GET'])] + #[Route('{prefix}/warehouse/search/history/{id}', requirements: ["id" => "\d+", 'prefix' => '.*'], methods: ['GET'])] + public function getHistoryById(Request $request, int $id): Response { $action = 'getHistoryById'; - $user = $this->authorize($request); - $realm = $this->getStringParam($request, 'realm', true); $searchHistory = $this->getUserStore($user, $realm); - $record = $searchHistory->getById($id); if (isset($record)) { foreach ($record['results'] as &$result) { @@ -449,17 +283,18 @@ public function getHistoryById(Request $request, Application $app, $id) $record['success'] = true; $record['action'] = $action; - $results = $app->json($record); - - return $results; + return $this->json($record); } - public function getHistoryByTitle(Request $request, Application $app, $realm, $title) + /** + * @param XDUser $user + * @param string $realm + * @param string $title + * @return Response + */ + private function getHistoryByTitle(XDUser $user, string $realm, string $title): Response { $action = 'getHistoryByTitle'; - - $user = $this->getUserFromRequest($request); - $userHistory = $this->getUserStore($user, $realm); $searches = $userHistory->get(); foreach ($searches as $search) { @@ -468,19 +303,17 @@ public function getHistoryByTitle(Request $request, Application $app, $realm, $t if (!isset($search['dtype'])) { $search['dtype'] = 'recordid'; } - return $app->json( - array( + return $this->json( + [ 'action' => $action, 'success' => true, 'data' => $search - ), - 200 + ] ); - break; } } - throw new NotFoundHttpException(); + throw new NotFoundHttpException(''); } /** @@ -488,19 +321,16 @@ public function getHistoryByTitle(Request $request, Application $app, $realm, $t * throws and exception if the parameters are missing. * @param Request $request The request. * @return array decoded search parameters. - * @throws BadRequestHttpException If the required 'data' parameter is - * absent. + * @throws MissingMandatoryParametersException If the required parameters are absent. */ - private function getSearchParams(Request $request) + private function getSearchParams(Request $request): array { $data = $this->getStringParam($request, 'data', true); $decoded = json_decode($data, true); - if ($decoded === null || !isset($decoded['text']) ) { - throw new BadRequestHttpException( - 'Malformed request. Expected \'data.text\' to be present.' - ); + if ($decoded === null || !isset($decoded['text'])) { + throw new BadRequestHttpException('Malformed request. Expected \'data.text\' to be present.'); } $decoded['text'] = htmlspecialchars($decoded['text'], ENT_COMPAT | ENT_HTML5); @@ -510,39 +340,37 @@ private function getSearchParams(Request $request) /** * Attempt to create a new Search History record with the provided 'data' - * form parameter. + * form parameter. * - * @param Request $request that will be used to complete the requested operation - * @param Application $app that will be used to complete the requested operation - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws AccessDeniedException - * @throws BadRequestHttpException + * + * + * @param Request $request + * + * @return Response + * + * @throws Exception */ - public function createHistory(Request $request, Application $app) + #[Route('/warehouse/search/history', methods: ['POST'])] + #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['POST'])] + public function createHistory(Request $request): Response { $action = 'createHistory'; - - $user = $this->authorize($request); - $realm = $this->getStringParam($request, 'realm', true); + $recordId = $this->getIntParam($request, 'recordid'); $history = $this->getUserStore($user, $realm); - $decoded = $this->getSearchParams($request); - $recordId = $this->getIntParam($request, 'recordid'); - $created = is_numeric($recordId) ? $history->upsert($recordId, $decoded) : $history->insert($decoded); - if ($created == null) { + if ($created === null) { throw new BadRequestHttpException( - "Create request will exceed record storage restrictions " . - "(record count limited to " . - WarehouseControllerProvider::_MAX_RECORDS . ")" + 'Create request will exceed record storage restrictions ' . + '(record count limited to ' . self::MAX_RECORDS . ')' ); } @@ -551,35 +379,38 @@ public function createHistory(Request $request, Application $app) } - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, 'total' => count($created), 'results' => $created - ) + ] ); } /** * Attempt to update the Search History Record identified by the provided - * 'id' with the contents of the form parameter 'data'. + * 'id' with the contents of the form parameter 'data'. + * + * * * @param Request $request that will be used to complete the requested operation - * @param Application $app that will be used to complete the requested operation * @param int $id of the Search History Record to be updated. - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException - * @throws AccessDeniedException + * + * @return Response + * + * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ - public function updateHistory(Request $request, Application $app, $id) + #[Route('/warehouse/search/history/{id}', requirements: ["id" => '\d+'], methods: ['POST', 'PUT'])] + #[Route('{prefix}/warehouse/search/history/{id}', requirements: ["id" => '\d+', 'prefix' => '.*'], methods: ['POST', 'PUT'])] + public function updateHistory(Request $request, int $id): Response { - $user = $this->authorize($request); $action = 'updateHistory'; + $user = $this->authorize($request); $data = $this->getSearchParams($request); - $realm = $this->getStringParam($request, 'realm', true); $history = $this->getUserStore($user, $realm); @@ -590,30 +421,30 @@ public function updateHistory(Request $request, Application $app, $id) $result['dtype'] = 'recordid'; } - $results = $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, + 'total' => 1, 'results' => $result - ), - 200 + ] ); - - return $results; } /** * Attempt to delete the Search History Record identified by the - * provided 'id'. + * provided 'id'. * * @param Request $request that will be used to complete the requested operation - * @param Application $app that will be used to complete the requested operation * @param int $id of the Search History Record to be removed. - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException - * @throws AccessDeniedException + * + * @return Response + * + * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ - public function deleteHistory(Request $request, Application $app, $id) + #[Route('/warehouse/search/history/{id}', requirements: ["id" => "\d+"], methods: ['DELETE'])] + #[Route('{prefix}/warehouse/search/history/{id}', requirements: ["id" => "\d+", 'prefix' => '.*'], methods: ['DELETE'])] + public function deleteHistory(Request $request, int $id): Response { $user = $this->authorize($request); $action = 'deleteHistory'; @@ -623,12 +454,12 @@ public function deleteHistory(Request $request, Application $app, $id) $history = $this->getUserStore($user, $realm); $deleted = $history->delById($id); - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, 'total' => $deleted - ) + ] ); } @@ -636,13 +467,17 @@ public function deleteHistory(Request $request, Application $app, $id) * Attempt to remove all of the Search History Records for the currently logged in * user making the request. * - * @param Request $request that will be used to complete the requested operation - * @param Application $app that will be used to complete the requested operation - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException - * @throws AccessDeniedException + * + * + * @param Request $request + * + * @return Response + * + * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ - public function deleteAllHistory(Request $request, Application $app) + #[Route('/warehouse/search/history', methods: ['DELETE'])] + #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['DELETE'])] + public function deleteAllHistory(Request $request): Response { $user = $this->authorize($request); @@ -653,11 +488,11 @@ public function deleteAllHistory(Request $request, Application $app) $history = $this->getUserStore($user, $realm); $history->del(); - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action - ) + ] ); } @@ -665,12 +500,15 @@ public function deleteAllHistory(Request $request, Application $app) * Attempt to perform a search of the jobs realm with the criteria provided in the * * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\JsonResponse + * + * @return Response + * * @throws BadRequestHttpException - * @throws AccessDeniedException + * @throws AccessDeniedException if the user executing this request does not have access to the provided realm. + * @throws Exception if a user record is not found in the database that corresponds to the current user's username. */ - public function searchJobs(Request $request, Application $app) + #[Route('{prefix}/warehouse/search/jobs', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function searchJobs(Request $request): Response { $user = $this->authorize($request); @@ -678,88 +516,43 @@ public function searchJobs(Request $request, Application $app) $params = $this->getStringParam($request, 'params', true); $params = json_decode($params, true); - - if($params === null) { - throw new BadRequestHttpException('params parameter must be valid JSON'); - } - - if ( (isset($params['resource_id']) && isset($params['local_job_id'])) || isset($params['jobref']) ) { - return $this->getJobByPrimaryKey($app, $user, $realm, $params); + if ((isset($params['resource_id']) && isset($params['local_job_id'])) || isset($params['jobref'])) { + return $this->getJobByPrimaryKey($user, $realm, $params); } else { $startDate = $this->getStringParam($request, 'start_date', true); $endDate = $this->getStringParam($request, 'end_date', true); - return $this->processJobSearch($request, $app, $user, $realm, $startDate, $endDate, 'searchJobs'); + return $this->processJobSearch($request, $user, $realm, $startDate, $endDate, 'searchJobs'); } } /** * @param Request $request - * @param Application $app * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException - * @throws AccessDeniedException + * @return Response + * @throws BadRequestHttpException|AccessDeniedHttpException|Exception if a user record is not found in the database + * that corresponds to the current user's username. */ - public function searchJobsByAction(Request $request, Application $app, $action) + #[Route( + "/warehouse/search/{realms}/{action}", + requirements: ["action" => "([\w|_|-])+", "realms" => "cloud|jobs"], + methods: ["GET", "POST"] + )] + #[Route( + "{prefix}/warehouse/search/{realms}/{action}", + requirements: ["action" => "([\w|_|-])+", "realms" => "cloud|jobs", 'prefix' => '.*'], + methods: ["GET", "POST"] + )] + public function searchJobsByAction(Request $request, string $action): Response { $user = $this->authorize($request); + $actionName = 'searchJobsByAction'; - $name = 'searchJobsByAction'; + /*TODO: verify that `ucfirst` is needed */ + $realm = ucfirst($this->getStringParam($request, 'realms')); - $realm = $this->getStringParam($request, 'realm'); $jobId = $this->getIntParam($request, 'jobid'); - - $results = $this->processJobSearchByAction($request, $app, $user, $action, $realm, $jobId, $name); - - return $results; - - } - - /** - * Get the list of resources known to XDMoD and the metadata about them - * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the dimensions retrieved. - */ - public function getResources(Request $request, Application $app) - { - Tokens::authenticate($request); - - $config = \Configuration\XdmodConfiguration::assocArrayFactory('resource_metadata.json', CONFIG_DIR); - - $query_sql = $config['resource_query']; - $params = array(); - $wheres = array(); - - foreach ($config['where_conditions'] as $param => $wherecond) { - $value = $this->getStringParam($request, $param); - if ($value) { - $params[$param] = $value; - array_push($wheres, $wherecond); - } - } - - if (count($wheres) > 0) { - $query_sql .= " WHERE " . implode(" AND ", $wheres); - } - - $db = DB::factory('database'); - $stmt = $db->prepare($query_sql); - $stmt->execute($params); - - $resourceData = array(); - while ($result = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $resourceData[$result['resource_name']] = $result; - } - return $app->json(array( - 'success' => true, - 'results' => $resourceData - )); + return $this->processJobSearchByAction($request, $user, $action, $realm, $jobId, $actionName); } /** @@ -767,36 +560,51 @@ public function getResources(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the realms retrieved. + * + * + * @param Request $request The request used to make this call. + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * + * @throws Exception */ - public function getRealms(Request $request, Application $app) + #[Route('/warehouse/realms', methods: ['GET'])] + public function getRealms(Request $request): Response { - $user = $this->authorize($request); + /*TODO: verify that unauthorized users should be able to access this endpoint */ + $user = $this->getUser(); + if (null === $user) { + $user = XDUser::getPublicUser(); + } else { + $user = XDUser::getUserByUserName($user->getUserIdentifier()); + } - // Get the realms for the user's active role. + // Get the realms for the query group and the user's active role. $realms = Realms::getRealmsForUser($user); // Return the realms found. - return $app->json(array( + return $this->json([ 'success' => true, 'results' => $realms, - )); + ]); } /** * Return aggregate data from the datawarehouse * - * @param Request $request The request used to make this call. - * @param Application $app The router application. * - * @return json object + * + * @param Request $request The request used to make this call. + * + * @return Response + * + * @throws AccessDeniedException|UnauthorizedHttpException|Exception */ - public function getAggregateData(Request $request, Application $app) + #[Route('/warehouse/aggregatedata', methods: ['GET'])] + #[Route('{prefix}/warehouse/aggregatedata', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getAggregateData(Request $request): Response { $user = $this->authorize($request); @@ -820,8 +628,8 @@ public function getAggregateData(Request $request, Application $app) $permittedStats = Acls::getPermittedStatistics($user, $config->realm, $config->group_by); $forbiddenStats = array_diff($config->statistics, $permittedStats); - if (!empty($forbiddenStats) ) { - throw new AccessDeniedException('access denied to ' . json_encode($forbiddenStats)); + if (!empty($forbiddenStats)) { + throw new AccessDeniedHttpException('access denied to ' . json_encode($forbiddenStats)); } $query = new \DataWarehouse\Query\AggregateQuery( @@ -852,7 +660,7 @@ public function getAggregateData(Request $request, Application $app) $dataset = new \DataWarehouse\Data\SimpleDataset($query); $results = $dataset->getResults($limit, $start); - foreach($results as &$val){ + foreach ($results as &$val) { $val['name'] = $val[$config->group_by . '_name']; $val['id'] = $val[$config->group_by . '_id']; $val['short_name'] = $val[$config->group_by . '_short_name']; @@ -862,12 +670,12 @@ public function getAggregateData(Request $request, Application $app) unset($val[$config->group_by . '_short_name']); unset($val[$config->group_by . '_order_id']); } - return $app->json( - array( + return $this->json( + [ 'results' => $results, 'total' => $dataset->getTotalPossibleCount(), 'success' => true - ) + ] ); } @@ -876,29 +684,39 @@ public function getAggregateData(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the dimensions retrieved. + * + * + * @param Request $request The request used to make this call. + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the dimensions retrieved. + * @throws Exception if a XDMoD user cannot be found for the currently logged in users username. */ - public function getDimensions(Request $request, Application $app) + #[Route('{prefix}/warehouse/dimensions', requirements: ['prefix' => '.*'], methods: ['GET'])] + #[Route('/warehouse/dimensions', methods: ['GET'])] + public function getDimensions(Request $request): Response { $user = $this->authorize($request); - // Get parameters. - $realmParam = $this->getStringParam($request, 'realm'); + $realm = $this->getStringParam($request, 'realm'); + + /*TODO: verify that this is what the expected exception is here.*/ // Get the dimensions for the query group, realm, and user's active role. - $groupBys = Acls::getQueryDescripters( - $user, - $realmParam - ); + try { + $groupBys = Acls::getQueryDescripters($user, $realm); + } catch (Exception $e) { + return $this->json([ + 'success' => false, + 'message' => $e->getMessage() + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + $dimensionsToReturn = array(); - foreach($groupBys as $groupByName => $queryDescriptors) { - foreach($queryDescriptors as $queryDescriptor) { + foreach ($groupBys as $groupByName => $queryDescriptors) { + foreach ($queryDescriptors as $queryDescriptor) { if ($groupByName !== 'none') { $dimensionsToReturn[] = array( 'id' => $queryDescriptor->getGroupByName(), @@ -911,38 +729,42 @@ public function getDimensions(Request $request, Application $app) } } - // Return the dimensions found. - return $app->json(array( + return $this->json([ 'success' => true, - 'results' => $dimensionsToReturn, - )); + 'results' => $dimensionsToReturn + ]); } /** * Get the dimension values available for the user's active role. * - * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the dimension values retrieved. + * + * @param Request $request The request used to make this call. + * @param string $dimension + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the dimension values retrieved. + * + * @throws Exception */ - public function getDimensionValues(Request $request, Application $app, $dimension) + #[Route('/warehouse/dimensions/{dimension}', requirements: ["dimension" => "\w+"], methods: ['GET'])] + #[Route('{prefix}/warehouse/dimensions/{dimension}', requirements: ["dimension" => "\w+", 'prefix' => '.*'], methods: ['GET'])] + public function getDimensionValues(Request $request, string $dimension): Response { $user = $this->authorize($request); - // Get parameters. + // Get Parameter values for feeding to MetricExplorer::getDimensionValues $offset = $this->getIntParam($request, 'offset', false, 0); $limit = $this->getIntParam($request, 'limit'); $searchText = $this->getStringParam($request, 'search_text'); - $realmParameter = $this->getStringParam($request, 'realm'); + $realm = $this->getStringParam($request, 'realm'); $realms = null; - if ($realmParameter !== null) { - $realms = preg_split('/,\s*/', trim($realmParameter), null, PREG_SPLIT_NO_EMPTY); + if (null !== $realm) { + $realms = preg_split('/,\s*/', trim($realm), -1, PREG_SPLIT_NO_EMPTY); } // Get the dimension values. @@ -963,33 +785,43 @@ public function getDimensionValues(Request $request, Application $app, $dimensio $dimensionValue['short_name'] = html_entity_decode($dimensionValue['short_name']); } - // Return the found dimension values. - return $app->json(array( + return $this->json([ 'success' => true, - 'results' => $dimensionValuesData, - )); + 'results' => $dimensionValuesData + ]); } /** * Get a set of quick filters tailored to the current user. * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the metrics retrieved. + * + * + * @param Request $request The request used to make this call. + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the metrics retrieved. + * + * @throws UnavailableTimeAggregationUnitException + * @throws UnknownGroupByException + * @throws Exception if unable to find an XDMoD User by the currently logged in Users username. */ - public function getQuickFilters(Request $request, Application $app) + #[Route('/warehouse/quick_filters', methods: ['GET'])] + #[Route('{prefix}/warehouse/quick_filters', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getQuickFilters(Request $request): Response { - // Get the user. - $user = $this->getUserFromRequest($request); + $user = $this->getUser(); + if (null === $user) { + $user = XDUser::getPublicUser(); + } else { + $user = XDUser::getUserByUserName($user->getUserIdentifier()); + } // Check whether multiple service providers are supported or not. try { - $multipleProvidersSupported = \xd_utilities\getConfiguration('features', 'multiple_service_providers') === 'on'; - } - catch(Exception $e){ + $multipleProvidersSupported = getConfiguration('features', 'multiple_service_providers') === 'on'; + } catch (Exception $e) { $multipleProvidersSupported = false; } @@ -1059,80 +891,92 @@ public function getQuickFilters(Request $request, Application $app) } } - // Return the quick filters. - return $app->json(array( + return $this->json([ 'success' => true, - 'results' => array( + 'results' => [ 'dimensionNames' => $dimensionIdsToNames, - 'filters' => $filters, - ), - )); + 'filters' => $filters + ] + ]); } - /** + /** * Attempt to retrieve the the name for the provided dimensionId. * - * @param Request $request - * @param Application $app - * @param string $dimensionId * - * @return \Symfony\Component\HttpFoundation\JsonResponse + * + * @param Request $request + * @param string $dimensionId + * + * @return Response + * + * @throws Exception if there is no logged in user. */ - public function getDimensionName(Request $request, Application $app, $dimensionId) + #[Route('/warehouse/dimensions/{dimensionId}/name', requirements: ["dimensionId" => "(\w|_|-])+"], methods: ['GET'])] + public function getDimensionName(Request $request, string $dimensionId): Response { - $user = $this->getUserFromRequest($request); + /*TODO: verify that this endpoint is for authorized users only. */ + $user = $this->authorize($request); + $dimensionName = MetricExplorer::getDimensionName($user, $dimensionId); $success = !empty($dimensionName); $status = $success ? 200 : 404; $payload = $success - ? array( - 'success' => $success, - 'results' => array( - 'name' => $dimensionName - )) - : array( - 'success' => false, - 'message' => "Unable to find a name for dimension: $dimensionId" - ); - - return $app->json( + ? array( + 'success' => $success, + 'results' => array( + 'name' => $dimensionName + )) + : array( + 'success' => false, + 'message' => "Unable to find a name for dimension: $dimensionId" + ); + + return $this->json( $payload, $status ); } /** - * Attempt to retrieve the the name for the provided dimensionId and - * valueId. + * Attempt to retrieve the the name for the provided dimensionId and valueId. * - * @param Request $request - * @param Application $app - * @param string $dimensionId - * @param string $valueId * - * @return \Symfony\Component\HttpFoundation\JsonResponse + * + * @param Request $request + * @param string $dimensionId + * @param string $valueId + * + * @return Response + * + * @throws Exception */ - public function getDimensionValueName(Request $request, Application $app, $dimensionId, $valueId) + #[Route( + "/warehouse/dimensions/{dimensionId}/values/{valueId}/name", + requirements: ["dimensionId" => "(\w|_|-])+", "valueId" => "(\w|_|-])+"], + methods: ["GET"] + )] + public function getDimensionValueName(Request $request, string $dimensionId, string $valueId): Response { - $user = $this->getUserFromRequest($request); + $user = $this->authorize($request); $valueName = MetricExplorer::getDimensionValueName($user, $dimensionId, $valueId); $success = !empty($valueName); $status = $success ? 200 : 404; $payload = $success - ? array( - 'success' => $success, - 'results' => array( - 'name' => $valueName - ) - ) - : array( - 'success' => $success, - 'message' => "Unable to find a name for dimesion: $dimensionId | value: $valueId" - ); - - return $app->json( + ? array( + 'success' => $success, + 'results' => array( + 'name' => $valueName + ) + ) + : array( + 'success' => $success, + 'message' => "Unable to find a name for dimesion: $dimensionId | value: $valueId" + ); + + return $this->json( $payload, $status ); @@ -1143,23 +987,28 @@ public function getDimensionValueName(Request $request, Application $app, $dimen * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the available aggregation units. + * + * + * @param Request $request The request used to make this call. + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the available aggregation units. + * + * @throws Exception */ - public function getAggregationUnits(Request $request, Application $app) + #[Route('/warehouse/aggregation_units', methods: ['GET'])] + public function getAggregationUnits(Request $request): Response { $this->authorize($request); // Return the available aggregation units. $aggregation_units = \DataWarehouse\QueryBuilder::getAggregationUnits(); - return $app->json(array( + return $this->json([ 'success' => true, 'results' => array_keys($aggregation_units), - )); + ]); } /** @@ -1167,20 +1016,25 @@ public function getAggregationUnits(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the available dataset types. + * + * + * @param Request $request The request used to make this call. + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the available dataset types. + * + * @throws Exception */ - public function getDatasetTypes(Request $request, Application $app) + #[Route('/warehouse/dataset/types', methods: ['GET'])] + public function getDatasetTypes(Request $request): Response { $this->authorize($request); // Return the available dataset types. $datasetTypes = \DataWarehouse\QueryBuilder::getDatasetTypes(); - return $app->json(array( + return $this->json(array( 'success' => true, 'results' => $datasetTypes, )); @@ -1189,21 +1043,22 @@ public function getDatasetTypes(Request $request, Application $app) /** * Get the dataset output formats available for use. * - * Ported from: classes/REST/DataWarehouse/Explorer.php + * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. - * @return Response A response containing the following info: - * success: A boolean indicating if the call was successful. - * results: An object containing data about - * the available dataset output formats. + * + * + * @param Request $request The request used to make this call. + * + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the available dataset output formats. */ - public function getDatasetOutputFormats(Request $request, Application $app) + #[Route('/warehouse/dataset/output_formats', methods: ['GET'])] + public function getDatasetOutputFormats(Request $request): Response { - $this->authorize($request); - // Return the available dataset output formats. - return $app->json(array( + return $this->json(array( 'success' => true, 'results' => \DataWarehouse\ExportBuilder::$dataset_action_formats, )); @@ -1212,11 +1067,16 @@ public function getDatasetOutputFormats(Request $request, Application $app) /** * Generate a dataset using the given parameters. * - * @param Request $request The request used to make this call. - * @param Application $app The router application. + * + * + * @param Request $request The request used to make this call. + * * @return Response + * + * @throws Exception */ - public function getDatasets(Request $request, Application $app) + #[Route('/datasets', methods: ['GET'])] + public function getDatasets(Request $request): Response { $user = $this->getUserFromRequest($request); @@ -1240,19 +1100,20 @@ public function getDatasets(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. + * + * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the available plot output formats. */ - public function getPlotOutputFormats(Request $request, Application $app) + #[Route('/warehouse/plots/formats/output', methods: ['GET'])] + public function getPlotOutputFormats(Request $request) { $this->authorize($request); // Return the available plot output formats. - return $app->json(array( + return $this->json(array( 'success' => true, 'results' => \DataWarehouse\VisualizationBuilder::$plot_action_formats, )); @@ -1263,19 +1124,22 @@ public function getPlotOutputFormats(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. + * + * + * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the available plot display types. + * @throws Exception */ - public function getPlotDisplayTypes(Request $request, Application $app) + #[Route('/warehouse/plots/formats/output', methods: ['GET'])] + public function getPlotDisplayTypes(Request $request): Response { $this->authorize($request); // Return the available plot display types. - return $app->json(array( + return $this->json(array( 'success' => true, 'results' => \DataWarehouse\VisualizationBuilder::$display_types, )); @@ -1286,19 +1150,22 @@ public function getPlotDisplayTypes(Request $request, Application $app) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * @param Request $request The request used to make this call. - * @param Application $app The router application. + * + * + * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the available plot combine types. + * @throws Exception */ - public function getPlotCombineTypes(Request $request, Application $app) + #[Route('/warehouse/plots/types/combine', methods: ['GET'])] + public function getPlotCombineTypes(Request $request): Response { $this->authorize($request); // Return the available plot combine types. - return $app->json(array( + return $this->json(array( 'success' => true, 'results' => \DataWarehouse\VisualizationBuilder::$combine_types, )); @@ -1307,8 +1174,9 @@ public function getPlotCombineTypes(Request $request, Application $app) /** * Generate a plot using the given parameters. * - * @param Request $request The request used to make this call. - * @param Application $app The router application. + * + * + * @param Request $request The request used to make this call. * @return Response A response containing the following info * if JSON was requested: * success: A boolean indicating if the call was successful. @@ -1317,32 +1185,52 @@ public function getPlotCombineTypes(Request $request, Application $app) * * If another format was requested, the * response will contain file data. + * @throws Exception */ - public function getPlots(Request $request, Application $app) + #[Route('/warehouse/plots', methods: ['GET'])] + public function getPlots(Request $request): Response { - $this->authorize($request); - return $this->getDatasets($request, $app); + return $this->getDatasets($request); } - public function processJobSearch(Request $request, Application $app, XDUser $user, $realm, $startDate, $endDate, $action) + /** + * @param Request $request + * @param XDUser $user + * @param string $realm + * @param string $startDate + * @param string $endDate + * @param string $action + * @return Response + * @throws Exception + * @noinspection PhpTooManyParametersInspection + */ + private function processJobSearch( + Request $request, + XDUser $user, + string $realm, + string $startDate, + string $endDate, + string $action + ): Response { $queryDescripters = Acls::getQueryDescripters($user, $realm); if (empty($queryDescripters)) { - throw new BadRequestHttpException('Invalid realm'); + throw new BadRequestHttpException('Invalid realm', null); } $offset = $this->getIntParam($request, 'start', true); $limit = $this->getIntParam($request, 'limit', true); - $searchParameterStr = $this->getStringParam($request, 'params', true); - - $searchParams = json_decode($searchParameterStr, true); + $searchParams = json_decode( + $this->getStringParam($request, 'params', true), + true + ); if ($searchParams === null || !is_array($searchParams)) { - throw new BadRequestHttpException('The params parameter must be a json object'); + throw new BadRequestHttpException('params parameter must be valid JSON'); } $params = array_intersect_key($searchParams, $queryDescripters); @@ -1351,7 +1239,7 @@ public function processJobSearch(Request $request, Application $app, XDUser $use throw new BadRequestHttpException('Invalid search parameters specified in params object'); } else { $QueryClass = "\\DataWarehouse\\Query\\$realm\\RawData"; - $query = new $QueryClass($realm, "day", $startDate, $endDate, null, "", array()); + $query = new $QueryClass($realm, 'day', $startDate, $endDate, null, '', []); $allRoles = $user->getAllRoles(); $query->setMultipleRoleParameters($allRoles, $user); @@ -1363,25 +1251,25 @@ public function processJobSearch(Request $request, Application $app, XDUser $use $dataSet = new \DataWarehouse\Data\SimpleDataset($query); $raw = $dataSet->getResults($limit, $offset); - $data = array(); + $data = []; foreach ($raw as $row) { $resource = $row['resource']; $localJobId = $row['local_job_id']; $row['text'] = "$resource-$localJobId"; $row['dtype'] = 'jobid'; - array_push($data, $row); + $data[] = $row; } $total = $dataSet->getTotalPossibleCount(); - $results = $app->json( - array( + $results = $this->json( + [ 'success' => true, 'action' => $action, 'results' => $data, 'totalCount' => $total - ) + ] ); if ($total === 0) { @@ -1390,18 +1278,18 @@ public function processJobSearch(Request $request, Application $app, XDUser $use // need to rerun the query without the role params to see if any results come back. // note the data for the priviledged query is not returned to the user. - $privQuery = new $QueryClass("day", $startDate, $endDate, null, "", array()); + $privQuery = new $QueryClass('day', $startDate, $endDate, null, '', []); $privQuery->setRoleParameters($params); $privDataSet = new \DataWarehouse\Data\SimpleDataset($privQuery, 1, 0); $privResults = $privDataSet->getResults(); if (count($privResults) != 0) { - $results = $app->json( - array( + $results = $this->json( + [ 'success' => false, 'action' => $action, 'message' => 'Unable to complete the requested operation. Access Denied.' - ), + ], 401 ); } @@ -1413,53 +1301,75 @@ public function processJobSearch(Request $request, Application $app, XDUser $use /** * @param Request $request - * @param Application $app * @param XDUser $user - * @param $action - * @param $realm - * @param $jobId - * @param $actionName - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws AccessDeniedException + * @param string $action + * @param string $realm + * @param ?int $jobId + * @param string $actionName + * + * @return Response + * + * @throws AccessDeniedException if the provided user does not have access to the specified realm. + * @throws Exception if executable information unavailable for the provided jobId. */ - public function processJobSearchByAction(Request $request, Application $app, XDUser $user, $action, $realm, $jobId, $actionName) + private function processJobSearchByAction( + Request $request, + XDUser $user, + string $action, + string $realm, + ?int $jobId, + string $actionName + ): Response { + switch ($action) { case 'accounting': case 'jobscript': case 'analysis': case 'metrics': case 'analytics': - $results = $this->getJobData($app, $user, $realm, $jobId, $action, $actionName); + /*TODO: verify that this doesn't need to be here*/ + /*$realm = $this->getStringParam($request, 'realm', true);*/ + $results = $this->getJobData($user, $realm, $jobId, $action); break; case 'peers': $start = $this->getIntParam($request, 'start', true); $limit = $this->getIntParam($request, 'limit', true); - $results = $this->getJobPeers($app, $user, $realm, $jobId, $start, $limit); + /*TODO: verify that this needs to be here.*/ + if ($jobId === null) { + throw new BadRequestHttpException('Invalid value for realm. Must be a(n) string.'); + } + /*TODO: verify that this needs to be here.*/ + $realm = $this->getStringParam($request, 'realm', true); + + $results = $this->getJobPeers($user, $realm, $jobId, $start, $limit); break; case 'executable': - $results = $this->getJobExecutable($app, $user, $realm, $jobId, $action, $actionName); + $realm = $this->getStringParam($request, 'realm', true); + $results = $this->getJobExecutable($user, $realm, $jobId, $action, $actionName); break; case 'detailedmetrics': - $results = $this->getJobSummary($app, $user, $realm, $jobId, $action, $actionName); + $realm = $this->getStringParam($request, 'realm', true); + $results = $this->getJobSummary($user, $realm, $jobId, $action, $actionName); break; case 'timeseries': $tsId = $this->getStringParam($request, 'tsid', true); - $nodeId = $this->getIntParam($request, 'nodeid', false); - $cpuId = $this->getIntParam($request, 'cpuid', false); - - $results = $this->getJobTimeSeriesData($app, $request, $user, $realm, $jobId, $tsId, $nodeId, $cpuId); + $nodeId = $this->getIntParam($request, 'nodeid'); + $cpuId = $this->getIntParam($request, 'cpuid'); + $realm = $this->getStringParam($request, 'realm', true); + $results = $this->getJobTimeSeriesData($request, $user, $realm, $jobId, $tsId, $nodeId, $cpuId); break; case 'vmstate': - $results = $this->getJobTimeSeriesData($app, $request, $user, $realm, $jobId, null, null, null); + $realm = $this->getStringParam($request, 'realm', true); + $results = $this->getJobTimeSeriesData($request, $user, $realm, $jobId, null, null, null); break; default: - $results = $app->json( - array( + $results = $this->json( + [ 'success' => false, 'action' => $actionName, 'message' => "Unable to process the requested operation. Unsupported action $action." - ), + ], 400 ); break; @@ -1469,18 +1379,16 @@ public function processJobSearchByAction(Request $request, Application $app, XDU } /** - * Return data about a job's peers. - * - * @param Application $app The router application. * @param XDUser $user the logged in user. - * @param $realm data realm. - * @param $jobId the unique identifier for the job. - * @param $start the start offset (for store paging). - * @param $limit the number of records to return (for store paging). - * @return json in Extjs.store parsable format. - * @throws NotFoundHttpException + * @param string $realm data realm. + * @param int $jobId the unique identifier for the job. + * @param int $start the start offset (for store paging). + * @param int $limit the number of records to return (for store paging). + * @return Response + * @throws AccessDeniedException if the provided user does not have access to the specified realm. + * @throws NotFoundHttpException if the provided jobId has no data in the provided realm. */ - protected function getJobPeers(Application $app, XDUser $user, $realm, $jobId, $start, $limit) + private function getJobPeers(XDUser $user, string $realm, $jobId, int $start, int $limit): Response { $jobdata = $this->getJobDataSet($user, $realm, $jobId, 'internal'); if (!$jobdata->hasResults()) { @@ -1516,14 +1424,14 @@ protected function getJobPeers(Application $app, XDUser $user, $realm, $jobId, $ 'ref' => array( 'realm' => $realm, 'jobid' => $jobId, - "text" => $thisjob['resource'] . '-' . $thisjob['local_job_id'] + 'text' => $thisjob['resource'] . '-' . $thisjob['local_job_id'] ) ) ); $dataset = $this->getJobDataSet($user, $realm, $jobId, 'peers'); foreach ($dataset->getResults() as $index => $jobpeer) { - if ( ($index >= $start) && ($index < ($start + $limit))) { + if (($index >= $start) && ($index < ($start + $limit))) { $result['series'][1]['data'][] = array( 'x' => $i++, 'low' => $jobpeer['start_time_ts'] * 1000.0, @@ -1539,34 +1447,26 @@ protected function getJobPeers(Application $app, XDUser $user, $realm, $jobId, $ } } - return $app->json(array( + return $this->json([ 'success' => true, - 'data' => array($result), + 'data' => [$result], 'total' => count($dataset->getResults()) - )); + ]); } /** - * @param Application $app * @param XDUser $user - * @param $realm - * @param $jobId - * @param $action - * @param $actionName - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \DataWarehouse\Query\Exceptions\AccessDeniedException - */ - private function getJobData(Application $app, XDUser $user, $realm, $jobId, $action, $actionName) + * @param string $realm + * @param int $jobId + * @param string $action + * @return Response + * @throws AccessDeniedException + */ + private function getJobData(XDUser $user, string $realm, int $jobId, string $action): Response { $dataSet = $this->getJobDataSet($user, $realm, $jobId, $action); - return $app->json( - array( - 'data' => $dataSet->export(), - 'success' => true - ), - 200 - ); + return $this->json(['data' => $dataSet->export(), 'success' => true]); } /** @@ -1574,13 +1474,13 @@ private function getJobData(Application $app, XDUser $user, $realm, $jobId, $act * @param string $realm * @param int $jobId * @param string $action - * @return \DataWarehouse\Data\RawDataset - * @throws \DataWarehouse\Query\Exceptions\AccessDeniedException + * @return RawDataset + * @throws AccessDeniedException if the provided user does not have access to the specified realm. */ - private function getJobDataSet(XDUser $user, $realm, $jobId, $action) + private function getJobDataSet(XDUser $user, string $realm, $jobId, string $action): RawDataset { if (!\DataWarehouse\Access\RawData::realmExists($user, $realm)) { - throw new \DataWarehouse\Query\Exceptions\AccessDeniedException; + throw new AccessDeniedException(); } $QueryClass = "\\DataWarehouse\\Query\\$realm\\JobDataset"; @@ -1590,13 +1490,13 @@ private function getJobDataSet(XDUser $user, $realm, $jobId, $action) $allRoles = $user->getAllRoles(); $query->setMultipleRoleParameters($allRoles, $user); - $dataSet = new \DataWarehouse\Data\RawDataset($query, $user); + $dataSet = new RawDataset($query, $user); if (!$dataSet->hasResults()) { $privilegedQuery = new $QueryClass($params, $action); $results = $privilegedQuery->execute(1); if ($results['count'] != 0) { - throw new \DataWarehouse\Query\Exceptions\AccessDeniedException; + throw new AccessDeniedException(); } } return $dataSet; @@ -1605,16 +1505,15 @@ private function getJobDataSet(XDUser $user, $realm, $jobId, $action) /** * Retrieves the executable information for a given job. * - * @param Application $app the Application instance used. - * @param \XDUser $user the user that made this particular request. + * @param XDUser $user the user that made this particular request. * @param string $realm the data realm in which this request was made. - * @param string $jobId the unique identifier for the job. - * @param string $action the parent action that called this function. - * @param string $actionName the child action that called this function. - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @param ?int $jobId the unique identifier for the job. + * + * @return Response + * * @throws Exception */ - private function getJobExecutable(Application $app, \XDUser $user, $realm, $jobId, $action, $actionName) + private function getJobExecutable(XDUser $user, string $realm, ?int $jobId): Response { $QueryClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; $query = new $QueryClass(); @@ -1622,268 +1521,270 @@ private function getJobExecutable(Application $app, \XDUser $user, $realm, $jobI $execInfo = $query->getJobExecutableInfo($user, $jobId); if (count($execInfo) === 0) { - throw new Exception( + throw new \Exception( "Executable information unavailable for $realm $jobId", 500 ); } - return $app->json( - $this->arraytostore(json_decode(json_encode($execInfo), true)), - 200 + return $this->json( + $this->arrayToStore( + json_decode(json_encode($execInfo), true) + ) ); } - private function arraytostore(array $values) + /** + * @param array $values + * @return array[] + */ + private function arrayToStore(array $values): array { - return array(array("key" => ".", "value" => "", "expanded" => true, "children" => $this->atosrecurse($values, false) )); + return [['key' => '.', 'value' => '', 'expanded' => true, 'children' => $this->atosRecurse($values)]]; } - private function atosrecurse(array $values) + /** + * @param array $values + * @return array + */ + private function atosRecurse(array $values): array { - $result = array(); - foreach($values as $key => $value) { - if( is_array($value) ) { - if(count($value) > 0 ) { - $result[] = array("key" => "$key", "value" => "", "expanded" => true, "children" => $this->atosrecurse($value) ); + $result = []; + foreach ($values as $key => $value) { + if (is_array($value)) { + if (count($value) > 0) { + $result[] = [ + 'key' => "$key", + 'value' => '', + 'expanded' => true, + 'children' => $this->atosRecurse($value) + ]; } } else { - $result[] = array("key" => "$key", "value" => $value, "leaf" => true); + $result[] = ['key' => "$key", 'value' => $value, 'leaf' => true]; } } return $result; } - /** - * @param Application $app * @param XDUser $user * @param string $realm - * @param int $jobId + * @param ?int $jobId * @param string $tsId * @param int $nodeId * @param int $infoId - * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException - * @throws Exception + * @return Response + * @noinspection PhpTooManyParametersInspection */ private function processJobNodeTimeSeriesRequest( - Application $app, XDUser $user, - $realm, - $jobId, - $tsId, - $nodeId, - $infoId, - $action - ) { + string $realm, + ?int $jobId, + string $tsId, + int $nodeId, + int $infoId + ): Response + { if ($infoId != \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS) { throw new BadRequestHttpException("Node $infoId is a leaf"); } - $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoclass(); + $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoClass(); - $result = array(); + $result = []; foreach ($info->getJobTimeseriesMetricNodeMeta($user, $jobId, $tsId, $nodeId) as $cpu) { - $cpu['url'] = "/rest/v0.1/warehouse/search/jobs/timeseries"; - $cpu['type'] = "timeseries"; - $cpu['dtype'] = "cpuid"; + $cpu['url'] = '/warehouse/search/jobs/timeseries'; + $cpu['type'] = 'timeseries'; + $cpu['dtype'] = 'cpuid'; $result[] = $cpu; } - return $app->json(array("success" => true, "results" => $result)); + return $this->json(['success' => true, 'results' => $result]); } /** - * @param Application $app * @param XDUser $user - * @param $realm - * @param int $jobId - * @param $tsId + * @param string $realm + * @param ?int $jobId + * @param string $tsId * @param int $infoId - * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException + * @return Response */ private function processJobTimeSeriesRequest( - Application $app, XDUser $user, - $realm, - $jobId, - $tsId, - $infoId, - $action - ) { + string $realm, + ?int $jobId, + string $tsId, + int $infoId + ): Response + { if ($infoId != \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS) { throw new BadRequestHttpException("Node $infoId is a leaf"); } - $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoclass(); + $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoClass(); - $result = array(); + $result = []; foreach ($info->getJobTimeseriesMetricMeta($user, $jobId, $tsId) as $node) { - $node['url'] = "/rest/v0.1/warehouse/search/jobs/timeseries"; - $node['type'] = "timeseries"; - $node['dtype'] = "nodeid"; + $node['url'] = '/warehouse/search/jobs/timeseries'; + $node['type'] = 'timeseries'; + $node['dtype'] = 'node'; $result[] = $node; } - return $app->json(array("success" => true, "results" => $result)); + return $this->json(['success' => true, 'results' => $result]); } /** - * @param Application $app * @param XDUser $user * @param string $realm * @param int $jobId - * @param int $infoId - * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws BadRequestHttpException + * @param string $infoId + * @return Response */ private function processJobRequest( - Application $app, XDUser $user, - $realm, - $jobId, - $infoId, - $action - ) { + string $realm, + ?int $jobId, + int $infoId + ): Response + { switch ($infoId) { - case "" . \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE: - $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoclass(); + case '' . \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE: + $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoClass(); - $result = array(); + $result = []; foreach ($info->getJobTimeseriesMetaData($user, $jobId) as $tsid) { - $tsid['url'] = "/rest/v0.1/warehouse/search/jobs/vmstate"; - $tsid['type'] = "timeseries"; - $tsid['dtype'] = "tsid"; + $tsid['url'] = '/warehouse/search/jobs/vmstate'; + $tsid['type'] = 'timeseries'; + $tsid['dtype'] = 'tsid'; $result[] = $tsid; } - return $app->json(array('success' => true, "results" => $result)); - break; - case "" . \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS: - $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoclass(); + return $this->json(['success' => true, 'results' => $result]); + case '' . \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS: + $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoClass(); - $result = array(); + $result = []; foreach ($info->getJobTimeseriesMetaData($user, $jobId) as $tsid) { - $tsid['url'] = "/rest/v0.1/warehouse/search/jobs/timeseries"; - $tsid['type'] = "timeseries"; - $tsid['dtype'] = "tsid"; + $tsid['url'] = '/warehouse/search/jobs/timeseries'; + $tsid['type'] = 'timeseries'; + $tsid['dtype'] = 'tsid'; $result[] = $tsid; } - return $app->json(array('success' => true, "results" => $result)); - break; + return $this->json(['success' => true, 'results' => $result]); default: - throw new BadRequestHttpException("Node is a leaf"); + throw new BadRequestHttpException('Node is a leaf'); } } /** - * @param Application $app * @param XDUser $user * @param string $realm * @param int $jobId * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return Response */ private function processJobByJobId( - Application $app, XDUser $user, - $realm, - $jobId, - $action - ) { + string $realm, + int $jobId, + string $action + ): Response + { $JobMetaDataClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; $info = new $JobMetaDataClass(); $jobMetaData = $info->getJobMetadata($user, $jobId); - $data = array_intersect_key($this->_supported_types, $jobMetaData); + $data = array_intersect_key($this->supportedTypes, $jobMetaData); - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, 'results' => array_values($data) - ) + ] ); } /** - * @param Application $app * @param XDUser $user * @param string $realm * @param string $action - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return Response */ - private function processHistoryRequest(Application $app, XDUser $user, $realm, $action) + private function processHistoryRequest(XDUser $user, string $realm, string $action): Response { $history = $this->getUserStore($user, $realm); $output = $history->get(); - $results = array(); + + $results = []; foreach ($output as $item) { - $results[] = array( + $results[] = [ 'text' => $item['text'], 'dtype' => 'recordid', 'recordid' => $item['recordid'], 'searchterms' => $item['searchterms'] - ); + ]; } - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, 'results' => $results, 'total' => count($results) - ) + ] ); } /** - * @param Application $app - * @param $action - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @param XDUser $user + * @param string $action + * @return Response */ - private function processHistoryDefaultRealmRequest(Application $app, XDUser $user, $action) + private function processHistoryDefaultRealmRequest(XDUser $user, string $action): Response { - $results = array(); + $results = []; - foreach(\DataWarehouse\Access\RawData::getRawDataRealms($user) as $realmconfig) { - $history = $this->getUserStore($user, $realmconfig['name']); + foreach (\DataWarehouse\Access\RawData::getRawDataRealms($user) as $realmConfig) { + $history = $this->getUserStore($user, $realmConfig['name']); $records = $history->get(); if (!empty($records)) { - $results[] = array( + $results[] = [ 'dtype' => 'realm', - 'realm' => $realmconfig['name'], - 'text' => $realmconfig['display'] - ); + 'realm' => $realmConfig['name'], + 'text' => $realmConfig['display'] + ]; } } - return $app->json( - array( + return $this->json( + [ 'success' => true, 'action' => $action, 'results' => $results - ) + ] ); } - private function encodeFloatArray(array $in) + /** + * @param array $in + * @return array + */ + private function encodeFloatArray(array $in): array { - $out = array(); + $out = []; foreach ($in as $key => $value) { if (is_float($value) && is_nan($value)) { $out[$key] = 'NaN'; @@ -1894,36 +1795,60 @@ private function encodeFloatArray(array $in) return $out; } - private function getJobSummary(Application $app, \XDUser $user, $realm, $jobId, $action, $actionName) + /** + * @param XDUser $user + * @param string $realm + * @param int $jobId + * @return Response + */ + private function getJobSummary(XDUser $user, string $realm, int $jobId): Response { - $queryclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $query = new $queryclass(); + $queryClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $query = new $queryClass(); - $jobsummary = $query->getJobSummary($user, $jobId); + $jobSummary = $query->getJobSummary($user, $jobId); - $result = array(); + $result = []; // Really this should be a recursive function! - foreach ($jobsummary as $key => $val) { + foreach ($jobSummary as $key => $val) { $name = "$key"; if (is_array($val)) { if (array_key_exists('avg', $val) && !is_array($val['avg'])) { - $result[] = array_merge(array("name" => $name, "leaf" => true), $this->encodeFloatArray($val)); + $result[] = array_merge( + [ + 'name' => $name, + 'leaf' => true + ], + $this->encodeFloatArray($val) + ); } else { - $l1data = array("name" => $name, "avg" => "", "expanded" => "true", "children" => array()); - foreach ($val as $subkey => $subval) { + $l1data = ['name' => $name, 'avg' => '', 'expanded' => 'true', 'children' => []]; + foreach ($val as $subkey => $subVal) { $subName = "$subkey"; - if (is_array($subval)) { - if (array_key_exists('avg', $subval) && !is_array($subval['avg'])) { - $l1data['children'][] = array_merge(array("name" => $subName, "leaf" => true), $this->encodeFloatArray($subval)); + if (is_array($subVal)) { + if (array_key_exists('avg', $subVal) && !is_array($subVal['avg'])) { + $l1data['children'][] = array_merge( + [ + 'name' => $subName, + 'leaf' => true + ], + $this->encodeFloatArray($subVal) + ); } else { - $l2data = array("name" => $subName, "avg" => "", "expanded" => "true", "children" => array()); - - foreach ($subval as $subsubkey => $subsubval) { - $subSubName = "$subsubkey"; - if (is_array($subsubval)) { - if (array_key_exists('avg', $subsubval) && !is_array($subsubval['avg'])) { - $l2data['children'][] = array_merge(array("name" => $subSubName, "leaf" => true), $this->encodeFloatArray($subsubval)); + $l2data = ['name' => $subName, 'avg' => '', 'expanded' => 'true', 'children' => []]; + + foreach ($subVal as $subSubKey => $subSubVal) { + $subSubName = "$subSubKey"; + if (is_array($subSubVal)) { + if (array_key_exists('avg', $subSubVal) && !is_array($subSubVal['avg'])) { + $l2data['children'][] = array_merge( + [ + 'name' => $subSubName, + 'leaf' => true + ], + $this->encodeFloatArray($subSubVal) + ); } } } @@ -1941,41 +1866,40 @@ private function getJobSummary(Application $app, \XDUser $user, $realm, $jobId, } } - return $app->json( - $result - ); + return $this->json($result); } /** * Encode a chart data series in CSV data and send as an attachment - * @param $data the data series information - * @return Response the data in a CSV file attachment + * + * @param array $data the data series information + * @return Response */ - private function chartDataResponse($data) + private function chartDataResponse(array $data): Response { $filename = tempnam(sys_get_temp_dir(), 'xdmod'); $fp = fopen($filename, 'w'); - $columns = array('Time'); - $ndatapoints = 0; + $columns = ['Time']; + $numberOfDataPoints = 0; foreach ($data['series'] as $series) { if (isset($series['dtype'])) { $columns[] = $series['name']; - if ($ndatapoints === 0) { - $ndatapoints = count($series['data']); + if ($numberOfDataPoints === 0) { + $numberOfDataPoints = count($series['data']); } } } fputcsv($fp, $columns); - for ($i = 0; $i < $ndatapoints; $i++) { - $outline = array(); + for ($i = 0; $i < $numberOfDataPoints; $i++) { + $outline = []; foreach ($data['series'] as $series) { if (isset($series['dtype'])) { if (count($outline) === 0) { - $outline[] = isset($series['data'][$i]['x']) ? $series['data'][$i]['x'] : $series['data'][$i][0]; + $outline[] = $series['data'][$i]['x'] ?? $series['data'][$i][0]; } - $outline[] = isset($series['data'][$i]['y']) ? $series['data'][$i]['y'] : $series['data'][$i][1]; + $outline[] = $series['data'][$i]['y'] ?? $series['data'][$i][1]; } } fputcsv($fp, $outline); @@ -1998,11 +1922,12 @@ private function chartDataResponse($data) * This function is used for exporting *Job Viewer Timeseries* plots only. * It repeats chart config performed for browser in job viewer's ChartPanel.js. * - * @param $data the data - * @param $type the type of image to generate - * @return Response the image as an attachment + * @param array $data the data + * @param string $type the type of image to generate + * @param array $settings + * @return Response */ - private function chartImageResponse($data, $type, $settings) + private function chartImageResponse(array $data, string $type, array $settings): Response { $axisTitleFontSize = ($settings['font_size'] + 12) . 'px'; $axisLabelFontSize = ($settings['font_size'] + 11) . 'px'; @@ -2011,29 +1936,57 @@ private function chartImageResponse($data, $type, $settings) $lineWidth = 1 + $settings['scale']; $chartConfig = array( - 'data' => $data, - 'axisTickSize' => $axisLabelFontSize, - 'axisTitleSize' => $axisTitleFontSize, - 'lineWidth' => $lineWidth, - 'chartTitleSize' => $mainTitleFontSize + 'data' => $data, + 'axisTickSize' => $axisLabelFontSize, + 'axisTitleSize' => $axisTitleFontSize, + 'lineWidth' => $lineWidth, + 'chartTitleSize' => $mainTitleFontSize ); $globalConfig = array( 'timezone' => $data['schema']['timezone'] ); - $chartImage = \xd_charting\exportChart($chartConfig, $settings['width'], $settings['height'], $settings['scale'], $type, $globalConfig, $settings['fileMetadata']); + $chartImage = \xd_charting\exportChart( + $chartConfig, + $settings['width'], + $settings['height'], + $settings['scale'], + $type, + $globalConfig, + $settings['fileMetadata'] + ); $chartFilename = $settings['fileMetadata']['title'] . '.' . $type; $mimeOverride = $type == 'svg' ? 'image/svg+xml' : null; return $this->sendAttachment($chartImage, $chartFilename, $mimeOverride); } - private function getJobTimeSeriesData(Application $app, Request $request, \XDUser $user, $realm, $jobId, $tsId, $nodeId, $cpuId) + /** + * @param Request $request + * @param XDUser $user + * @param string $realm + * @param ?int $jobId + * @param ?string $tsId + * @param ?int $nodeId + * @param ?int $cpuId + * @return Response + * @throws NotFoundHttpException + */ + private function getJobTimeSeriesData( + Request $request, + XDUser $user, + string $realm, + ?int $jobId, + ?string $tsId, + ?int $nodeId, + ?int $cpuId + ): Response { - $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoclass(); - $results = $info->getJobTimeseriesData($user, $jobId, $tsId, $nodeId, $cpuId); + $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoClass(); + + $results = $info->getJobTimeseriesData($user, $jobId, $tsId, $nodeId, $cpuId, $this->logger); if (count($results) === 0) { throw new NotFoundHttpException('The requested resource does not exist'); @@ -2041,26 +1994,28 @@ private function getJobTimeSeriesData(Application $app, Request $request, \XDUse $format = $this->getStringParam($request, 'format', false, 'json'); - if (!in_array($format, array('json', 'png', 'svg', 'pdf', 'csv'))) { + if (!in_array($format, ['json', 'png', 'svg', 'pdf', 'csv'])) { throw new BadRequestHttpException('Unsupported format type.'); } + $subject = $results['schema']['source'] ?? ''; + $title = $results['schema']['description'] ?? ''; switch ($format) { case 'png': case 'pdf': case 'svg': - $exportConfig = array( + $exportConfig = [ 'width' => $this->getIntParam($request, 'width', false, 916), 'height' => $this->getIntParam($request, 'height', false, 484), 'scale' => floatval($this->getStringParam($request, 'scale', false, '1')), 'font_size' => $this->getIntParam($request, 'font_size', false, 3), - 'show_title' => $this->getStringParam($request, 'show_title', false, 'y') === 'y' ? true : false, - 'fileMetadata' => array( + 'show_title' => $this->getStringParam($request, 'show_title', false, 'y') === 'y', + 'fileMetadata' => [ 'author' => $user->getFormalName(), - 'subject' => 'Timeseries data for ' . $results['schema']['source'], - 'title' => $results['schema']['description'] - ) - ); + 'subject' => 'Timeseries data for ' . $subject, + 'title' => $title + ] + ]; $response = $this->chartImageResponse($results, $format, $exportConfig); break; case 'csv': @@ -2068,7 +2023,7 @@ private function getJobTimeSeriesData(Application $app, Request $request, \XDUse break; case 'json': default: - $response = $app->json(array("success" => true, "data" => array($results))); + $response = $this->json(['success' => true, 'data' => [$results]]); break; } @@ -2081,132 +2036,161 @@ private function getJobTimeSeriesData(Application $app, Request $request, \XDUse * confusion between this internal identifier and the job id provided * by the resource-manager). * - * @param Application $app - * @param \XDUser $user + * @param XDUser $user * @param string $realm - * @param array $searchparams - * @return \Symfony\Component\HttpFoundation\JsonResponse - * @throws \DataWarehouse\Query\Exceptions\AccessDeniedException - * @throws BadRequestHttpException + * @param array $searchParams + * @return Response + * @throws AccessDeniedException if the provided user does not have access to the provided realm. */ - private function getJobByPrimaryKey(Application $app, \XDUser $user, $realm, $searchparams) + private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchParams): Response { if (!\DataWarehouse\Access\RawData::realmExists($user, $realm)) { - throw new \DataWarehouse\Query\Exceptions\AccessDeniedException; - } - - if (isset($searchparams['jobref']) && is_numeric($searchparams['jobref'])) { - $params = array( - 'primary_key' => $searchparams['jobref'] - ); - } elseif (isset($searchparams['resource_id']) && isset($searchparams['local_job_id'])) { - $params = array( - 'resource_id' => $searchparams['resource_id'], - 'job_identifier' => $searchparams['local_job_id'] - ); + throw new AccessDeniedException(); + } + + if (isset($searchParams['jobref']) && is_numeric($searchParams['jobref'])) { + $params = [ + 'primary_key' => $searchParams['jobref'] + ]; + } elseif (isset($searchParams['resource_id']) && isset($searchParams['local_job_id'])) { + $params = [ + 'resource_id' => $searchParams['resource_id'], + 'job_identifier' => $searchParams['local_job_id'] + ]; } else { throw new BadRequestHttpException('invalid search parameters'); } $QueryClass = "\\DataWarehouse\\Query\\$realm\\JobDataset"; - $query = new $QueryClass($params, "brief"); + $query = new $QueryClass($params, 'brief'); $allRoles = $user->getAllRoles(); $query->setMultipleRoleParameters($allRoles, $user); - $dataSet = new \DataWarehouse\Data\RawDataset($query, $user); + $dataSet = new RawDataset($query, $user); $results = array(); foreach ($dataSet->getResults() as $result) { - $result['text'] = $result['resource'] . "-" . $result['local_job_id']; + $result['text'] = $result['resource'] . '-' . $result['local_job_id']; $result['dtype'] = 'jobid'; - array_push($results, $result); + $results[] = $result; } if (!$dataSet->hasResults()) { - $privilegedQuery = new $QueryClass($params, "brief"); + $privilegedQuery = new $QueryClass($params, 'brief'); $privilegedResults = $privilegedQuery->execute(1); if ($privilegedResults['count'] != 0) { - throw new \DataWarehouse\Query\Exceptions\AccessDeniedException(); + throw new AccessDeniedHttpException(); } } - return $app->json( - array( + return $this->json( + [ 'success' => true, - "results" => $results, - "totalCount" => count($results) - ) + 'results' => $results, + 'totalCount' => count($results) + ] ); } - private function getUserStore(\XDUser $user, $realm) + /** + * @param XDUser $user + * @param string $realm + * @return UserStorage + */ + private function getUserStore(XDUser $user, string $realm): UserStorage { - $container = implode('-', array_filter(array(self::_HISTORY_STORE, strtoupper($realm)))); - return new \UserStorage($user, $container); + $container = implode( + '-', + array_filter([ + self::HISTORY_STORE_KEY, + strtoupper($realm) + ]) + ); + return new UserStorage($user, $container); } /** * Endpoint to get rows of raw data from the data warehouse. Requires API - * token authorization. - * - * The request should contain the following parameters: - * - start_date: start of date range for which to get data. - * - end_date: end of date range for which to get data. - * - realm: data realm for which to get data. - * - * It can also contain the following optional parameters: - * - fields: list of aliases of fields to get (if not provided, all - * fields are obtained). - * - filters: mapping of dimension names to their possible values. - * Results will only be included whose values for each of the - * given dimensions match one of the corresponding given values. - * - offset: starting row index of data to get. - * - * If successful, the response will be a stream of chunks of data of type - * `text/plain`. The beginning of each chunk is a string of hex digits - * indicating the size of the chunk data in octets, followed by `\\r\\n`, - * followed by the chunk data, followed by another `\\r\\n`. The first - * chunk contains an array that contains the `display` property of each - * obtained field. Each subsequent chunk contains an array that contains - * the obtained field values for the next row of raw data. The final chunk - * is of length zero to indicate the end of the stream. + * token authorization. + * + * The request should contain the following parameters: + * - start_date: start of date range for which to get data. + * - end_date: end of date range for which to get data. + * - realm: data realm for which to get data. + * + * It can also contain the following optional parameters: + * - fields: list of aliases of fields to get (if not provided, all + * fields are obtained). + * - filters: mapping of dimension names to their possible values. + * Results will only be included whose values for each of the + * given dimensions match one of the corresponding given values. + * - offset: starting row index of data to get. + * + * If successful, the response will be a JSON text sequence. The first line + * will be an array containing the `display` property of each obtained + * field. Subsequent lines will be arrays containing the obtained field + * values for each record. + * + * * * @param Request $request - * @param Application $app - * @return \Symfony\Component\HttpFoundation\StreamedResponse + * + * @return StreamedResponse + * * @throws BadRequestHttpException if any of the required parameters are - * not included; if an invalid start date, - * end date, realm, field alias, or filter - * key is provided; if the end date is - * before the start date; or if the offset - * is negative. + * not included; if an invalid start date, + * end date, realm, field alias, or filter + * key is provided; if the end date is + * before the start date; or if the offset + * is negative. * @throws AccessDeniedException if the user does not have permission to - * get raw data from the requested realm. + * get raw data from the requested realm. + * @throws Exception */ - public function getRawData(Request $request, Application $app) + #[Route('/warehouse/raw-data', methods: ['GET'])] + #[Route('{prefix}/warehouse/raw-data', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getRawData(Request $request): Response { - $user = Tokens::authenticate($request); - $params = $this->validateRawDataParams($request, $user); + $this->logger->debug('Getting Raw Data!'); + $this->logger->debug('Authenticating User By Token'); + $user = parent::authenticateToken($request); + /*TODO: Validate that this is supposed to be here. */ + if ($user === null) { + $this->logger->error('Unable to authenticate user by token'); + return $this->json(buildError(new Exception('No Token Provided.')), 401, [ + 'WWW-Authenticate' => 'Bearer' + ]); + } + try { + $params = $this->validateRawDataParams($request, $user); + } catch (HttpException $e) { + $this->logger->error('Unable to validate parameters'); + return $this->json(buildError($e), $e->getStatusCode()); + } + $realmManager = new RealmManager(); $queryClass = $realmManager->getRawDataQueryClass($params['realm']); $logger = $this->getRawDataLogger(); + $this->logger->debug('Have everything, beginning to stream!'); $streamCallback = function () use ( $user, $params, $queryClass, $logger ) { + $logger->debug('Streaming Starting!'); $reachedOffset = false; $i = 1; $offset = $params['offset']; // Jobs realm has a performance improvement by querying one day at // a time. if ('Jobs' === $params['realm']) { + $logger->debug('Streaming Jobs realm Data'); $currentDate = $params['start_date']; while ($currentDate <= $params['end_date']) { - self::echoRawData( + $this->echoRawData( $queryClass, $currentDate, $currentDate, @@ -2225,9 +2209,10 @@ public function getRawData(Request $request, Application $app) ); } } else { + $logger->debug('Streaming other realms'); // All other realms query the entire date range in a single // query. - self::echoRawData( + $this->echoRawData( $queryClass, $params['start_date'], $params['end_date'], @@ -2242,30 +2227,69 @@ public function getRawData(Request $request, Application $app) ); } }; - return $app->stream( - $streamCallback, - 200, - ['Content-Type' => 'text/plain'] - ); + /*TODO: Validate that this is how to do a streamed response. */ + return new StreamedResponse($streamCallback, 200, ['Content-Type' => 'application/json-seq']); } /** - * Validate the parameters of the request from the given user to the raw - * data endpoint (@see getRawData()). - * * @param Request $request + * @return Response + */ + #[Route('/warehouse/resources', methods: ['GET'])] + #[Route('{prefix}/warehouse/resources', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getResources(Request $request): Response + { + Tokens::authenticate($request); + + $config = \Configuration\XdmodConfiguration::assocArrayFactory('resource_metadata.json', CONFIG_DIR); + + $query_sql = $config['resource_query']; + $params = array(); + $wheres = array(); + + foreach ($config['where_conditions'] as $param => $wherecond) { + $value = $this->getStringParam($request, $param); + if ($value) { + $params[$param] = $value; + array_push($wheres, $wherecond); + } + } + + if (count($wheres) > 0) { + $query_sql .= " WHERE " . implode(" AND ", $wheres); + } + + $db = DB::factory('database'); + $stmt = $db->prepare($query_sql); + $stmt->execute($params); + + $resourceData = array(); + while ($result = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $resourceData[$result['resource_name']] = $result; + } + return $this->json(array( + 'success' => true, + 'results' => $resourceData + )); + } + + /** + * Validate the parameters of the request from the given user to the raw + * data endpoint (@param Request $request * @param XDUser $user * @return array of validated parameter values. - * @throws BadRequestHttpException if any of the parameters are invalid. - * @throws AccessDeniedException if the user does not have permission to - * get raw data from the requested realm. + * @throws BadRequestException if any of the parameters are invalid. + * @throws AccessDeniedHttpException if the user does not have permission to + * get raw data from the requested realm. + * @throws Exception if there is a problem retrieving the query descripters. */ - private function validateRawDataParams($request, $user) + private function validateRawDataParams($request, $user): array { $params = []; list( $params['start_date'], $params['end_date'] - ) = $this->validateRawDataDateParams($request); + ) = $this->validateRawDataDateParams($request); + $params['realm'] = $this->getStringParam($request, 'realm', true); $allRealmNames = self::getRealmNames(Realms::getRealms()); if (!in_array($params['realm'], $allRealmNames)) { @@ -2282,6 +2306,7 @@ private function validateRawDataParams($request, $user) 'The requested realm is not configured to provide raw data.' ); } + $queryDescripters = Acls::getQueryDescripters($user, $params['realm']); if (empty($queryDescripters)) { throw new AccessDeniedException( @@ -2296,7 +2321,7 @@ private function validateRawDataParams($request, $user) ); $params['offset'] = $this->getIntParam($request, 'offset', false, 0); if ($params['offset'] < 0) { - throw new BadRequestHttpException('Offset must be non-negative.'); + throw new BadRequestHttpException('Offset must be non-negative.', null); } return $params; } @@ -2304,9 +2329,10 @@ private function validateRawDataParams($request, $user) /** * Generate a database logger for the raw data queries. * - * @return \CCR\Logger + * @return LoggerInterface + * @throws Exception if there's a problem instantiating the Logger */ - private function getRawDataLogger() + private function getRawDataLogger(): LoggerInterface { return Log::factory( 'data-warehouse-raw-data-rest', @@ -2319,8 +2345,7 @@ private function getRawDataLogger() } /** - * Perform an unbuffered database query and echo the result using chunked - * transfer encoding, flushing every 10000 rows. + * Perform an unbuffered database query and echo the result as a JSON text sequence, flushing every 10000 rows. * * @param string $queryClass the fully qualified name of the query class. * @param string $startDate the start date of the query in ISO 8601 format. @@ -2330,30 +2355,31 @@ private function getRawDataLogger() * @param bool $isLastQueryInSeries if true, switch back to MySQL buffered query mode after echoing the last row. * @param array $params validated parameter values from @see validateRawDataParams(). * @param XDUser $user the user making the request. - * @param \CCR\Logger $logger used to log the database request. + * @param LoggerInterface $logger used to log the database request. * @param bool $reachedOffset if true, the requested offset row has been already been reached so don't keep * checking for it, instead just echo all rows. Otherwise, keep checking for the * offset row and only start echoing rows once it is reached. * @param int $i the number of rows iterated so far plus one — used to keep track of whether the offset has been * reached and when to flush. * @param int $offset the number of rows to ignore before echoing. - * @return null + * @return void * @throws Exception if $startDate or $endDate are invalid ISO 8601 dates, if there is an error connecting to * or querying the database, or if invalid fields have been specified in the query parameters. */ - private static function echoRawData( - $queryClass, - $startDate, - $endDate, - $isFirstQueryInSeries, - $isLastQueryInSeries, - $params, - $user, - $logger, - &$reachedOffset, - &$i, - &$offset - ) { + private function echoRawData( + string $queryClass, + string $startDate, + string $endDate, + bool $isFirstQueryInSeries, + bool $isLastQueryInSeries, + array $params, + XDUser $user, + LoggerInterface $logger, + bool &$reachedOffset, + int &$i, + int &$offset + ): void + { $query = new $queryClass( [ 'start_date' => $startDate, @@ -2361,8 +2387,8 @@ private static function echoRawData( ], 'batch' ); - $query = self::setRawDataQueryFilters($query, $params); - $dataset = self::getRawBatchDataset( + $query = $this->setRawDataQueryFilters($query, $params); + $dataset = $this->getRawBatchDataset( $user, $params, $query, @@ -2407,18 +2433,19 @@ private static function echoRawDataRow($row) { * * @param XDUser $user * @param array $params validated parameter values. - * @param \DataWarehouse\Query\RawQuery $query - * @param \CCR\Logger + * @param RawQuery $query + * @param LoggerInterface $logger * @return BatchDataset * @throws Exception if the `fields` parameter contains invalid field * aliases. */ - private static function getRawBatchDataset( + private function getRawBatchDataset( $user, $params, $query, $logger - ) { + ): BatchDataset + { try { $dataset = new BatchDataset( $query, @@ -2437,18 +2464,17 @@ private static function getRawBatchDataset( } /** - * Validate the `start_date` and `end_date` parameters of the given request - * to the raw data endpoint (@see getRawData()). - * - * @param Request $request + * Validate the 'start_date' and 'end_date' parameters of the given request + * to the raw data endpoint (@param Request $request * @return array containing the validated start and end dates in Y-m-d * format. - * @throws BadRequestHttpException if the start and/or end dates are not - * provided or are not valid ISO 8601 dates - * or the end date is less than the start - * date. + * @throws BadRequestException if the start and/or end dates are not + * provided or are not valid ISO 8601 dates or + * the end date is less than the start date. + * @see getRawData()). + * */ - private function validateRawDataDateParams($request) + private function validateRawDataDateParams(Request $request): array { $startDate = $this->getDateFromISO8601Param( $request, @@ -2461,9 +2487,7 @@ private function validateRawDataDateParams($request) true ); if ($endDate < $startDate) { - throw new BadRequestHttpException( - 'End date cannot be less than start date.' - ); + throw new BadRequestHttpException('End date cannot be less than start date.', null); } return [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]; } @@ -2475,7 +2499,8 @@ private function validateRawDataDateParams($request) * @param array $realms array of Realm\Realm objects. * @return array of string realm names. */ - private static function getRealmNames(array $realms) { + private static function getRealmNames(array $realms): array + { return array_map( function ($realm) { return $realm->getName(); @@ -2486,14 +2511,14 @@ function ($realm) { /** * Get the array of field aliases from the given request to the raw data - * endpoint (@see getRawData()), e.g., the parameter `fields=foo,bar,baz` - * results in `['foo', 'bar', 'baz']`. - * - * @param Request $request + * endpoint (@param Request $request * @return array|null containing the field aliases parsed from the request, * if provided. + * @see getRawData()), e.g., the parameter `fields=foo,bar,baz` + * results in `['foo', 'bar', 'baz']`. + * */ - private function getRawDataFieldsArray($request) + private function getRawDataFieldsArray(Request $request): ?array { $fields = null; $fieldsStr = $this->getStringParam($request, 'fields', false); @@ -2505,20 +2530,20 @@ private function getRawDataFieldsArray($request) /** * Validate the optional `filters` parameter of the given request to the - * raw data endpoint (@see getRawData()), e.g., the parameter - * `filters[foo]=bar,baz` results in `['foo' => ['bar', 'baz']]`. - * - * @param Request $request + * raw data endpoint (@param Request $request * @param array $queryDescripters the set of dimensions the user is * authorized to see based on their assigned * ACLs. - * @return array whose keys are the validated filter keys (they must be + * @return array|null whose keys are the validated filter keys (they must be * valid dimensions the user is authorized to see) and whose * values are arrays of the provided string values. * @throws BadRequestHttpException if any of the filter keys are invalid - * dimension names. + * dimension names. + * @see getRawData()), e.g., the parameter + * `filters[foo]=bar,baz` results in `['foo' => ['bar', 'baz']]`. + * */ - private function validateRawDataFiltersParams($request, $queryDescripters) + private function validateRawDataFiltersParams(Request $request, array $queryDescripters): ?array { $filters = null; $filtersParam = $request->get('filters'); @@ -2540,21 +2565,20 @@ private function validateRawDataFiltersParams($request, $queryDescripters) * values, set the query to filter out records whose value for the given * dimension does not match any of the provided values. * - * @param \DataWarehouse\Query\RawQuery $query - * @param array $params containing a `filters` key whose value is an + * @param RawQuery $query + * @param array $params containing a 'filters' key whose value is an * associative array of dimensions and dimension * values. - * @return \DataWarehouse\Query\RawQuery the query with the filters - * applied. + * @return RawQuery the query with the filters applied. */ - private static function setRawDataQueryFilters($query, $params) + private function setRawDataQueryFilters(RawQuery $query, array $params): RawQuery { if (is_array($params['filters']) && count($params['filters']) > 0) { $f = new stdClass(); $f->{'data'} = []; foreach ($params['filters'] as $dimension => $values) { foreach ($values as $value) { - $f->{'data'}[] = (object) [ + $f->{'data'}[] = (object)[ 'id' => "$dimension=$value", 'value_id' => $value, 'dimension_id' => $dimension, @@ -2569,31 +2593,103 @@ private static function setRawDataQueryFilters($query, $params) /** * Validate a specific filter from the `filters` parameter of a request to - * the raw data endpoint (@see getRawData()), and return the parsed array - * of values for that filter (e.g., `foo,bar,baz` becomes `['foo', 'bar', - * 'baz']`). - * - * @param Request $request - * @param array $queryDescripters the set of dimensions the user is + * the raw data endpoint (@param array $queryDescripters the set of dimensions the user is * authorized to see based on their assigned * ACLs. * @param string $filterKey the label of a dimension. - * @param string $filerValuesStr a comma-separated string. + * @param string $filterValuesStr a comma-separated string. * @return array - * @throws BadRequestHttpException if the filter key is an invalid - * dimension name. + * @throws BadRequestHttpException if the filter key is an invalid dimension name. + * @see getRawData()), and return the parsed array + * of values for that filter (e.g., `foo,bar,baz` becomes `['foo', 'bar', + * 'baz']`). + * */ private function validateRawDataFilterParam( - $queryDescripters, - $filterKey, - $filterValuesStr - ) { + array $queryDescripters, + string $filterKey, + string $filterValuesStr + ): array + { if (!in_array($filterKey, array_keys($queryDescripters))) { - throw new BadRequestHttpException( - 'Invalid filter key \'' . $filterKey . '\'.' - ); + throw new BadRequestHttpException('Invalid filter key \'' . $filterKey . '\'.', null); + } + return explode(',', $filterValuesStr); + } + + /** + * Helper function that creates a Response object that will result in a file download on the client. + * + * @param string $content + * @param string $filename + * @param string|null $mimetype + * @return Response + */ + protected function sendAttachment(string $content, string $filename, string $mimetype = null): Response + { + if ($mimetype === null) { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mimetype = $finfo->buffer($content); } - $filterValuesArray = explode(',', $filterValuesStr); - return $filterValuesArray; + + $response = new Response( + $content, + Response::HTTP_OK, + ['Content-Type' => $mimetype] + ); + $response->headers->set( + 'Content-Disposition', + $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename + ) + ); + + return $response; + } + + /** + * Endpoint to get the maximum number of rows that can be returned in a + * single response from the raw data endpoint + * + * + * + * @param Request $request + * + * @return JsonResponse + * + * @throws Exception if there is no setting for 'rest_raw_row_limit' in the 'datawarehouse' section of + * portal_settings.ini. + */ + #[Route('/warehouse/raw-data/limit', methods: ['GET'])] + #[Route('{prefix}/warehouse/raw-data/limit', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getRawDataLimit(Request $request): JsonResponse + { + parent::authenticateToken($request); + + $limit = $this->getConfiguredRawDataLimit(); + + return $this->json([ + 'success' => true, + 'data' => $limit + ]); + } + + /** + * Get the value configured in the portal settings for the maximum number + * of rows that can be returned in a single response from the raw data + * endpoint. + * + * @return int + * @throws Exception if the 'datawarehouse' section and/or the + * 'rest_raw_row_limit' option have not been set in the + * portal configuration. + */ + private function getConfiguredRawDataLimit(): int + { + return intval(getConfiguration( + 'datawarehouse', + 'rest_raw_row_limit' + )); } } diff --git a/src/Controller/WarehouseExportController.php b/src/Controller/WarehouseExportController.php new file mode 100644 index 0000000000..58e6cc90a8 --- /dev/null +++ b/src/Controller/WarehouseExportController.php @@ -0,0 +1,391 @@ + '.*'])] +class WarehouseExportController extends BaseController +{ + /** + * + */ + private const LOG_MODULE = 'data-warehouse-export'; + + + /** + * @var RealmManager + */ + private $realmManager; + + /** + * @var QueryHandler + */ + private $queryHandler; + + /** + * @throws Exception if unable to instantiate the logger. + */ + public function __construct(LoggerInterface $logger, Environment $twig, Tokens $tokenHelper) + { + parent::__construct($logger, $twig, $tokenHelper); + + $this->realmManager = new RealmManager(); + $this->queryHandler = new QueryHandler($this->logger); + } + + + /** + * + * @param Request $request + * @return Response + * @throws Exception if user is not authorized to access this route. + */ + #[Route('/realms', methods: ['GET'])] + public function getRealms(Request $request): Response + { + $user = null; + + // We need to wrap the token authentication because we want the token authentication to be optional, proceeding + // to the normal session authentication if a token is not provided. + try { + $user = $this->tokenHelper->authenticateToken($request); + } catch (Exception $e) { + // NOOP + } + + if ($user === null) { + $user = $this->authorize($request); + } + + $config = RawStatisticsConfiguration::factory(); + + $realms = array_map( + function ($realm) use ($config) { + $name = $realm->getName(); + return [ + 'id' => $name, + 'name' => $realm->getDisplay(), + 'fields' => $config->getBatchExportFieldDefinitions($name) + ]; + }, + $this->realmManager->getRealmsForUser($user) + ); + + return $this->json( + [ + 'success' => true, + 'data' => array_values($realms), + 'total' => count($realms) + ] + ); + } + + /** + * @param Request $request + * @return Response + * @throws Exception + */ + #[Route('/requests', methods: ['GET'])] + public function getRequests(Request $request): Response + { + $user = $this->authorize($request); + $results = $this->queryHandler->listUserRequestsByState($user->getUserId()); + return $this->json( + [ + 'success' => true, + 'data' => $results, + 'total' => count($results) + ] + ); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception if user is not authorized to access this route. + */ + #[Route('/request', methods: ['POST'])] + public function createRequest(Request $request): Response + { + $this->logger->debug('Creating Request'); + $user = $this->authorize($request); + + $this->logger->debug('User is Authenticated'); + + $realm = $this->getStringParam($request, 'realm', true); + + $realms = array_map( + function ($realm) { + return $realm->getName(); + }, + $this->realmManager->getRealmsForUser($user) + ); + if (!in_array($realm, $realms)) { + $this->logger->debug('Invalid Realm'); + throw new BadRequestHttpException('Invalid realm'); + } + $this->logger->debug('Realm is valid'); + + $startDate = $this->getDateFromISO8601Param($request, 'start_date', true); + $endDate = $this->getDateFromISO8601Param($request, 'end_date', true); + $now = new DateTime(); + + if ($startDate > $now) { + $this->logger->debug('Start Date is invalid'); + throw new BadRequestHttpException('Start date cannot be in the future'); + } + + $this->logger->debug('Start Date is valid.'); + + if ($endDate > $now) { + $this->logger->debug('End Date is invalid'); + throw new BadRequestHttpException('End date cannot be in the future'); + } + + $this->logger->debug('End Date is valid'); + + $interval = $startDate->diff($endDate); + + if ($interval === false) { + $this->logger->debug('Interval is Invalid'); + throw new BadRequestHttpException('Failed to calculate date interval'); + } + $this->logger->debug('Interval is valid'); + + if ($interval->invert === 1) { + $this->logger->debug('Interval is invalid'); + throw new BadRequestHttpException('Start date must be before end date'); + } + + $format = strtoupper($this->getStringParam($request, 'format', true)); + + if (!in_array($format, ['CSV', 'JSON'])) { + $this->logger->debug('Format is invalid'); + throw new BadRequestHttpException('format must be CSV or JSON'); + } + + try { + $this->logger->debug('Creating Export Request'); + $id = $this->queryHandler->createRequestRecord( + $user->getUserID(), + $realm, + $startDate->format('Y-m-d'), + $endDate->format('Y-m-d'), + $format + ); + } catch (Exception $e) { + $this->logger->debug('Failed to create export request'); + throw new BadRequestHttpException('Failed to create export request'); + } + + $this->logger->debug('Created Export Request'); + return $this->json([ + 'success' => true, + 'message' => 'Created export request', + 'data' => [['id' => $id]], + 'total' => 1 + ]); + } + + /** + * + * + * @param Request $request + * @param int $id + * @return Response + * @throws Exception if the user is not authorized for this route. + * @throws NotFoundHttpException if there were no requests for the provided id. + * @throws NotFoundHttpException if the file for the request identified by the provided id is not found on the file system. + * @throws BadRequestHttpException if the request that corresponds to the provided id is not in the Available state. + * @throws AccessDeniedHttpException if the file for the request identified by the provided id is not readable. + */ + #[Route('/download/{id}', requirements: ["id" => "\d+"], methods: ['GET'])] + public function getExportedDataFile(Request $request, int $id): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $user = $this->authorize($request); + + $requests = array_filter( + $this->queryHandler->listUserRequestsByState($user->getUserID()), + function ($request) use ($id) { + return $request['id'] == $id; + } + ); + + if (count($requests) === 0) { + throw new NotFoundHttpException('Export request not found'); + } + + // Using `array_shift` because `array_filter` preserves keys so the + // request may not be at index 0. + $request = array_shift($requests); + + if ($request['state'] !== 'Available') { + throw new BadRequestHttpException('Requested data is not available'); + } + + $fileManager = new FileManager(); + $file = $fileManager->getExportDataFilePath($id); + + if (!is_file($file)) { + throw new NotFoundHttpException('Exported data not found'); + } + + if (!is_readable($file)) { + throw new AccessDeniedHttpException('Exported data is not readable'); + } + + $this->logger->info('Sending data warehouse export file'); + + if ($request['downloaded_datetime'] === null) { + $this->queryHandler->updateDownloadedDatetime($request['id']); + } + return new BinaryFileResponse( + $file, + 200, + [ + 'Content-type' => 'application/zip', + 'Content-Disposition' => sprintf( + 'attachment; filename="%s"', + $fileManager->getZipFileName($request) + ) + ] + ); + } + + /** + * + * @param Request $request + * @return Response + * @throws Exception if the user is not authorized to access this route. + * @throws BadRequestHttpException if the provided request ids are not in a json decodable format + * @throws BadRequestHttpException if the provided request ids are not in a json array. + * @throws BadRequestHttpException if any of the provided request ids are not integers. + * @throws HttpException if the sql delete operation fails. + * @throws NotFoundHttpException if any of the provided request ids are not found. + * + */ + #[Route('/requests', methods: ['DELETE'])] + public function deleteRequests(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + $user = $this->authorize($request); + + $requestIds = []; + + try { + $this->logger->debug(var_export($request->request->all(), true)); + $requestIds = json_decode($request->get('ids')); + $this->logger->debug(sprintf('Request ids: %s', var_export($requestIds, true))); + if ($requestIds === null) { + throw new Exception('Failed to decode JSON'); + } + + if (!is_array($requestIds)) { + throw new Exception('Export request IDs must be in an array'); + } + + try { + $requestIds = array_map( + function ($value) { + return is_int($value) ? $value : intval($value); + }, + $requestIds + ); + } catch (Exception $e) { + throw new Exception('Export request IDs must integers'); + } + + } catch (Exception $e) { + return $this->json(buildError('Malformed HTTP request content: ' . $e->getMessage())); + } + + try { + $dbh = DB::factory('database'); + $dbh->beginTransaction(); + + foreach ($requestIds as $id) { + $count = $this->queryHandler->deleteRequest($id, $user->getUserId()); + if ($count === 0) { + throw new NotFoundHttpException('Export request not found'); + } + + $this->logger->info('Deleted data warehouse export request'); + } + + $dbh->commit(); + } catch (NotFoundHttpException $e) { + $dbh->rollBack(); + throw $e; + } catch (Exception $e) { + $dbh->rollBack(); + throw new HttpException(500, 'Failed to delete export requests'); + } + + return $this->json([ + 'success' => true, + 'message' => 'Deleted export requests', + 'data' => array_map( + function ($id) { + return ['id' => $id]; + }, + $requestIds + ), + 'total' => count($requestIds) + ]); + } + + /** + * + * @param Request $request + * @param string $id + * @return Response + * @throws Exception + */ + #[Route('/request/{id}', requirements: ["id" => "\w+"], methods: ['DELETE'])] + public function deleteRequest(Request $request, string $id): Response + { + $user = $this->authorize($request); + + $count = $this->queryHandler->deleteRequest($id, $user->getUserID()); + + if ($count === 0) { + throw new NotFoundHttpException('Export request not found'); + } + + $this->logger->info('Deleted data warehouse export request'); + + return $this->json([ + 'success' => true, + 'message' => 'Deleted export request', + 'data' => [['id' => $id]], + 'total' => 1 + ]); + } + +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000000..ad5dc14e33 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,183 @@ +username = $username; + $this->roles = $roles; + $this->userId = $userId; + $this->token = $token; + $this->password = $password; + $this->salt = $salt; + } + + + /** + * @inheritDoc + **/ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + if (in_array('mgr', $this->roles)) { + $roles[] = 'ROLE_ALLOWED_TO_SWITCH'; + $roles[] = 'ROLE_ADMIN'; + } + + return array_unique($roles); + } + + /** + * @inheritDoc + **/ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * {@inheritDoc} + */ + public function getSalt(): ?string + { + return $this->salt; + } + + /** + * @inheritDoc + **/ + public function eraseCredentials(): void + { + // TODO: Implement eraseCredentials() method. + } + + /** + * @return string + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * @inheritDoc + **/ + public function getUserIdentifier(): string + { + return $this->username; + } + + /** + * @return int + */ + public function getUserId(): int + { + return $this->userId; + } + + /** + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * @return bool + */ + public function isPublicUser(): bool + { + return in_array('pub', $this->roles); + } + + /** + * @return User + */ + public static function getPublicUser(): User + { + return new User('Public User', ['pub']); + } + + /** + * @param \XDUser $xdUser + * @return User + */ + public static function fromXDUser(\XDUser $xdUser): User + { + return new User( + $xdUser->getUsername(), + $xdUser->getRoles(), + $xdUser->getUserID(), + $xdUser->getToken(), + $xdUser->getPassword() + ); + } + + /** + * @param array $attributes + * @return void + */ + public function setSamlAttributes(array $attributes): void + { + $this->samlAttributes = $attributes; + } +} diff --git a/src/Errors/ErrorController.php b/src/Errors/ErrorController.php new file mode 100644 index 0000000000..5c54ece7e1 --- /dev/null +++ b/src/Errors/ErrorController.php @@ -0,0 +1,51 @@ +logger = $logger; + parent::__construct($kernel, $controller, $errorRenderer); + } + + /** + * Specifically designed to work with instances of FlattenException. We return a JsonResponse due to XDMoD expecting + * errors in this way. + * + * @param Throwable $exception + * @return Response + */ + public function __invoke(Throwable $exception): Response + { + $this->logger->error('Error Controller Erroring!'); + $headers = []; + if (method_exists($exception, 'getHeaders')) { + $headers = $exception->getHeaders(); + } + + $message = $exception->getMessage(); + $userPos = strpos($message, 'User'); + $alreadyExistsPos = strpos($message, 'already exists'); + if ($userPos && $alreadyExistsPos) { + return new RedirectResponse('/'); + } + + return new JsonResponse(buildError($exception), 200, $headers); + } + +} diff --git a/src/EventListeners/LogoutListener.php b/src/EventListeners/LogoutListener.php new file mode 100644 index 0000000000..8878ed1c4f --- /dev/null +++ b/src/EventListeners/LogoutListener.php @@ -0,0 +1,29 @@ +logger = $logger; + } + public function onLogout(LogoutEvent $event): void + { + $this->logger->debug('*** Logging Out w/ Logout Listener *** '); + $request = $event->getRequest(); + $token = $request->getSession()->get('xdmod_token'); + \XDSessionManager::logoutUser($token); + $request->getSession()->invalidate(); + } +} + diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000000..6a516fe765 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,26 @@ +logger = $logger; + $this->httpUtils = $httpUtils; + $this->urlGenerator = $urlGenerator; + $this->options = array_merge([ + 'username_parameter' => 'username', + 'password_parameter' => 'password', + 'check_paths' => ['xdmod_login', 'xdmod_new_login'], + 'failure_path' => 'xdmod_home', + 'post_only' => true, + 'form_only' => true, + ], $options); + } + + + /** + * This method is overwritten because we specifically only want this Authenticator to apply when the request is a + * POST with a content-type of application/x-www-form-urlencoded w/ a path that matches our `check_path`. + * + * @param Request $request + * @return bool + */ + public function supports(Request $request): bool + { + $postOnly = (!$this->options['post_only'] || $request->isMethod('POST')); + $formOnly = (!$this->options['form_only'] || 'form' === $request->getContentTypeFormat()); + if ($request->attributes->has('_route')) { + $requestPath = $request->attributes->get('_route'); + } else { + $requestPath = $request->getPathInfo(); + } + $this->logger->debug('Checking Path', [$requestPath]); + + $found = false; + foreach ($this->options['check_paths'] as $checkPath) { + $requestPathMatches = $this->httpUtils->checkRequestPath($request, $checkPath); + if ($requestPathMatches) { + $found = true; + break; + } + } + + $this->logger->debug('Checking if FormLoginAuthenticator supports request', [$postOnly, $found, $formOnly]); + + return $postOnly && $found && $formOnly; + } + + /** + * Create a passport for the current request. + * + * The passport contains the user, credentials and any additional information + * that has to be checked by the Symfony Security system. For example, a login + * form authenticator will probably return a passport containing the user, the + * presented password and the CSRF token value. + * + * You may throw any AuthenticationException in this method in case of error (e.g. + * a UserNotFoundException when the user cannot be found). + * + * @param Request $request + * @return Passport + */ + public function authenticate(Request $request): Passport + { + $this->logger->debug('Initiating Form Login Authentication', [$request]); + + $credentials = $this->getCredentials($request); + $this->logger->debug('Attempting to login user ' . $credentials['username'], $credentials); + + return new Passport( + new UserBadge($credentials['username']), + new PasswordCredentials($credentials['password']), + [new RememberMeBadge()] + ); + } + + /** + * Retrieve user credentials from the provided Request. Validates that the username length is less than or equal to + * Security::MAX_USERNAME_LENGTH and if not it throws a BadCredentialsException. If credentials are able to be + * successfully retrieved and they are valid than the Security::LAST_USERNAME session variable is set to the + * retrieved username. + * + * @param Request $request + * @return array containing the username / password retrieved from the provided Request. + * @throws BadRequestHttpException if the username parameter is not a string, or if it's an object that does not provide a __toString method. + * @throws BadCredentialsException if the provided username is longer than Security::MAX_USERNAME_LENGTH. + */ + private function getCredentials(Request $request): array + { + $credentials = []; + + if ($this->options['post_only']) { + $credentials['username'] = ParameterBagUtils::getParameterBagValue($request->request, $this->options['username_parameter']); + $credentials['password'] = ParameterBagUtils::getParameterBagValue($request->request, $this->options['password_parameter']) ?? ''; + } else { + $credentials['username'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['username_parameter']); + $credentials['password'] = ParameterBagUtils::getRequestParameterValue($request, $this->options['password_parameter']) ?? ''; + } + + if (!\is_string($credentials['username']) && (!\is_object($credentials['username']) || !method_exists($credentials['username'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); + } + + $credentials['username'] = trim($credentials['username']); + + if (\strlen($credentials['username']) > Security::MAX_USERNAME_LENGTH) { + $this->logger->error('Username is to long', $credentials); + throw new BadCredentialsException('Invalid username.'); + } + + $request->getSession()->set(Security::LAST_USERNAME, $credentials['username']); + + return $credentials; + } + + /** + * We do the translation from Symfony User to XDUser here by looking for an XDUser that has the same username as + * the authenticated Symfony User. When found, we set the `xdUser` session variable equal to the XDUser's user id. + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the last page they visited. + * + * If you return null, the current request will continue, and the user + * will be authenticated. This makes sense, for example, with an API. + * + * @param Request $request + * @param TokenInterface $token + * @param string $firewallName + * @return Response + * @throws \Exception if unable to find an XDUser that matches the provided Symfony User + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + $user = $token->getUser(); + $xdUser = XDUser::getUserByUserName($user->getUserIdentifier()); + $xdUser->postLogin(); + $request->getSession()->set('xdUser', $xdUser->getUserID()); + $response = new JsonResponse([ + 'success' => true, + 'results' => [ + 'token' => $xdUser->getToken(), + 'name' => $xdUser->getFormalName() + ] + ]); + $response->headers->setCookie(new Cookie('xdmod_token', $xdUser->getToken())); + return $response; + } + + /** + * Return the URL to the login page. + * @param Request $request + * @return string the login url that this FormLoginAuthenticator supports. + */ + protected function getLoginUrl(Request $request): string + { + return $this->httpUtils->generateUri($request, $this->options['check_path']); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + if ($request->hasSession()) { + $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception); + } + return new JsonResponse([], 401); + } + + /** + * This is required for the Authenticator to be set as an entrypoint. We need to set an entrypoint because we have + * multiple authenticators setup for our main firewall ( FormLoginAuthenticator, TokenAuthenticator, SSOAuthenticator ) + * + * @param Request $request + * @param AuthenticationException|null $authException + * @return RedirectResponse + */ + public function start(Request $request, AuthenticationException $authException = null): Response + { + return new RedirectResponse($this->urlGenerator->generate('xdmod_home')); + } +} diff --git a/src/Security/Authenticators/SimpleSamlPhpAuthenticator.php b/src/Security/Authenticators/SimpleSamlPhpAuthenticator.php new file mode 100644 index 0000000000..8d71e2b589 --- /dev/null +++ b/src/Security/Authenticators/SimpleSamlPhpAuthenticator.php @@ -0,0 +1,174 @@ +logger = $logger; + $this->httpUtils = $httpUtils; + $this->urlGenerator = $urlGenerator; + $this->parameters = $parameters; + + $this->sources = Source::getSources(); + $this->logger->debug('Auth Sources', [$this->sources]); + if (!empty($this->sources)) { + try { + $authSource = \xd_utilities\getConfiguration('authentication', 'source'); + $this->logger->debug('Found Auth Source', [$authSource]); + } catch (\Exception $e) { + $authSource = null; + } + if (!is_null($authSource) && array_search($authSource, $this->sources) !== false) { + $this->authSourceName = $authSource; + $this->authSource = new \SimpleSAML\Auth\Simple($authSource); + } else { + $this->authSourceName = $this->sources[0]; + $this->authSource = new \SimpleSAML\Auth\Simple($this->authSourceName); + } + } + } + + + public function supports(Request $request): ?bool + { + $referer = $request->headers->get('referer'); + $this->logger->info('Checking if Authenticator supports request', [$referer]); + return $referer === $this->parameters->get('sso')['login_link']; + } + + public function authenticate(Request $request): Passport + { + if ($this->authSource->isAuthenticated()) { + $attributes = $this->authSource->getAttributes(); + $username = $attributes['username'][0]; + $logger = $this->logger; + return new SelfValidatingPassport( + new UserBadge( + $username, + function($userName, $samlAttributes) use ($logger) { + $logger->debug('Loading SimpleSAMLPHP User'); + + function getOrganizationId($samlAttrs, $personId) + { + if ($personId !== -1 ) { + return Organizations::getOrganizationIdForPerson($personId); + } elseif(!empty($samlAttrs['organization'])) { + return Organizations::getIdByName($samlAttrs['organization'][0]); + } + return -1; + } + + $xdmodUserId = \XDUser::userExistsWithUsername($userName); + $logger->debug('XDMoD UserID ', [$xdmodUserId]); + if ($xdmodUserId !== INVALID) { + $user = \XDUser::getUserByID($xdmodUserId); + $user->setSSOAttrs($samlAttributes); + return User::fromXDUser($user); + } + $logger->debug('Creating New SSO User!'); + // If we've gotten this far then we're creating a new user. Proceed with gathering the + // information we'll need to do so. + $emailAddress = isset($samlAttributes['email_address']) ? $samlAttributes['email_address'][0] : NO_EMAIL_ADDRESS_SET; + $systemUserName = isset($samlAttributes['system_username']) ? $samlAttributes['system_username'][0] : $userName; + $firstName = isset($samlAttributes['first_name']) ? $samlAttributes['first_name'][0] : 'UNKNOWN'; + $middleName = isset($samlAttributes['middle_name']) ? $samlAttributes['middle_name'][0] : null; + $lastName = isset($samlAttributes['last_name']) ? $samlAttributes['last_name'][0] : null; + $personId = \DataWarehouse::getPersonIdFromPII($systemUserName, $samlAttributes['organization'][0]); + + // Attempt to identify which organization this user should be associated with. Prefer + // using the personId if not unknown, then fall back to the saml attributes if the + // 'organization' property is present, and finally defaulting to the Unknown organization + // if none of the preceding conditions are met. + $userOrganization = getOrganizationId($samlAttributes, $personId); + + try { + $newUser = new \XDUser( + $userName, + null, + $emailAddress, + $firstName, + $middleName, + $lastName, + array(ROLE_ID_USER), + ROLE_ID_USER, + $userOrganization, + $personId, + $samlAttributes + ); + } catch (\Exception $e) { + throw new \Exception('An account is currently configured with this information, please contact an administrator.'); + } + + $newUser->setUserType(SSO_USER_TYPE); + + try { + $newUser->saveUser(); + } catch (\Exception $e) { + $this->logger->error('User creation failed: ' . $e->getMessage()); + throw $e; + } + + return User::fromXDUser($newUser); + }, + $attributes + ) + ); + } + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + $this->logger->info('SimpleSAMLPHP Authentication Succeeded!'); + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $this->logger->info('SimpleSAMLPHP Authentication Failed!', [$exception]); + } + + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + return new RedirectResponse($this->urlGenerator->generate('xdmod_home')); + } +} diff --git a/src/Security/Helpers/Tokens.php b/src/Security/Helpers/Tokens.php new file mode 100644 index 0000000000..f8ee9909af --- /dev/null +++ b/src/Security/Helpers/Tokens.php @@ -0,0 +1,170 @@ +logger = $logger; + } + + /** + * Perform token authentication for the provided $userId & $token combo. If the authentication is successful, an + * XDUser object will be returned for the provided $userId. If not, an exception will be thrown. + * + * @param int|string $userId The id used to look up the the users hashed token. + * @param string $password The value to be checked against the retrieved hashed token. + * + * @return XDUser for the provided $userId, if the authentication is successful else an exception will be thrown. + * + * @throws Exception if unable to retrieve a database connection. + * @throws UnauthorizedHttpException if no token can be found for the provided $userId, + * if the stored token for $userId has expired, or + * if the provided $token doesn't match the stored hash. + */ + public function authenticate($userId, string $password): ?XDUser + { + $this->logger->info(sprintf('Beginning Authentication for %s', $userId)); + + $db = DB::factory('database'); + $query = <<query($query, array(':user_id' => $userId)); + + if (count($row) === 0) { + $this->logger->debug('User (%s) does not have an active token.'); + throw new UnauthorizedHttpException(Tokens::HEADER_KEY, 'Invalid API token.'); + } + + $expectedToken = $row[0]['token']; + $expiresOn = $row[0]['expires_on']; + $dbUserId = $row[0]['user_id']; + + // Check that expected token isn't expired. + $now = new DateTime(); + $expires = new DateTime($expiresOn); + if ($expires < $now) { + $this->logger->debug(sprintf('User\'s (%s) token is expired.', $userId)); + throw new UnauthorizedHttpException(Tokens::HEADER_KEY, 'Token has expired.', null, 0); + } + + // finally check that the provided token matches it's stored hash. + if (!password_verify($password, $expectedToken)) { + $this->logger->debug(sprintf('User\'s (%s) token is invalid.', $userId)); + throw new UnauthorizedHttpException(Tokens::HEADER_KEY, 'Invalid token.'); + } + + // and if we've made it this far we can safely return the requested Users data. + return XDUser::getUserByID($dbUserId); + } + + /** + * This function is a stop-gap that is meant to be used to protect controller endpoints until they can be moved to + * the new REST stack. + * + * @return XDUser|null if the authentication is successful then an XDUser instance for the authenticated user will + * be returned, if the authentication is not successful then null will be returned. + */ + public function authenticateToken(Request $request): ?XDUser + { + $this->logger->info('Beginning Token Authentication'); + + $rawToken = self::getRawToken($request); + if (empty($rawToken)) { + // we want to the token authentication to be optional so instead of throwing an exception we return null. + // This allows us to provide token authentication to existing endpoints without impeding their normal use. + return null; + } + + // We expect the token to be in the form /^(\d+).(.*)$/ so just make sure it at least has the required delimiter. + $delimPosition = strpos($rawToken, Tokens::DELIMITER); + if ($delimPosition === false) { + // Same as above, token authentication is optional so we return null instead of throwing an exception. + return null; + } + + $userId = substr($rawToken, 0, $delimPosition); + $token = substr($rawToken, $delimPosition + 1); + + try { + return Tokens::authenticate($userId, $token); + } catch (Exception $e) { + // and again, same as above. + return null; + } + } + + /** + * Attempt to retrieve the raw API Token from one of the following sources: + * - Headers + * - GET Parameters + * - POST Parameters + * + * @return null|string returns the api token if found else it returns null. + */ + private function getRawToken(Request $request): ?string + { + // Try to find the token in the `Authorization` header. + $headers = getallheaders(); + if (!empty($headers['Authorization'])) { + $authorizationHeader = $headers['Authorization']; + if (is_string($authorizationHeader) && strpos($authorizationHeader, Tokens::HEADER_KEY) !== false) { + $this->logger->info('Valid token found in Header'); + // The format for including the token in the header is slightly different then when included as a get or + // post parameter. Here the value will be in the form: `Bearer ` + return substr( + $authorizationHeader, + strpos($authorizationHeader, Tokens::HEADER_KEY) + strlen(Tokens::HEADER_KEY) + 1 + ); + } + + } + + // If it's not in the headers, try $_GET + if (isset($_GET[Tokens::HEADER_KEY]) && is_string($_GET[Tokens::HEADER_KEY])) { + return $_GET[Tokens::HEADER_KEY]; + } + + if (isset($_POST[Tokens::HEADER_KEY]) && is_string($_POST[Tokens::HEADER_KEY])) { + return $_POST[Tokens::HEADER_KEY]; + } + + return null; + } +} diff --git a/src/Security/PasswordHashers/DefaultPasswordHasher.php b/src/Security/PasswordHashers/DefaultPasswordHasher.php new file mode 100644 index 0000000000..1a2324a232 --- /dev/null +++ b/src/Security/PasswordHashers/DefaultPasswordHasher.php @@ -0,0 +1,28 @@ +logger = $logger; + } + + /** + * {@inheritDoc} + */ + public function refreshUser(UserInterface $user): UserInterface + { + $this->logger->debug('Refreshing User', [$user]); + try { + return User::fromXDUser(XDUser::getUserByUserName($user->getUserIdentifier())); + } catch (\Exception $e) { + throw new UserNotFoundException(sprintf('No user found for username %s', $user->getUserIdentifier()), $e->getCode(), $e); + } + } + + /** + * {@inheritDoc} + */ + public function supportsClass(string $class): bool + { + return $class === User::class || is_subclass_of($class, User::class); + } + + /** + * {@inheritDoc} + */ + public function loadUserByUsername(string $username): UserInterface + { + try { + return User::fromXDUser( XDUser::getUserByUserName($username)); + } catch (\Exception $e) { + throw new UserNotFoundException(sprintf('No user found for username %s', $username), $e->getCode(), $e); + } + + } + + /** + * {@inheritDoc} + */ + public function loadUserByIdentifier(string $identifier): UserInterface + { + $user = XDUser::getUserByToken($identifier); + + if (null === $user) { + throw new UserNotFoundException(); + } + + return User::fromXDUser($user); + } +} diff --git a/src/Security/UsernameUserProvider.php b/src/Security/UsernameUserProvider.php new file mode 100644 index 0000000000..6722daf386 --- /dev/null +++ b/src/Security/UsernameUserProvider.php @@ -0,0 +1,146 @@ +logger = $logger; + } + + + /** + * @inheritDoc + */ + public function refreshUser(UserInterface $user): UserInterface + { + $this->logger->debug('Refreshing User ' . $user->getUserIdentifier(), [$user]); + try { + return $user; + } catch (\Exception $e) { + throw new UserNotFoundException($e->getMessage()); + } + + } + + /** + * @inheritDoc + */ + public function supportsClass(string $class): bool + { + return $class === User::class || is_subclass_of($class, User::class); + } + + /** + * @inheritDoc + * @throws \Exception + */ + public function loadUserByIdentifier(string $identifier): UserInterface + { + + $this->logger->debug("Loading User By Identifier: $identifier"); + $isSamlUser = $this->classesContains('saml', (new \Exception())->getTrace()); + try { + $user = XDUser::getUserByUserName($identifier); + + if ($isSamlUser && $user->getUserType() !== SSO_USER_TYPE) { + $this->logger->error('SSO User attempted to log in as a local user.'); + throw new InsufficientAuthenticationException(); + } + } catch (\Exception $e) { + $this->logger->debug("Loading User By Id instead"); + $user = XDUser::getUserByID($identifier); + if ($isSamlUser && isset($user) && $user->getUserType() !== SSO_USER_TYPE) { + $this->logger->error('SSO User attempted to log in as a local user.'); + throw new InsufficientAuthenticationException(); + } + if (!isset($user)) { + $this->logger->debug(sprintf('User %s not found', $identifier)); + throw new UserNotFoundException("Unable to find User identified by $identifier"); + } + } + + $this->logger->debug("XDUser found by username: {$user->getUserID()} {$user->getUsername()}"); + $foundUser = User::fromXDUser($user); + $this->logger->debug(sprintf('Final User Found: %s %s', $foundUser->getUserIdentifier(), $foundUser->getPassword())); + return $foundUser; + } + + /** + * @inerhitDoc + */ + public function loadUserByUsername(string $username): ?UserInterface + { + $this->logger->debug("Loading User By Username: $username"); + return $this->loadUserByIdentifier($username); + } + + /** + * Upgrades the hashed password of a user, typically for using a better hash algorithm. + * + * This method should persist the new password in the user storage and update the $user object accordingly. + * Because you don't want your users not being able to log in, this method should be opportunistic: + * it's fine if it does nothing or if it fails without throwing any exception. + */ + public function upgradePassword(UserInterface|PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + $this->logger->debug('Attempting to upgrade password'); + } + + /** + * @param $classPart + * @param $trace + * @return bool + */ + private function classesContains($classPart, $trace): bool + { + if (is_null($classPart)) { + return false; + } + $classes = $this->getCallingClasses($trace); + foreach($classes as $class) { + if (is_null($class)) { + continue; + } + $pos = strpos(strtolower($class), strtolower($classPart)); + if ($pos !== false && is_numeric($pos)) { + return true; + } + } + return false; + } + + private function getCallingClasses($trace): array + { + return array_reduce( + $trace, + function ($carry, $item) { + $value = array_key_exists('class', $item) ? $item['class'] : null; + $carry[] = $value; + return $carry; + }, + [] + ); + } +} diff --git a/symfony.lock b/symfony.lock new file mode 100644 index 0000000000..97e718351b --- /dev/null +++ b/symfony.lock @@ -0,0 +1,202 @@ +{ + "doctrine/annotations": { + "version": "2.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.10", + "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" + } + }, + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "87424683adc81d7dc305eefec1fced883084aab9" + } + }, + "doctrine/doctrine-bundle": { + "version": "2.15", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.10", + "ref": "d1778a69711a9b06bb4e202977ca6c4a0d16933d" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "doctrine/doctrine-migrations-bundle": { + "version": "3.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.1", + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" + }, + "files": [ + "config/packages/doctrine_migrations.yaml", + "migrations/.gitignore" + ] + }, + "google/recaptcha": { + "version": "1.3", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.1", + "ref": "e5a4aa21f2e98d7440ae9aab6b56e307f99dd084" + }, + "files": [ + "config/packages/google_recaptcha.yaml" + ] + }, + "nyholm/psr7": { + "version": "1.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2" + }, + "files": [ + "config/packages/nyholm_psr7.yaml" + ] + }, + "phpunit/phpunit": { + "version": "9.6", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "9.6", + "ref": "6a9341aa97d441627f8bd424ae85dc04c944f8b4" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php" + ] + }, + "symfony/console": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/flex": { + "version": "2.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/framework-bundle": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "32126346f25e1cee607cc4aa6783d46034920554" + }, + "files": [ + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "html/index.php", + "src/Controller/.gitignore", + "src/Kernel.php" + ] + }, + "symfony/maker-bundle": { + "version": "1.64", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" + } + }, + "symfony/monolog-bundle": { + "version": "3.10", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.7", + "ref": "aff23899c4440dd995907613c1dd709b6f59503f" + }, + "files": [ + "config/packages/monolog.yaml" + ] + }, + "symfony/routing": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.2", + "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6" + }, + "files": [ + "config/packages/routing.yaml", + "config/routes.yaml" + ] + }, + "symfony/security-bundle": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "2ae08430db28c8eb4476605894296c82a642028f" + }, + "files": [ + "config/packages/security.yaml", + "config/routes/security.yaml" + ] + }, + "symfony/twig-bundle": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877" + }, + "files": [ + "config/packages/twig.yaml", + "templates/base.html.twig" + ] + }, + "symfony/web-profiler-bundle": { + "version": "6.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.1", + "ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7" + }, + "files": [ + "config/packages/web_profiler.yaml", + "config/routes/web_profiler.yaml" + ] + } +} diff --git a/templates/about/federated.html.twig b/templates/about/federated.html.twig new file mode 100644 index 0000000000..b8af89466f --- /dev/null +++ b/templates/about/federated.html.twig @@ -0,0 +1,65 @@ +

    Federated Open XDMoD

    +

    + Federated XDMoD supports the collection and aggregation of data from a number of fully-functional and individually + managed XDMoD instances into a single federated instance of XDMoD capable of displaying federation-wide metrics. + Each participating institution deploys an XDMoD instance through which local data will be collected and shipped to a + central Federation Hub where it is aggregated to provide a federation-wide view of the data. + Data particular to an individual center is available from the Hub by applying filters and drill-downs. +

    +

    +

    + Diagram of an example Federated XDMoD deployment +
    + + + Example data flow from heterogeneous computing resources to an XDMoD federated hub. + XDMoD instances X and Y ingest data into their databases from the computing resources that they monitor. + Following ingestion on the satellite instances, job data are replicated to the federated hub's database, + where they are aggregated for use in the federated XDMoD user interface. + + +
    +
    +

    +

    + A simple example use of the federated module is: + Three academic institutions each with their own HPC resource. + Each institution has its own XDMoD instance which contains the accounting data for only their HPC resource. + These institutions federate their data to a central hub. + HPC accounting data for all three HPC resources is shown on the central hub. + This central hub can then be used to report on the combined data. +

    +

    + This example illustrates only one use case. + The federated module supports cloud data as well as HPC. Support for other data realms is planned. + There are no pre-defined limits on the number of instances that can be part of a federation. +

    +

    + For more information see Section II of Federating XDMoD to + Monitor Affiliated Computing Resources. +

    +

    + Documentation available at https://federated.xdmod.org. +

    +

    + Source code and downloads at https://github.com/ubccr/xdmod-federated. +

    +{% if federated_role is not empty %} + {% if federated_role == 'instance' %} +

    This instance is part of a federation

    + Federation Hub: {{ hub_url }} + {% elseif federaged_role == 'hub' %} +

    Instances that are part of this Federation

    +
      + {% for instance in instances %} +
    • +

      {{ instance['url'] }}

      + last event retrieved ({{ instance['lastCloudEvent'] }}) +
    • + {% endfor %} +
    + {% endif %} +{% else %} + This installation is not part of a federation. +{% endif %} + diff --git a/templates/about/links.html.twig b/templates/about/links.html.twig new file mode 100644 index 0000000000..7a70163336 --- /dev/null +++ b/templates/about/links.html.twig @@ -0,0 +1,40 @@ +

    Links

    + + + + + + + + + + + + + +
    + + + + + + + +
    + + + + + + + +
    + + + + + + + +
    + diff --git a/templates/about/open_xdmod.html.twig b/templates/about/open_xdmod.html.twig new file mode 100644 index 0000000000..7dc9d1156f --- /dev/null +++ b/templates/about/open_xdmod.html.twig @@ -0,0 +1,44 @@ +

    Open XDMoD

    +
    +

    While initially focused on the NSF XSEDE program, an open source version of XDMoD that provides similar functionality + for academic and industrial HPC centers is available and undergoing continued development, namely Open XDMoD. Open + XDMoD for use by academic and industrial HPC centers is available for download through GitHub (http://open.xdmod.org).

    +

    Highlights include:

    +
      +
    • A graphical user interface with extensive graphic and analytical capability.
    • +
    • Detailed utilization metrics including number of jobs, CPU hours, wait times, job size, etc.
    • +
    • Customizable Metric Explorer where users can generate custom plots comparing multiple metrics
    • +
    • A custom report builder for the automatic generation of detailed periodic reports.
    • +
    • Support for resource managers includes
    • +
        +
      • SLURM, SGE/UGE, PBS/TORQUE/PBS Pro, LSF
      • +
      +
    • Optional modules supported
    • + +
    +
    + + + + + + + + + + + + + + + +
    +
    Fig.1 Open Source XDMoD Summary Tab

    +
    Fig.2 Open Source XDMoD Usage Tab

    diff --git a/templates/about/presentations.html.twig b/templates/about/presentations.html.twig new file mode 100644 index 0000000000..7109122b39 --- /dev/null +++ b/templates/about/presentations.html.twig @@ -0,0 +1,148 @@ + +

    Presentations

    +
    + +
    PEARC '25
    +
      +
    • Nikolay A. Simakov. "Enhancing an HPC Resources Modeling Framework with a Realistic, Slurm-Like, HPC Resource Model". Presentation available at doi:10.13140/RG.2.2.16351.98724.
    • +
    +
    Supercomputing 2024 (SC24), Atlanta, GA
    +
      +
    • Nikolay A. Simakov. "Benchmarking and Continuous Performance Monitoring of HPC Resources using the XDMoD Application Kernel Module." SIGHPC Systems Professionals Workshop HPCSYSPROS24 at SC24. November 22, 2024. The presentation is available at doi:10.13140/RG.2.2.13362.62409.
    • +
    + +
    2024-12-12 Internet2 Technical Exchange: Boston, MA
    +
      +
    • Jennifer Schopf, "Understanding Globus Data Transfers with NetSage"
    • +
    + +
    ACCESS Resource Provider Workshop September 2024
    +
      +
    • Aaron Weeden, "What We Do in ACCESS Metrics"
    • +
    + +
    PEARC24: Providence, RI
    +
      +
    • Nikolay A. Simakov, "Modeling Users on High-Performance Computing Resource"
    • +
    • Tom Furlani, "ACCESS Metrics Overview and Career Guidance"
    • +
    + +
    Research Computing at Smaller Institutions Conference, Swarthmore College, June 2024
    +
      +
    • Joseph White, "Making the Case: Monitoring and Metrics"
    • +
    + +
    ACCESS Resource Provider Forum May 2024
    +
      +
    • Aaron Weeden, "Plans for reporting on NAIRR Pilot usage"
    • +
    + +
    HPC Asia 2024: Nagoya, Japan
    +
      +
    • N.A. Simakov, "First Impressions of the NVIDIA Grace CPU Superchip and NVIDIA Grace Hopper Superchip and Scientific Workloads"
    • +
    + +
    2023-10-26 ACCESS RP Forum (virtual)
    +
      +
    • How to leverage ACCESS XDMoD to facilitate Campus Champion support for campus researchers
    • +
    + +
    2023-09-19 Campus Champions All Champions Call (virtual)
    +
      +
    • How to leverage ACCESS XDMoD to facilitate Resource Provider Operations
    • +
    + +
    Metrics2023: Denver, CO
    +
      +
    • Dr. Abani Patra, "Measuring Performance and Usage - Evolution of the Measuring and Monitoring of NSF Supercomputing"
    • +
    • N.A. Simakov, "Feasibility of Application-Agnostic Performance per Currency Metric on an Example of Gromacs, a Molecular Dynamics Application"
    • +
    • Aaron Weeden, "The Data Analytics Framework for XDMoD"
    • +
    + +
    PEARC23: Portland, OR
    +
      +
    • Open OnDemand, XDMoD, and ColdFront: an HPC center management toolset (tutorial)
    • +
    • Introduction to CI usage and performance data analysis with XDMoD and the new Analytics Framework. (tutorial)
    • +
    • N.A Simakov, "The Taming of the Wolf - how to use the Ookami Cray Apollo 80 system and Fujitsu A64FX processors" (workshop)
    • +
    • Dr. Jennifer M. Schopf, Doug Southworth, "EPOC Support for Cyberinfrastructure and Data Movement" (Panel discussion)
    • +
    + +
    Cray User Group meeting (CUG) 2023 in Helsinki, Finland, May 7 – 11, 2023
    +
      +
    • N.A. Simakov, "Benchmarking High-End ARM Systems with Scientific Applications. Performance and Energy Efficiency"
    • +
    + +
    ISC High Performance 2023 (ISC23): Hamburg, Germany
    + + +
    ARM HPC User Group (AHUG) Symposium at SC 2022
    +
      +
    • N.A. Simakov, “Are we ready for broader adoption of ARM in the HPC community: Benchmarks and Applications on High-End ARM Systems with XDMoD Application Kernels”
    • +
    + +
    PEARC22: Boston, MA
    + + +
    PEARC21: (virtual)
    + + +
    Supercomputing 2020 (SC'20): Atlanta, GA (virtual), November 18, 2020
    + + +
    Gateways20: Bethesda, MD (virtual), October 13, 2020
    + + +
    NYSERNet 2020: (virtual), October 2, 2020
    + + +
    PEARC20: Portland, OR (virtual)
    + + +
    PEARC19: Chicago, IL
    + + +
    2018-09-05 Research Computing Campus Champions Presentation
    + + +
    SC17: Denver, CO
    + + +
    SC16: Salt Lake City, UT
    + + +
    XSEDE16: Miami, FL
    + + +
    XSEDE15: Saint Louis, MO
    + diff --git a/html/about/publications.html b/templates/about/publications.html.twig similarity index 100% rename from html/about/publications.html rename to templates/about/publications.html.twig diff --git a/templates/about/roadmap.html.twig b/templates/about/roadmap.html.twig new file mode 100644 index 0000000000..8b1625342e --- /dev/null +++ b/templates/about/roadmap.html.twig @@ -0,0 +1,17 @@ +{% if header is not empty %} +

    {{ header }}

    +{% endif %} + +{% if url is not empty %} + +{% else %} +
    +
    +

    Roadmap Not Configured

    +

    + Please contact your Systems Administrator if you believe this is + in error. +

    +
    +
    +{% endif %} diff --git a/templates/about/supremm.html.twig b/templates/about/supremm.html.twig new file mode 100644 index 0000000000..b514a17112 --- /dev/null +++ b/templates/about/supremm.html.twig @@ -0,0 +1,24 @@ + +

    SUPReMM Program

    +
    +

    The SUPReMM program is designed integrate job level performance data into the XDMoD framework so it is available for detailed analysis. Initially an independently funded NSF program, SUPReMM was subsequently merged into the TAS program. The goal of the SUPReMM program is to develop the TACC_Stats and Lariat data sources and pipe this data into the XDMoD data warehouse.

    +

    Lariat captures application information at the time that jobs are launched. TACC_Stats uses collectors sampled at the beginning and end of every job and at 10 minute intervals to provide a wide variety of job performance information including memory, I/O file data, CPU data and network data. Accordingly, with this data system personnel will have at their fingertips detailed performance data for every job that runs on the HPC resource. Starting with XDMoD 4.0, this job performance information has been available in the XDMoD SUPReMM data realm.

    +
    + + + + + + + + + + + + + + + +
    Fig 1. SUPReMM data workflow diagram
    +
    Fig 2. Serial Data Copy causing a large dropoff of performance.
    + diff --git a/templates/about/team.html.twig b/templates/about/team.html.twig new file mode 100644 index 0000000000..39a157553d --- /dev/null +++ b/templates/about/team.html.twig @@ -0,0 +1,27 @@ +

    XMS Team

    +
    +

    University at Buffalo

    +

    Dr. Matthew D. Jones: coPI, XMS Technical Project Manager

    +

    Dr. Robert L. DeLeon: XMS Project Manager

    +

    Dr. Joseph P. White: Job level performance data integration & analytics and XDMoD data warehouse

    +

    Mr. Jeffrey T. Palmer: Open XDMoD development, XDMoD portal development and XDMoD data warehouse development

    +

    Dr. Nikolay Simakov: Application kernel development and performance data modeling

    +

    Mr. Gregary Dean: XDMoD portal development

    +

    Mr. Ryan Rathsam: XDMoD portal development and XDMoD data warehouse development

    +

    Ms. Hannah Taylor: XDMoD portal development

    +

    Mr. Conner Saeli: Job level performance data integration

    +
    +

    Roswell Park Cancer Institute

    +

    Dr. Thomas R. Furlani: PI, Oversees XMS Program

    +

    Mr. Steven M. Gallo: coPI, Oversees development and implementation of the XDMoD portal infrastructure

    +
    +

    Tufts University

    +

    Dr. Abani Patra: coPI

    +
    +

    Indiana University

    +

    Dr. Gregor von Laszewski: coPI, Scientific Impact Analysis

    +

    Mr. Fugang Wang: Scientific Impact Analysis

    +
    +

    Texas Advanced Computing Center

    +

    Dr. Todd Evans: Job level performance data integration

    +

    Dr. Bill Barth: Job level performance data integration

    diff --git a/templates/about/xdmod.html.twig b/templates/about/xdmod.html.twig new file mode 100644 index 0000000000..2748dfa787 --- /dev/null +++ b/templates/about/xdmod.html.twig @@ -0,0 +1,47 @@ +
    + +
    +

    XDMoD: Comprehensive HPC System Management Tool

    +
    + +

    The University at Buffalo Center for Computational Research (CCR) has been at the forefront of the development of + open source tools for use by national and campus level high performance computing (HPC) centers to help ensure their + optimal operation as well as provide metrics to demonstrate the utility, service, competitive advantage, and return + on investment that these centers provide.

    +

    The XDMoD (XD Metrics on Demand) tool provides HPC center personnel and senior leadership with the ability to easily + obtain detailed operational metrics of HPC systems coupled with extensive analytical capability to optimize + performance at the system and job level, ensure quality of service, and provide accurate data to guide system + upgrades and acquisitions.

    +
    + + + + + + + +
    XDMoD Summary Tab

    +
    +

    + Funded by the National Science Foundation, XDMoD (https://xdmod.ccr.buffalo.edu/) + is designed to audit and facilitate the operation and utilization of XSEDE, the most advanced and robust collection + of + integrated advanced digital resources and services in the world. Similarly, Open XDMoD (http://open.xdmod.org), the open + source version of XDMoD, is designed to provide similar capability to academic and industrial HPC centers.

    + +

    + + When referencing XDMoD, please cite the following publication:

    + Jeffrey T. Palmer, Steven M. Gallo, Thomas R. Furlani, Matthew D. Jones, Robert L. DeLeon, Joseph P. White, + Nikolay Simakov, Abani K. Patra, Jeanette Sperhac, Thomas Yearke, Ryan Rathsam, Martins Innus, Cynthia D. + Cornelius, James C. Browne, William L. Barth, Richard T. Evans, + "Open XDMoD: A Tool for the Comprehensive Management of High-Performance Computing Resources", + Computing in Science & Engineering, Vol 17, Issue 4, 2015, pp. 52-62. + 10.1109/MCSE.2015.68 +
    +

    + +

    XDMoD Version: {{ xdmod_version }}

    diff --git a/html/about/release_notes/xdmod.html b/templates/about/xdmod_release_notes.html.twig similarity index 100% rename from html/about/release_notes/xdmod.html rename to templates/about/xdmod_release_notes.html.twig diff --git a/templates/apache.conf b/templates/apache.conf index d6203529eb..27019c8afa 100644 --- a/templates/apache.conf +++ b/templates/apache.conf @@ -1,34 +1,3 @@ -# TEMPLATE Apache configuration file for Open XDMoD. This file should -# be copied to the Apache configuration directory and -# edited to specify the correct site-specific settings. -# -# On Rocky 8 and RHEL 8, this file should be copied -# to: -# /etc/httpd/conf.d/xdmod.conf -# -# For other Linux distributions consult the distribtion documentation -# to determine the path to the webserver configuration files. -# -# This template file must be modified to update site specific settings: -# -# The ServerName setting should be updated. -# -# The SSLCertificateFile and SSLCertificateKeyFile settings should -# be updated to specify paths to the valid SSL certificates for the -# site. -# -# Optionally the port number in the VirtualHost section can be updated -# from 443 to the desired listen port. -# -# The server name and port number in the Apache configuration must match -# the site_address and user_manual settings in the Open XDMoD portal_settings.ini -# configuration file. -# - -# If the server is not already configured to listen on port 443 then the -# following Listen command should be uncommented. -#Listen 443 - # The ServerName and ServerAdmin parameters should be updated. ServerName localhost @@ -52,32 +21,22 @@ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" DocumentRoot /usr/share/xdmod/html - - Options FollowSymLinks - AllowOverride All - DirectoryIndex index.php - - - Require all granted - - - - - RewriteEngine On - RewriteRule (.*) index.php [L] + AllowOverride None + Require all granted + FallbackResource /index.php ## SimpleSAML Single Sign On authentication. - #SetEnv SIMPLESAMLPHP_CONFIG_DIR /etc/xdmod/simplesamlphp/config - #Alias /simplesaml /usr/share/xdmod/vendor/simplesamlphp/simplesamlphp/www - # - # Options FollowSymLinks - # AllowOverride All - # - # Require all granted - # - # +# SetEnv SIMPLESAMLPHP_CONFIG_DIR /usr/share/xdmod/vendor/simplesamlphp/simplesamlphp/config +# Alias /simplesaml /usr/share/xdmod/vendor/simplesamlphp/simplesamlphp/public +# +# Options FollowSymLinks +# AllowOverride All +# +# Require all granted +# +# # Update the path to rotatelogs if it is different on your system. ErrorLog "|/usr/sbin/rotatelogs -n 5 /var/log/xdmod/apache-error.log 1M" diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000000..1b7fe10fe8 --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,18 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {{ encore_entry_link_tags('app') }} + {% endblock %} + + {% block javascripts %} + {{ encore_entry_script_tags('app') }} + {% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/templates/emails/new_user.html.twig b/templates/emails/new_user.html.twig new file mode 100644 index 0000000000..741292c19e --- /dev/null +++ b/templates/emails/new_user.html.twig @@ -0,0 +1,12 @@ +Welcome to the {{ page_title }}. Your account has been created. + +Your username is: {{ username }} + +Please visit the following page to create your password: + +{{ site_address }}password_reset.php?mode=new?rid={{ rid }} + +For assistance on using the portal, please consult the User Manual: +{{ site_address }}user_manual + +The XDMoD Team \ No newline at end of file diff --git a/templates/emails/password_reset.html.twig b/templates/emails/password_reset.html.twig new file mode 100644 index 0000000000..10071fc63b --- /dev/null +++ b/templates/emails/password_reset.html.twig @@ -0,0 +1,15 @@ +Dear {{ first_name }}, + +Your username is: {{ username }} + +To reset your password, please navigate to the following link: + +{{ reset_link }} + +This link will expire at: {{ expiration }}. + +(Please note that once you update your password, the above link will no +longer be valid) + +Sincerely, +{{ maintainer_signature }} diff --git a/templates/index.html.twig b/templates/index.html.twig new file mode 100644 index 0000000000..783f66e910 --- /dev/null +++ b/templates/index.html.twig @@ -0,0 +1,390 @@ + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if user is not null %} + + {% endif %} + + + + {% if user is not null %} + + {% endif %} + + + + + + + + + + + + + + + + + + + + + {% if user is not null %} + + {% endif %} + + {% if user is not null %} + + {% endif %} + + {% if user is not null %} + + + + + + + + + + + {% endif %} + + + + + + + + + + + + + + {% if user is not null %} + + {% endif %} + + + + + + + + + + + {% if user is null %} + + {% endif %} + + + + {# Profile Editor #} + {% if user is not null %} + + + + + + {% endif %} + + + + + + + + + + {% if user is not null %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + + {% if user is not null %} + + {% endif %} + + + + + + + + {% if user is not null %} + + + + + + {% endif %} + + + + + + + + + + {% if user is not null %} + + {% endif %} + + + + + + + + + + + + + + + {% if user is not null %} + + + {% endif %} + + + + + {% if user is null %} + + {% endif %} + + + + {% if user is not null %} + + {% endif %} + + + {% if user is not null and user_dashboard %} + + {% else %} + + {% endif %} + + + + {% if user is not null %} + + + {% endif %} + + + {% if user is not null %} + + + + {% if raw_data_realms|length > 0 %} + + + + + + + + + + + + + + + + {% endif %} + {# From gaq/xdmod.php #} + + {% endif %} + + {% if use_center_logo %} + + {% endif %} + + + {% if user is not null and not is_public_user %} + {{ asset_paths | raw }} + {% endif %} + + + + + {% if user is not null %} + + {% endif %} + {% if captcha_site_key|length > 0 %} + + {% endif %} + + + + + +
    + + +
    + + +
    + + + + diff --git a/templates/internal_dashboard.html.twig b/templates/internal_dashboard.html.twig new file mode 100644 index 0000000000..5cceaced62 --- /dev/null +++ b/templates/internal_dashboard.html.twig @@ -0,0 +1,200 @@ + + + + + XDMoD Internal Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if user is not null and not is_public_user %} + {{ asset_paths | raw }} + {% endif %} + + + + + {% if has_app_kernels %} + + {% endif %} + + + diff --git a/templates/internal_dashboard_login.html.twig b/templates/internal_dashboard_login.html.twig new file mode 100644 index 0000000000..f2c02792d0 --- /dev/null +++ b/templates/internal_dashboard_login.html.twig @@ -0,0 +1,135 @@ + + + + + XDMoD Internal Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + XDMoD Internal Dashboard

    + + + + + + + + + + + + + + + + + + + + + + +
    Please Sign In Below
    Username: + +
    Password: + +
    + +
    +
    + + + diff --git a/templates/password_reset.html.twig b/templates/password_reset.html.twig new file mode 100644 index 0000000000..c64d1c8fce --- /dev/null +++ b/templates/password_reset.html.twig @@ -0,0 +1,108 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Password ImageGo To XDMoD +
    + +
    + +

    + Welcome, {{ first_name }}. To {{ mode }} your password, supply a new password below and click + on Update.

    + +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ mode | capitalize }} Your + Password
    Password: + + 5 characters min.
      + + + + + +
    +
    password not specified
    +
    +
    Password Again: + + 5 characters min. +
    + +
    +
    + +
    + +
    + + + diff --git a/templates/password_reset_expired.html.twig b/templates/password_reset_expired.html.twig new file mode 100644 index 0000000000..6999812c7d --- /dev/null +++ b/templates/password_reset_expired.html.twig @@ -0,0 +1,35 @@ + + + + + {{ title }} + + + + + + + +
    Go To XDMoD +
    + +
    + +

    + + The page you are trying to access has already expired.

    + If you still need to reset your password, visit the login page and + click on Problem Logging In? below the login prompt. +
    + +
    + + + diff --git a/tests/artifacts/xdmod/controllers/input/enum_target_addresses-update_enum_user_types_and_roles.json b/tests/artifacts/xdmod/controllers/input/enum_target_addresses-update_enum_user_types_and_roles.json index 7a0a4f4cc0..a371d8f6ee 100644 --- a/tests/artifacts/xdmod/controllers/input/enum_target_addresses-update_enum_user_types_and_roles.json +++ b/tests/artifacts/xdmod/controllers/input/enum_target_addresses-update_enum_user_types_and_roles.json @@ -404,7 +404,7 @@ "expected": { "file": "enum_target_addresses__-update_enum_user_types_and_roles", "http_code": 200, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } } ], @@ -429,7 +429,7 @@ "expected": { "file": "enum_target_addresses_rand_char(120)_rand_char(120)-update_enum_user_types_and_roles", "http_code": 200, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } } ], diff --git a/tests/artifacts/xdmod/controllers/output/enum_target_addresses__-update_enum_user_types_and_roles.json b/tests/artifacts/xdmod/controllers/output/enum_target_addresses__-update_enum_user_types_and_roles.json index 9be6195f7d..83861e0213 100644 --- a/tests/artifacts/xdmod/controllers/output/enum_target_addresses__-update_enum_user_types_and_roles.json +++ b/tests/artifacts/xdmod/controllers/output/enum_target_addresses__-update_enum_user_types_and_roles.json @@ -1,5 +1,5 @@ { - "success": true, - "count": 0, - "response": [] + "success": true, + "count": 0, + "response": [] } diff --git a/tests/artifacts/xdmod/user_admin/input/get_user_visits.json b/tests/artifacts/xdmod/user_admin/input/get_user_visits.json index cb1c84da86..080c31b1fa 100644 --- a/tests/artifacts/xdmod/user_admin/input/get_user_visits.json +++ b/tests/artifacts/xdmod/user_admin/input/get_user_visits.json @@ -29,7 +29,7 @@ }, "success": false, "output": "get_user_visits_quarter_1", - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -62,7 +62,7 @@ }, "output": "get_user_visits_quarter_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -95,7 +95,7 @@ }, "output": "get_user_visits_quarter_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -128,7 +128,7 @@ }, "output": "get_user_visits_quarter_3", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -161,7 +161,7 @@ }, "output": "get_user_visits_quarter_3_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -194,7 +194,7 @@ }, "output": "get_user_visits_quarter_3_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -227,7 +227,7 @@ }, "output": "get_user_visits_quarter_3_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -260,7 +260,7 @@ }, "output": "get_user_visits_quarter_5", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -293,7 +293,7 @@ }, "output": "get_user_visits_quarter_5_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -326,7 +326,7 @@ }, "output": "get_user_visits_quarter_5_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -359,7 +359,7 @@ }, "output": "get_user_visits_quarter_5_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -392,7 +392,7 @@ }, "output": "get_user_visits_quarter_5_3", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -425,7 +425,7 @@ }, "output": "get_user_visits_quarter_5_3_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -458,7 +458,7 @@ }, "output": "get_user_visits_quarter_5_3_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -491,7 +491,7 @@ }, "output": "get_user_visits_quarter_5_3_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -524,7 +524,7 @@ }, "output": "get_user_visits_quarter_700", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -557,7 +557,7 @@ }, "output": "get_user_visits_quarter_700_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -590,7 +590,7 @@ }, "output": "get_user_visits_quarter_700_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -623,7 +623,7 @@ }, "output": "get_user_visits_quarter_700_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -656,7 +656,7 @@ }, "output": "get_user_visits_quarter_700_3", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -689,7 +689,7 @@ }, "output": "get_user_visits_quarter_700_3_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -722,7 +722,7 @@ }, "output": "get_user_visits_quarter_700_3_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -755,7 +755,7 @@ }, "output": "get_user_visits_quarter_700_3_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -788,7 +788,7 @@ }, "output": "get_user_visits_quarter_700_5", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -821,7 +821,7 @@ }, "output": "get_user_visits_quarter_700_5_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -854,7 +854,7 @@ }, "output": "get_user_visits_quarter_700_5_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -887,7 +887,7 @@ }, "output": "get_user_visits_quarter_700_5_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -920,7 +920,7 @@ }, "output": "get_user_visits_quarter_700_5_3", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -953,7 +953,7 @@ }, "output": "get_user_visits_quarter_700_5_3_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -986,7 +986,7 @@ }, "output": "get_user_visits_quarter_700_5_3_2", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ], [ @@ -1019,7 +1019,7 @@ }, "output": "get_user_visits_quarter_700_5_3_2_1", "success": false, - "content_type": "text/html; charset=UTF-8" + "content_type": "application/json" } ] ] diff --git a/tests/ci/samlSetup.sh b/tests/ci/samlSetup.sh index 08e6bcc7fc..9c9b2d8af7 100755 --- a/tests/ci/samlSetup.sh +++ b/tests/ci/samlSetup.sh @@ -9,229 +9,302 @@ DEFAULT_VENDOR_DIR=$DEFAULT_INSTALL_DIR/vendor INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR} VENDOR_DIR=${VENDOR_DIR:-$DEFAULT_VENDOR_DIR} -httpd -k stop -cd /tmp - -echo "installing saml idp server" -if [ -f $CACHE_FILE ]; -then - echo "using cached copy" - tar -zxf $CACHE_FILE - cd saml-idp -else - git clone https://github.com/mcguinness/saml-idp/ - cd saml-idp - git checkout 8ff807a91f4badc3c0a10551e1d789df140a66cc - rm -f package-lock.json - npm set progress=false - npm install --quiet --silent -fi +HOSTNAME="localhost:8080" +# valid values: local, keycloak +DEFAULT_TYPE=local +while getopts h:t: flag +do + case "${flag}" in + h) HOSTNAME=${OPTARG};; + t) TYPE=${DEFAULT_TYPE:-${OPTARG}};; + *) echo "Invalid argument"; exit 1; + esac +done -openssl req -x509 -new -newkey rsa:2048 -nodes -subj '/C=US/ST=New York/L=Buffalo/O=UB/CN=CCR Test Identity Provider' -keyout idp-private-key.pem -out idp-public-cert.pem -days 7300 - -cat > /tmp/saml-idp/config.js <% %" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# Options FollowSymLinks% Options FollowSymLinks%" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# AllowOverride All% AllowOverride All%" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# % %" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# Require all granted% Require all granted%" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# % %" /etc/httpd/conf.d/xdmod.conf + sed -i -- "s%# % %" /etc/httpd/conf.d/xdmod.conf + + # Copy in the default SimplesSamlPHP config file. + log "SimpleSamlPHP" "Copying SimpleSamlPHP config file into place" + cp "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php.dist" "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php" + + log "SimpleSamlPHP" "Configuring trusted url domains and a default admin password for testing." + # Ensure that we add `localhost` and 'xdmod' to the trusted url domains + sed -i -- "s|'trusted.url.domains' => \[\]|'trusted.url.domains' => \['localhost', 'xdmod'\]|g" "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php" + + # Change the default password so that we can test authentication + sed -i -- "s%'auth.adminpassword' => '123'%'auth.adminpassword' => 'zaq12wsx'%" "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php" + + log "SimpleSamlPHP" "Copying authsources config into place." + # Create the authsources config file for saml Authentication + cat > "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/authsources.php" < array( + 'saml:SP', + 'entityID' => 'https://$HOSTNAME/xdmod-sp', + 'idp' => 'urn:example:idp', + //'signature.algorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + 'authproc' => array( + 40 => array( + 'class' => 'core:AttributeMap', + 'email' => 'email_address', + 'firstName' => 'first_name', + 'middleName' => 'middle_name', + 'lastName' => 'last_name', + 'personId' => 'person_id', + 'orgId' => 'organization', + 'fieldOfScience' => 'field_of_science', + 'itname' => 'username' + ) + ) + ), + 'admin' => array( + // The default is to use core:AdminPassword, but it can be replaced with + // any authentication source. + 'core:AdminPassword', + ), + ); EOF -sed -i -- "s%#Alias /simplesaml $DEFAULT_VENDOR_DIR/simplesamlphp/simplesamlphp/www%Alias /simplesaml $VENDOR_DIR/simplesamlphp/simplesamlphp/www%" /etc/httpd/conf.d/xdmod.conf -sed -i -- "s%#%%" /etc/httpd/conf.d/xdmod.conf -sed -i -- 's/# Options FollowSymLinks/ Options FollowSymLinks/' /etc/httpd/conf.d/xdmod.conf -sed -i -- 's/# AllowOverride All/ AllowOverride All/' /etc/httpd/conf.d/xdmod.conf -sed -i -- 's/# / /' /etc/httpd/conf.d/xdmod.conf -sed -i -- 's/# Require all granted/ Require all granted/' /etc/httpd/conf.d/xdmod.conf -sed -i -- 's%# % %' /etc/httpd/conf.d/xdmod.conf -sed -i -- 's%#%%' /etc/httpd/conf.d/xdmod.conf - - -cp "$VENDOR_DIR/simplesamlphp/simplesamlphp/config-templates/config.php" "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php" -sed -i -- "s/'trusted.url.domains' => array(),/'trusted.url.domains' => array('localhost'),/" "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/config.php" - -cat > "$VENDOR_DIR/simplesamlphp/simplesamlphp/config/authsources.php" < array( - 'saml:SP', - 'idp' => 'urn:example:idp', - //'signature.algorithm' => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', - 'authproc' => array( - 40 => array( - 'class' => 'core:AttributeMap', - 'email' => 'email_address', - 'firstName' => 'first_name', - 'middleName' => 'middle_name', - 'lastName' => 'last_name', - 'personId' => 'person_id', - 'orgId' => 'organization', - 'fieldOfScience' => 'field_of_science', - 'itname' => 'username' + log "SimpleSamlPHP" "Retrieving the new x509 Cert to be used in SimpleSamlPHP" + NEW_CERT=`sed -n '2,21p' idp-public-cert.pem | perl -ne 'chomp and print'` + + log "SimpleSamlPHP" "Copying config file for SimpleSamlPHP remote IDP" + cat > "$VENDOR_DIR/simplesamlphp/simplesamlphp/metadata/saml20-idp-remote.php" < 'urn:example:idp', + 'contacts' => + array ( ), - 60 => array( - 'class' => 'authorize:Authorize', - 'username' => array( - '/\S+/' + 'metadata-set' => 'saml20-idp-remote', + 'SingleSignOnService' => + array ( + 0 => + array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://$HOSTNAME:7000', + ), + 1 => + array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://$HOSTNAME:7000', ), ), - 61 => array( - 'class' => 'authorize:Authorize', - 'organization' => array( - '/\S+/' - ) - ) - ) - ), - 'admin' => array( - // The default is to use core:AdminPassword, but it can be replaced with - // any authentication source. - 'core:AdminPassword', - ), -); + 'SingleLogoutService' => + array ( + 0 => + array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'https://$HOSTNAME:7000/signout', + ), + ), + 'ArtifactResolutionService' => + array ( + ), + 'NameIDFormats' => + array ( + 0 => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + 1 => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + 2 => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', + ), + 'keys' => + array ( + 0 => + array ( + 'encryption' => false, + 'signing' => true, + 'type' => 'X509Certificate', + 'X509Certificate' => '$NEW_CERT', + ), + ), + ); EOF + sleep 1 + log "SimpleSamlPHP" "Starting HTTPD" + httpd -k start +} + +localSSO() { + # For using the SSO locally we need the HOSTNAME to be localhost + #HOSTNAME=localhost:8181 + # For using the SSO via playwright then we need `xdmod` + #HOSTNAME=$(hostname) + + cd /tmp || exit + + log "setup" "installing saml idp server" + if [ -f $CACHE_FILE ]; + then + log "setup" "using cached copy" + tar -zxf $CACHE_FILE + cd saml-idp || exit + else + git clone https://github.com/mcguinness/saml-idp/ + cd saml-idp || exit + git checkout 8ff807a91f4badc3c0a10551e1d789df140a66cc + rm -f package-lock.json + npm set progress=false + npm install --quiet --silent + fi -CERTCONTENTS=`sed -n '2,21p' idp-public-cert.pem | perl -ne 'chomp and print'` -HOSTNAME=$(hostname) - -cat > "$VENDOR_DIR/simplesamlphp/simplesamlphp/metadata/saml20-idp-remote.php" < 'urn:example:idp', - 'contacts' => - array ( - ), - 'metadata-set' => 'saml20-idp-remote', - 'SingleSignOnService' => - array ( - 0 => - array ( - 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - 'Location' => 'https://$HOSTNAME:7000', - ), - 1 => - array ( - 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - 'Location' => 'https://$HOSTNAME:7000', - ), - ), - 'SingleLogoutService' => - array ( - 0 => - array ( - 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - 'Location' => 'https://$HOSTNAME:7000/signout', - ), - ), - 'ArtifactResolutionService' => - array ( - ), - 'NameIDFormats' => - array ( - 0 => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - 1 => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', - 2 => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient', - ), - 'keys' => - array ( - 0 => - array ( - 'encryption' => false, - 'signing' => true, - 'type' => 'X509Certificate', - 'X509Certificate' => '$CERTCONTENTS', - ), - ), -); + log "setup" "Generating new x509 cert" + openssl req -x509 -new -newkey rsa:2048 -nodes -subj '/C=US/ST=New York/L=Buffalo/O=UB/CN=CCR Test Identity Provider' -keyout idp-private-key.pem -out idp-public-cert.pem -days 7300 + + log "setup" "Adding IDP config file" + cat > /tmp/saml-idp/config.js < /var/log/xdmod/samlidp.log 2>&1 & +} + +keycloakSSO() { + echo "" +} + + +if [[ "$TYPE" == 'local' ]]; then + localSSO +elif [ "$TYPE" == "keycloak" ]; then + keycloakSSO +else + echo "You must provide a type of setup ( -t ) to continue "; +fi + + + -node app.js --acs https://$HOSTNAME/simplesaml/module.php/saml/sp/saml2-acs.php/xdmod-sp --aud https://$HOSTNAME/simplesaml/module.php/saml/sp/metadata.php/xdmod-sp --httpsPrivateKey idp-private-key.pem --httpsCert idp-public-cert.pem --https true > /var/log/xdmod/samlidp.log 2>&1 & -httpd -k start diff --git a/tests/component/lib/BaseTest.php b/tests/component/lib/BaseTest.php index 0c99df1bea..c3dbb28113 100644 --- a/tests/component/lib/BaseTest.php +++ b/tests/component/lib/BaseTest.php @@ -154,4 +154,39 @@ protected function arrayFilterKeysRecursive(array $keyList, array $input) } return $tmpArray; } + + /** + * This function provides away to determine if two arrays contain the same data. Ordering of keys will only affect + * the results if the arrays are numerically indexed. + * + * @param array $left + * @param array $right + * @param bool $exact if true, then the values will also be strictly compared. + * @return void this function does not return a value. If the arrays are not the same then it will fail the test, + * w/ the differences between the arrays that were found. + */ + protected function arraysAreSame( array $left, array $right, bool $exact = true) + { + if (count(array_diff(array_keys($left), array_keys($right))) > 0) { + $this->fail('Keys are different'); + } + $differences = []; + foreach($left as $lkey => $lvalue) { + $ltype = gettype($lvalue); + $rtype = gettype($right[$lkey]); + if ($ltype !== $rtype) { + $differences []= sprintf("Expected $lkey to be %s got %s", $ltype, $rtype); + if ($exact && $lvalue !== $right[$lkey]) { + $differences []= sprintf("Expected $lkey value to be %s got %s", $lvalue, $right[$lkey]); + } + } + } + if (count($differences) > 0) { + $this->fail(sprintf( + "Differences Found:\n%s", + implode("\n", $differences) + )); + } + $this->assertTrue(true); + } } diff --git a/tests/component/lib/ETL/IngestorTest.php b/tests/component/lib/ETL/IngestorTest.php index b8ffe16046..8d724beab3 100644 --- a/tests/component/lib/ETL/IngestorTest.php +++ b/tests/component/lib/ETL/IngestorTest.php @@ -48,7 +48,10 @@ public function testLoadDataInfileWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - $this->assertMatchesRegularExpression('/\[warning\]/', $line); + if (empty(trim($line))) { + continue; + } + $this->assertMatchesRegularExpression('/[Ww][Aa][Rr][Nn][Ii][Nn][Gg]/', $line); $numWarnings++; } } @@ -100,7 +103,7 @@ public function testHideSqlWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - $this->assertNotRegExp('/\[warning\]/', $line); + $this->assertDoesNotMatchRegularExpression('/\[warning\]/', $line); } } @@ -288,7 +291,7 @@ private function executeCommand($command) /** * Clean up tables created during the tests * - * @return Nothing + * @return void */ public static function tearDownAfterClass(): void diff --git a/tests/component/lib/Export/FileManagerTest.php b/tests/component/lib/Export/FileManagerTest.php index 101a2325f5..86157809dc 100644 --- a/tests/component/lib/Export/FileManagerTest.php +++ b/tests/component/lib/Export/FileManagerTest.php @@ -150,8 +150,6 @@ public function testWriteDataSetToFile(array $request) ->will($this->onConsecutiveCalls(1, 2, false)); $dataSet->method('valid') ->will($this->onConsecutiveCalls(true, true, false)); - $dataSet->method('next')->willReturn(null); - $dataSet->method('rewind')->willReturn(null); $format = $request['export_file_format']; diff --git a/tests/component/lib/Export/RealmManagerTest.php b/tests/component/lib/Export/RealmManagerTest.php index 4f038ae338..f0d2fa5dcb 100644 --- a/tests/component/lib/Export/RealmManagerTest.php +++ b/tests/component/lib/Export/RealmManagerTest.php @@ -88,11 +88,7 @@ public function testGetRealms($realms) fn($realm) => ['name' => $realm->getName(), 'display' => $realm->getDisplay()], self::$realmManager->getRealms() ); - $this->assertEquals( - $realms, - $actual, - sprintf('Expected: %s, Received: %s', json_encode($realms), json_encode($actual)) - ); + $this->arraysAreSame($realms, $actual); } /** @@ -107,11 +103,7 @@ public function testGetRealmsForUser($role, $realms) fn($realm) => ['name' => $realm->getName(), 'display' => $realm->getDisplay()], self::$realmManager->getRealmsForUser(self::$users[$role]) ); - $this->assertEquals( - $realms, - $actual, - sprintf('Expected: %s, Received: %s', json_encode($realms), json_encode($actual)) - ); + $this->arraysAreSame($realms, $actual); } /** diff --git a/tests/component/lib/XDUserTest.php b/tests/component/lib/XDUserTest.php index ad2fc92398..e0a52448c2 100644 --- a/tests/component/lib/XDUserTest.php +++ b/tests/component/lib/XDUserTest.php @@ -207,7 +207,7 @@ public function testGetRolesCasual() { $user = XDUser::getUserByUserName(self::CENTER_DIRECTOR_USER_NAME); $roles = $user->getRoles('casual'); - $this->assertNull($roles); + $this->assertEmpty($roles); } public function testSetRolesEmpty() diff --git a/tests/integration/lib/BaseTest.php b/tests/integration/lib/BaseTest.php index 2258ecbe98..a7420c1067 100644 --- a/tests/integration/lib/BaseTest.php +++ b/tests/integration/lib/BaseTest.php @@ -891,4 +891,14 @@ private static function truncateStr($str, $numChars) : $str ); } + + protected function log($message) + { + if (getenv('TEST_VERBOSE') === '1') { + echo "\n*****************************\n"; + echo "$message\n"; + } + } + + } diff --git a/tests/integration/lib/Controllers/BaseUserAdminTest.php b/tests/integration/lib/Controllers/BaseUserAdminTest.php index a669ce335a..8d14bac427 100644 --- a/tests/integration/lib/Controllers/BaseUserAdminTest.php +++ b/tests/integration/lib/Controllers/BaseUserAdminTest.php @@ -51,7 +51,7 @@ abstract class BaseUserAdminTest extends BaseTest */ protected $peopleHelper; - protected function setup(): void + protected function setUp(): void { $this->helper = new XdmodTestHelper(); $this->peopleHelper = new PeopleHelper(); @@ -90,7 +90,7 @@ protected static function removeUser($userId, $username) { $helper = new XdmodTestHelper(); - $helper->authenticateDashboard('mgr'); + $helper->authenticate('mgr'); $data = array( 'operation' => 'delete_user', 'uid' => $userId @@ -173,7 +173,7 @@ protected static function removeUser($userId, $username) **/ protected function createUser(array $options) { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); // retrieve required arguments $username = isset($options['username']) ? $options['username'] : null; @@ -200,26 +200,27 @@ protected function createUser(array $options) $userType = isset($options['user_type']) ? $options['user_type'] : self::DEFAULT_USER_TYPE; $output = isset($options['output']) ? $options['output'] : 'test.create.user'; $expectedSuccess = isset($options['expected_success']) ? $options['expected_success'] : true; + $expectedHttpCode = !$expectedSuccess ? 400 : 200; // construct form params for post request to create new user. $data = array( - 'operation' => 'create_user', + 'operation' => 'create_user', 'account_request_id' => '', - 'first_name' => $firstName, - 'last_name' => $lastName, - 'email_address' => $emailAddress, - 'username' => $username, - 'acls' => json_encode( + 'first_name' => $firstName, + 'last_name' => $lastName, + 'email_address' => $emailAddress, + 'username' => $username, + 'acls' => json_encode( $acls ), - 'assignment' => $person, - 'institution' => $institution, - 'user_type' => $userType + 'assignment' => $person, + 'institution' => $institution, + 'user_type' => $userType ); $response = $this->helper->post('controllers/user_admin.php', null, $data); - $this->validateResponse($response); + $this->validateResponse($response, $expectedHttpCode); // retrieve the expected results of submitting the 'create_user' request // with the supplied arguments. @@ -236,12 +237,12 @@ protected function createUser(array $options) // expected have keys in common. $substitutions = array( '$emailAddress' => $emailAddress, - '$username' => $username, - '$userType' => $userType, - '$firstName' => $firstName, - '$lastName' => $lastName, - '$assignment' => $person, - '$institution' => $institution + '$username' => $username, + '$userType' => $userType, + '$firstName' => $firstName, + '$lastName' => $lastName, + '$assignment' => $person, + '$institution' => $institution ); // retrieve the keys that the actual / expected have in common. @@ -262,27 +263,31 @@ protected function createUser(array $options) $userId = $this->retrieveUserId($username); self::$newUsers[$username] = $userId; } - + $this->log("Logging out of mgr session"); // make sure to logout of the current 'mgr' session. - $this->helper->logoutDashboard(); + $this->helper->logout(); return $userId; } - protected function updateCurrentUser($userId, $password = null, $firstName = null, $lastName = null, $emailAddress = null) + protected function updateCurrentUser($username, $password, $firstName = null, $lastName = null, $emailAddress = null) { $helper = new XdmodTestHelper(); - $helper->authenticateDashboard('mgr'); - $loginAsParams = array( - 'uid' => $userId - ); + $this->log("Logging in as Manager!"); + $helper->authenticate('mgr'); // perform the pseudo-login - $helper->get('internal_dashboard/controllers/pseudo_login.php', $loginAsParams); + $this->log("Attempting to Switch Users"); + $switchResult = $helper->get("?_switch_user=$username"); + if ($switchResult[1]['http_code'] !== 200) { + $this->fail("Unable to switch to $username"); + } // build the update user params - $updateUserData = array(); + $updateUserData = [ + '_user_switch' => $username + ]; if (isset($password)) { $updateUserData['password'] = $password; @@ -300,24 +305,30 @@ protected function updateCurrentUser($userId, $password = null, $firstName = nul $updateUserData['email_address'] = $emailAddress; } - $updateUserResponse = $helper->patch( - 'rest/v0.1/users/current', - null, - $updateUserData - ); + $this->log("Attempting to Update User"); + $this->log(var_export($updateUserData, true)); + $updateUserResponse = $helper->patch('rest/users/current', null, $updateUserData); $expected = JSON::loadFile( parent::getTestFiles()->getFile('user_admin', 'test.update_user') ); + $this->log("Validating Update User Response"); $this->validateResponse($updateUserResponse); $this->assertEquals( $expected, $updateUserResponse[0], - "Unable to validate update user response. Expected: " . json_encode($expected). " Received: " . json_encode($updateUserResponse[0]) + "Unable to validate update user response. Expected: " . json_encode($expected) . " Received: " . json_encode($updateUserResponse[0]) ); + $this->log("Switching back"); + $switchBackResult = $helper->get('', ['_switch_user' => '_exit']); + if ($switchBackResult[1]['http_code'] !== 200) { + echo "Switch Back Request unexpectedly failed\n"; + print_r($switchBackResult); + } + $this->log("Logging Out!"); $helper->logout(); } @@ -326,29 +337,30 @@ protected function updateCurrentUser($userId, $password = null, $firstName = nul * arguments. Note that this utilizes the user_admin/update_user operation to do the updating * as opposed to the `updateCurrentUser` function that utilizes the `users/current` rest path. * - * @param int $userId + * @param int $userId * @param string $emailAddress - * @param array $acls - * @param int $assignedPerson - * @param int $institution - * @param int $user_type + * @param array $acls + * @param int $assignedPerson + * @param int $institution + * @param int $user_type * @throws Exception */ protected function updateUser($userId, $emailAddress, $acls, $assignedPerson, $institution, $user_type, $sticky = false) { $data = array( - 'operation' => 'update_user', - 'uid' => $userId, + 'operation' => 'update_user', + 'uid' => $userId, 'email_address' => $emailAddress, - 'acls' => json_encode( + 'acls' => json_encode( $acls ), 'assigned_user' => $assignedPerson, - 'institution' => $institution, - 'user_type' => $user_type, - 'sticky' => $sticky + 'institution' => $institution, + 'user_type' => $user_type, + 'sticky' => $sticky ); - $this->helper->authenticateDashboard('mgr'); + + $this->helper->authenticate('mgr'); $response = $this->helper->post('controllers/user_admin.php', null, $data); @@ -380,14 +392,14 @@ protected function updateUser($userId, $emailAddress, $acls, $assignedPerson, $i */ protected function retrieveUserId($userName, $userGroup = 3) { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $listUsersResponse = $this->helper->post( 'controllers/user_admin.php', null, array( 'operation' => 'list_users', - 'group' => $userGroup + 'group' => $userGroup ) ); @@ -410,7 +422,7 @@ protected function retrieveUserId($userName, $userGroup = 3) } /** - * @param string $userId the `id` of the user whose properties we are retrieving. + * @param string $userId the `id` of the user whose properties we are retrieving. * @param array $properties the set of properties that we want to retrieve from the user. * @return mixed|array An empty array if none of the requested properties are found. If * only one property is requested / found then return the properties @@ -420,14 +432,14 @@ protected function retrieveUserId($userName, $userGroup = 3) */ protected function retrieveUserProperties($userId, array $properties) { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $response = $this->helper->post( 'controllers/user_admin.php', null, array( 'operation' => 'get_user_details', - 'uid' => $userId + 'uid' => $userId ) ); @@ -436,10 +448,9 @@ protected function retrieveUserProperties($userId, array $properties) $user = $response[0]['user_information']; $keys = array_intersect($properties, array_keys($user)); $results = array_intersect_key($user, array_flip($keys)); - $this->helper->logoutDashboard(); - return count($results) === 1 && count($properties) === 1 ? array_pop($results) : $results; + return count($results) === 1 && count($properties) === 1 ? array_pop($results) : $results; } /** @@ -447,8 +458,8 @@ protected function retrieveUserProperties($userId, array $properties) * response. In particular, it asserts that the http-code and content-type * match the provided arguments. * - * @param mixed $response to be validated. - * @param int $expectedHttpCode the http-code that the response is + * @param mixed $response to be validated. + * @param int $expectedHttpCode the http-code that the response is * expected to have. * @param string $expectedContentType the content-type that the response is * expected to have. @@ -457,6 +468,10 @@ protected function validateResponse($response, $expectedHttpCode = 200, $expecte { $actualContentType = $response[1]['content_type']; $actualHttpCode = $response[1]['http_code']; + if ($actualHttpCode !== $expectedHttpCode || $actualContentType !== $expectedContentType) { + print_r($response); + } + $this->assertTrue( strpos($actualContentType, $expectedContentType) !== false, "Expected content-type: $expectedContentType. Received: $actualContentType" diff --git a/tests/integration/lib/Controllers/ControllerTest.php b/tests/integration/lib/Controllers/ControllerTest.php index 86dffd076f..4cd8c67aff 100644 --- a/tests/integration/lib/Controllers/ControllerTest.php +++ b/tests/integration/lib/Controllers/ControllerTest.php @@ -471,7 +471,7 @@ public function testEnumTargetAddresses(array $options) $expectedFile = $expected['file']; $expectedFileName = parent::getTestFiles()->getFile('controllers', $expectedFile); - $expectedContentType = array_key_exists('content_type', $expected) ? $expected['content_type'] : 'text/html; charset=UTF-8'; + $expectedContentType = array_key_exists('content_type', $expected) ? $expected['content_type'] : 'application/json'; $expectedHttpCode = array_key_exists('http_code', $expected) ? $expected['http_code'] : 200; $data = array_merge( @@ -484,7 +484,11 @@ public function testEnumTargetAddresses(array $options) $helper = $options['helper']; $response = $helper->post("internal_dashboard/controllers/mailer.php", null, $data); - + if (($expectedContentType !== $response[1]['content_type']) || ($expectedHttpCode !== $response[1]['http_code'])) { + echo var_export($response, true) . "\n"; + echo var_export($data, true) . "\n"; + echo "$expectedFileName\n"; + } $this->assertEquals($expectedContentType, $response[1]['content_type']); $this->assertEquals($expectedHttpCode, $response[1]['http_code']); @@ -503,7 +507,11 @@ public function testEnumTargetAddresses(array $options) } $expected = JSON::loadFile($expectedFileName); - + if ($expected !== $actual) { + echo var_export($response, true) . "\n"; + echo var_export($data, true) . "\n"; + echo "$expectedFileName\n"; + } $this->assertEquals($expected, $actual); if (isset($options['last'])) { diff --git a/tests/integration/lib/Controllers/MetricExplorerTest.php b/tests/integration/lib/Controllers/MetricExplorerTest.php index c2ea7fd6e3..e0349ef364 100644 --- a/tests/integration/lib/Controllers/MetricExplorerTest.php +++ b/tests/integration/lib/Controllers/MetricExplorerTest.php @@ -136,12 +136,12 @@ public function testInvalidChartRequests() { unset($params['font_size']); - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $output = json_decode($response[0]); $this->assertEquals($output->data[0]->layout->annotations[0]->font->size, "19"); $params['data_series'] = '[object Object]'; - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $this->assertEquals(400, $response[1]['http_code']); } @@ -190,19 +190,19 @@ public function testInvalidRawDataRequests() { $this->helper->authenticate('cd'); unset($params['start_date']); - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $this->assertFalse($response[0]['success']); - $this->assertEquals('missing required start_date parameter', $response[0]['message']); + $this->assertEquals('start_date is a required parameter.', $response[0]['message']); $params['start_date'] = '2016-12-29'; unset($params['end_date']); - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $this->assertFalse($response[0]['success']); - $this->assertEquals('missing required end_date parameter', $response[0]['message']); + $this->assertEquals('end_date is a required parameter.', $response[0]['message']); $params['end_date'] = '2016-12-29'; $params['data_series'] = '[object Object]'; - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $this->assertFalse($response[0]['success']); $this->assertEquals('Invalid data_series specified', $response[0]['message']); } @@ -452,7 +452,7 @@ public function testGetRawData($params, $limit, $shouldHaveTotalAvail) $this->helper->authenticate('cd'); - $response = $this->helper->post('/controllers/metric_explorer.php', null, $params); + $response = $this->helper->post('controllers/metric_explorer.php', null, $params); $this->assertArrayHasKey('data', $response[0]); $this->assertCount($limit, $response[0]['data']); diff --git a/tests/integration/lib/Controllers/ReportBuilderTest.php b/tests/integration/lib/Controllers/ReportBuilderTest.php index 664be88379..7bf176f5f7 100644 --- a/tests/integration/lib/Controllers/ReportBuilderTest.php +++ b/tests/integration/lib/Controllers/ReportBuilderTest.php @@ -58,7 +58,7 @@ protected function setup(): void if (!isset($this->verbose)) { $this->verbose = false; } - $this->helper = new XdmodTestHelper(__DIR__ . '/../../../'); + $this->helper = new XdmodTestHelper(); } public function provideDlReportInputValidation() @@ -72,7 +72,7 @@ public function provideDlReportInputValidation() ); $response = array( 'success' => false, - 'message' => 'Invalid filename' + 'message' => 'Invalid report_loc' ); $tests[] = array($params, $response); @@ -82,7 +82,10 @@ public function provideDlReportInputValidation() 'report_loc' => '3-1614908275-PVe1U', 'format' => 'rar' ); - $response = 'Invalid format specified'; + $response = [ + 'success' => false, + 'message' => 'Invalid format' + ]; $tests[] = array($params, $response); @@ -91,7 +94,7 @@ public function provideDlReportInputValidation() ); $response = array( 'success' => false, - 'message' => '\'report_loc\' not specified.' + 'message' => 'report_loc is a required parameter.' ); $tests[] = array($params, $response); @@ -102,7 +105,7 @@ public function provideDlReportInputValidation() ); $response = array( 'success' => false, - 'message' => '\'format\' not specified.' + 'message' => 'format is a required parameter.' ); $tests[] = array($params, $response); @@ -118,9 +121,9 @@ public function provideDlReportInputValidation() public function testDownloadReportInputValidation($params, $expected) { $this->helper->authenticate('usr'); - $data = $this->helper->get('/controllers/report_builder.php', $params); + $data = $this->helper->get('controllers/report_builder.php', $params); - $response = $this->helper->get('/controllers/report_builder.php', $params); + $response = $this->helper->get('controllers/report_builder.php', $params); $data = $response[0]; $curlinfo = $response[1]; @@ -132,6 +135,9 @@ public function testDownloadReportInputValidation($params, $expected) } } else { // expect text data back + if ('text/html; charset=UTF-8' !== $curlinfo['content_type']) { + echo var_export($response, true) . "\n"; + } $this->assertEquals('text/html; charset=UTF-8', $curlinfo['content_type']); $this->assertEquals($expected, $response[0]); } @@ -166,7 +172,7 @@ public function testEnumAvailableCharts(array $options) 'operation' => $operation ); - $response = $this->helper->post("/controllers/report_builder.php", null, $params); + $response = $this->helper->post("controllers/report_builder.php", null, $params); $this->assertEquals($expected['content_type'], $response[1]['content_type']); $this->assertEquals($expected['http_code'], $response[1]['http_code']); @@ -227,7 +233,7 @@ public function testEnumReports(array $options) 'operation' => $operation ); - $response = $this->helper->post("/controllers/report_builder.php", null, $params); + $response = $this->helper->post("controllers/report_builder.php", null, $params); $this->assertEquals($expected['content_type'], $response[1]['content_type']); $this->assertEquals($expected['http_code'], $response[1]['http_code']); @@ -240,7 +246,9 @@ public function testEnumReports(array $options) } $actual = $response[0]; - + if ($actual !== $expected) { + echo var_export($response, true) . "\n"; + } $this->assertEquals($actual, $expected); if ($user !== 'pub') { @@ -283,10 +291,10 @@ public function testCreateReport(array $options) $this->log("Logged in as $user"); $chartParams = array(); - + $i = 0; foreach ($charts as $chart) { $chartParams = array(); - $this->log("Creating Chart..."); + $this->log("Creating Chart $i..."); // create the chart... $success = $this->createChart($chart); @@ -315,6 +323,7 @@ public function testCreateReport(array $options) $paramString = substr($thumbnailLink, strpos($thumbnailLink, '?') + 1, strlen($thumbnailLink) - strpos($thumbnailLink, '?')); $params = explode('&', $paramString); + $this->log(sprintf("Params:\n %s", var_export($params, true))); $results = array(); foreach ($params as $param) { list($key, $value) = explode('=', $param); @@ -333,25 +342,33 @@ public function testCreateReport(array $options) 'start_date' => $startDate, 'end_date' => $endDate ); - + $this->log('Rendering Report Image'); + $this->log(sprintf("New Params:\n %s", var_export($results, true))); // render the chart image so that a temp file is created on the backend. $this->reportImageRenderer($results); } - + $i += 1; } + $this->log('Rendering Chart Params...'); // render the charts as volatile foreach ($chartParams as $chartData) { + $params = $chartData['params']; $params['type'] = 'volatile'; + $this->log(var_export($params, true)); $this->reportImageRenderer($params); } + $this->log('Done Rendering Chart Params!'); + $this->log('Get new report Name'); // Retrieve the next available report name for this user. $reportName = $this->getNewReportName(); $data['report_name'] = $reportName; + $this->log('Creating Report...'); + $this->log(var_export($data, true)); // Attempt to create the report. $reportId = $this->createReport($data); @@ -457,7 +474,7 @@ public function testEnumTemplates(array $options) } $response = $this->helper->post( - '/controllers/report_builder.php', + 'controllers/report_builder.php', null, array('operation' => 'enum_templates') ); @@ -568,11 +585,17 @@ private function processChartAction(array $data, array $expected) $this->log("Processing Chart Action: $expectedAction"); - $response = $this->helper->post('/controllers/chart_pool.php', null, $data); + $response = $this->helper->post('controllers/chart_pool.php', null, $data); + $this->log('Expected Content-Type: [' . $expectedContentType . ']'); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); + $this->log('Expected HTTP-Code : [' . $expectedHttpCode . ']'); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); + if (($expectedContentType !== $response[1]['content_type']) || + ($expectedHttpCode !== $response[1]['http_code'])) { + echo var_export($response, true) . "\n"; + } $this->assertEquals($expectedContentType, $response[1]['content_type']); $this->assertEquals($expectedHttpCode, $response[1]['http_code']); @@ -581,7 +604,7 @@ private function processChartAction(array $data, array $expected) $this->log("\tResponse: " . json_encode($json)); $this->assertEquals($expectedResponse, $json); - + $this->log(sprintf('Done Processing %s Chart Action!', $expectedAction)); return $json['success']; } @@ -594,7 +617,7 @@ private function processChartAction(array $data, array $expected) private function createReport(array $data) { $this->log("Creating Report"); - $response = $this->helper->post('/controllers/report_builder.php', null, $data); + $response = $this->helper->post('controllers/report_builder.php', null, $data); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); @@ -633,7 +656,7 @@ private function removeReportById($reportId) 'selected_report' => $reportId ); - $response = $this->helper->post('/controllers/report_builder.php', null, $data); + $response = $this->helper->post('controllers/report_builder.php', null, $data); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); @@ -665,7 +688,7 @@ private function getNewReportName() 'operation' => 'get_new_report_name' ); - $response = $this->helper->post('/controllers/report_builder.php', null, $data); + $response = $this->helper->post('controllers/report_builder.php', null, $data); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); @@ -707,10 +730,11 @@ private function getNewReportName() */ private function enumAvailableCharts() { + $this->log('Enum Available Charts'); $data = array( 'operation' => 'enum_available_charts' ); - $response = $this->helper->post('/controllers/report_builder.php', null, $data); + $response = $this->helper->post('controllers/report_builder.php', null, $data); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); @@ -732,19 +756,14 @@ private function enumAvailableCharts() */ private function reportImageRenderer(array $params) { - $response = $this->helper->get('/report_image_renderer.php', $params); + $response = $this->helper->get('reports/builder/image', $params); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); - + if (('image/png' !== $response[1]['content_type']) || (200 !== $response[1]['http_code']) ) { + echo var_export($response, true) . "\n"; + } $this->assertEquals('image/png', $response[1]['content_type']); $this->assertEquals(200, $response[1]['http_code']); } - - private function log($msg) - { - if ($this->verbose) { - echo "$msg\n"; - } - } } diff --git a/tests/integration/lib/Controllers/RoleDelegationTest.php b/tests/integration/lib/Controllers/RoleDelegationTest.php index e2d0fe0257..289f8345ff 100644 --- a/tests/integration/lib/Controllers/RoleDelegationTest.php +++ b/tests/integration/lib/Controllers/RoleDelegationTest.php @@ -56,13 +56,13 @@ public function testSuccessfulRoleDelegation(array $options) $this->helper->authenticate($user); $response = $this->helper->post('controllers/role_manager.php', null, $data); - $this->validateResponse($response, 200, 'text/html; charset=UTF-8'); + $this->validateResponse($response, 200, 'application/json'); - $this->assertTrue(is_string($response[0]), "Response data not as expected. Received: " . json_encode($response[0])); - $content = json_decode($response[0], true); $expected = JSON::loadFile($this->getTestFiles()->getFile('role_delegation', $expectedFileName)); - - $this->assertEquals($expected, $content); + if ($response[0] !== $expected) { + echo var_export($response, true); + } + $this->assertEquals($expected, $response[0]); $this->helper->logout(); } @@ -107,7 +107,7 @@ public function testInvalidRoleDelegation(array $options) $this->helper->authenticate($user); $response = $this->helper->post('controllers/role_manager.php', null, $data); - $this->validateResponse($response, 200, 'application/json'); + $this->validateResponse($response); $content = $response[0]; $expected = JSON::loadFile($this->getTestFiles()->getFile('role_delegation', $expectedFileName)); diff --git a/tests/integration/lib/Controllers/SSOLoginTest.php b/tests/integration/lib/Controllers/SSOLoginTest.php index 3033c105f5..6f3534d52e 100644 --- a/tests/integration/lib/Controllers/SSOLoginTest.php +++ b/tests/integration/lib/Controllers/SSOLoginTest.php @@ -1051,8 +1051,8 @@ public function loginsProvider() public function createSystemAccount($personLongName, $resourceId, $username) { $query = <<markTestSkipped('Needs realm integration.'); } - $response = $this->helper->post('/controllers/user_interface.php', null, $input); + $response = $this->helper->post('controllers/user_interface.php', null, $input); $this->assertEquals('application/json', $response[1]['content_type']); $this->assertEquals(400, $response[1]['http_code']); @@ -119,7 +119,7 @@ public function testGetTabs() $this->markTestSkipped('Needs realm integration.'); } - $response = $this->helper->post('/controllers/user_interface.php', null, array('operation' => 'get_tabs', 'public_user' => 'true')); + $response = $this->helper->post('controllers/user_interface.php', null, array('operation' => 'get_tabs', 'public_user' => 'true')); $this->assertEquals($response[1]['content_type'], 'application/json'); $this->assertEquals($response[1]['http_code'], 200); @@ -152,14 +152,18 @@ public function testSystemUsernameAccess() $this->markTestSkipped('Needs realm integration.'); } self::$publicView['group_by'] = "username"; - $response = $this->helper->post('/controllers/user_interface.php', null, self::$publicView); + $response = $this->helper->post('controllers/user_interface.php', null, self::$publicView); $expectedErrorMessage = <<assertEquals($response[1]['content_type'], 'application/json'); $this->assertEquals($response[1]['http_code'], 403); $this->assertEquals($response[0]['message'], $expectedErrorMessage); @@ -316,7 +320,7 @@ public function provideJsonExport() { */ public function testJsonExport($input, $expected, $fieldCount, $recordCount) { - $response = $this->helper->post('/controllers/user_interface.php', null, $input); + $response = $this->helper->post('controllers/user_interface.php', null, $input); $got = json_decode($response[0], true); @@ -383,9 +387,12 @@ public function testAggregateViewValidData($view, $expected) $this->markTestSkipped('Needs realm integration.'); } - $response = $this->helper->post('/controllers/user_interface.php', null, $view); - - $this->assertNotFalse(strpos($response[1]['content_type'], 'text/plain')); + $response = $this->helper->post('controllers/user_interface.php', null, $view); + if ((strpos($response[1]['content_type'], 'text/html; charset=UTF-8') === false) || + ($response[1]['http_code'] !== 200)) { + echo "\n" . var_export($response, true) . "\n"; + } + $this->assertNotFalse(strpos($response[1]['content_type'], 'text/html; charset=UTF-8')); $this->assertEquals($response[1]['http_code'], 200); $plotdata = json_decode($response[0], true); @@ -414,9 +421,13 @@ public function testErrorBars($input, $expected) if (!in_array("jobs", self::$XDMOD_REALMS)) { $this->markTestSkipped('Needs realm integration.'); } - $response = $this->helper->post('/controllers/user_interface.php', null, $input); + $response = $this->helper->post('controllers/user_interface.php', null, $input); + if ((strpos($response[1]['content_type'], 'text/html; charset=UTF-8') === false) || + ($response[1]['http_code'] !== 200)) { + echo "\n" . var_export($response, true) . "\n"; + } - $this->assertNotFalse(strpos($response[1]['content_type'], 'text/plain')); + $this->assertNotFalse(strpos($response[1]['content_type'], 'text/html; charset=UTF-8')); $this->assertEquals($response[1]['http_code'], 200); $plotdata = json_decode(UsageExplorerHelper::demanglePlotData($response[0]), true); @@ -493,11 +504,15 @@ public function testExport($chartConfig, $expectedMimeType, $expectedFinfo) $this->markTestSkipped('Needs realm integration.'); } - $response = $this->helper->post('/controllers/user_interface.php', null, $chartConfig); + $response = $this->helper->post('controllers/user_interface.php', null, $chartConfig); + $actualContentType = $response[1]['content_type']; - $this->assertEquals($response[1]['http_code'], 200); + if (($response[1]['http_code'] !== 200) || + ($expectedMimeType !== $actualContentType) ) { + echo "\n" . var_export($response, true) . "\n"; + } - $actualContentType = $response[1]['content_type']; + $this->assertEquals($response[1]['http_code'], 200); $this->assertEquals($expectedMimeType, $actualContentType); $actualFinfo = finfo_buffer(finfo_open(FILEINFO_MIME), $response[0]); @@ -626,7 +641,7 @@ public function testPublicUserGetMenus() } EOF; - $response = $this->helper->post('/controllers/user_interface.php', null, json_decode($data, true)); + $response = $this->helper->post('controllers/user_interface.php', null, json_decode($data, true)); $this->assertEquals($response[1]['content_type'], 'application/json'); $this->assertEquals($response[1]['http_code'], 200); @@ -670,7 +685,7 @@ public function testDataFiltering($user, $chartSettings, $expectedNames) $this->helper->authenticate($user); - $response = $this->helper->post('/controllers/user_interface.php', null, $chartSettings); + $response = $this->helper->post('controllers/user_interface.php', null, $chartSettings); $this->assertEquals($response[1]['http_code'], 200); @@ -1144,7 +1159,7 @@ public function testGetTimeseriesDataCsv( $expectedParameterLine = ''; } $response = $this->helper->post( - '/controllers/user_interface.php', + 'controllers/user_interface.php', null, $data ); diff --git a/tests/integration/lib/Controllers/UserAdminTest.php b/tests/integration/lib/Controllers/UserAdminTest.php index 3ba31abf08..0dc4751ba0 100644 --- a/tests/integration/lib/Controllers/UserAdminTest.php +++ b/tests/integration/lib/Controllers/UserAdminTest.php @@ -19,11 +19,12 @@ class UserAdminTest extends BaseUserAdminTest */ public function testCreateUserFails(array $params, array $expected) { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $response = $this->helper->post('controllers/user_admin.php', null, $params); + $this->assertTrue(strpos($response[1]['content_type'], 'application/json') >= 0); - $this->assertEquals(200, $response[1]['http_code']); + $this->assertEquals(400, $response[1]['http_code']); $actual = $response[0]; @@ -158,12 +159,13 @@ public function provideCreateUserFails() public function testCreateUsersSuccess(array $user) { $userId = $this->createUser($user); + $expectedSuccess = isset($user['expected_success']) ? $user['expected_success'] : true; // if we received a userId back then let's go ahead and update the // users password so that it can be used to login in future tests. if ($userId !== null) { $username = array_search($userId, self::$newUsers); - $this->updateCurrentUser($userId, $username); + $this->updateCurrentUser($username, $username); } } @@ -242,10 +244,9 @@ function ($filter) use ($personId) { } } } - $this->helper->authenticateDirect($username, $username); - $response = $this->helper->get('rest/v0.1/warehouse/quick_filters'); + $response = $this->helper->get('warehouse/quick_filters'); $this->validateResponse($response); $this->assertArrayHasKey('results', $response[0]); @@ -463,7 +464,7 @@ public function testGetDwDescripters($username) [ 'status_code' => 200, 'body_validator' => ( - MetricExplorerTest::getDwDescriptersBodyValidator($this) + MetricExplorerTest::getDwDescriptersBodyValidator($this) ) ] ); @@ -525,7 +526,10 @@ public function testGetUserVisits(array $options) $this->validateResponse($response, 200, $expectedContentType); - $actual = json_decode($response[0], true); + $actual = $response[0]; + if (is_string($response[0])) { + $actual = json_decode($response[0], true); + } $expected = JSON::loadFile( parent::getTestFiles()->getFile('user_admin', $expectedOutput, 'output') @@ -608,7 +612,7 @@ public function testGetUserVisitsExport(array $options) ); $response = $helper->post("internal_dashboard/controllers/controller.php", null, $data); - $expectedContentType = $expectedSuccess ? 'application/xls' : 'text/html; charset=UTF-8'; + $expectedContentType = $expectedSuccess ? 'application/xls' : 'application/json'; $this->validateResponse($response, 200, $expectedContentType); @@ -634,12 +638,14 @@ public function testGetUserVisitsExport(array $options) } } else { // we expect the incoming data to be json formatted. - $actualLines = json_decode($response[0], true); + $actualLines = $response[0]; + if (is_string($response[0])) { + $actualLines = json_decode($response[0], true); + } foreach($actualLines as $key => $value) { $actual[] = array($key, $value); } } - $fileType = $expectedSuccess ? '.csv' : '.json'; $expectedFileName = parent::getTestFiles()->getFile('user_admin', $expectedOutput, 'output', $fileType); @@ -751,7 +757,7 @@ public function provideGetUserVisits() ); $helper = new XdmodTestHelper(); - $helper->authenticateDashboard('mgr'); + $helper->authenticate('mgr'); foreach($data as &$datum) { $datum[0]['helper'] = $helper; @@ -831,7 +837,7 @@ protected function getUserVisits(array $options) $expectedData = $options['expected']; $expectedContentType = $expectedData['content_type']; - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array_merge( array( diff --git a/tests/integration/lib/Logging/CCRDBHandlerTest.php b/tests/integration/lib/Logging/CCRDBHandlerTest.php index 418e5a7bb6..d090a36713 100644 --- a/tests/integration/lib/Logging/CCRDBHandlerTest.php +++ b/tests/integration/lib/Logging/CCRDBHandlerTest.php @@ -2,6 +2,9 @@ namespace IntegrationTests\Logging; +use CCR\Log; +use PHPSQLParser\Test\Creator\whereTest; + class CCRDBHandlerTest extends \PHPUnit\Framework\TestCase { @@ -19,16 +22,23 @@ public function testHandlerWritesCorrectly() 'file' => false, 'console' => false, 'mail' => false, - 'dbLogLevel' => \CCR\Log::DEBUG + 'dbLogLevel' => Log::DEBUG ) ); + // We should be able to just log strings $logger->debug("Testing DB Write Handler: $now"); + // We should be able to log string messages w/ additional context. + $logger->debug("Testing DB Write Handler w/ Context", ['timestamp' => "$now"]); + + // we should be able to log w/ no messages and only a context array. + $logger->debug('', ['message' => 'Testing 123', 'timestamp' => "$now"]); + $results = $db->query("SELECT * FROM $schema.$table WHERE message LIKE '%$now%' "); $actual = count($results); - $this->assertEquals(1, $actual, sprintf("Expected 1 log record to be written, but received: %s", $actual)); + $this->assertEquals(3, $actual, sprintf("Expected 2 log record to be written, but received: %s", $actual)); $this->assertTrue(is_numeric($results[0]['id']), sprintf("Expected the id value to be numeric, received: %s", $results[0]['id'])); // Check that the result has the required column @@ -45,21 +55,34 @@ public function testHandlerWritesCorrectly() // Check that the data contained in the required column is formatted correctly. $message = $result['message']; $json = null; + $isActuallyJson = str_contains($message, '{'); try { $json = json_decode($message); } catch (\Exception $e) { $this->fail("Expected the `message` property to be json de-codable. Received: $message"); } - $this->assertNotNull($json); - $this->assertObjectHasProperty( - 'message', - $json, - sprintf( - "Expected decoded message to be an object with a `message` property. Received: %s", - print_r($json, true) - ) - ); + // json_decode does things differently in php 8.2 vs. php 7.4. In 7.4, if you pass a string that does not + // contain json ( ex. "This is a test" ) to json_decode, it will return "This is a test". In 8.2 it will return + // null, which makes sense to be fair since "This is a test" is not valid json. But it's still annoying. + if (is_null($json) && $isActuallyJson) { + echo "\n". var_export($result, true) . "\n"; + } + // If it's null & not json then cool, if json is not null, then we expect $isActuallyJson to also be true. + $valid = (is_null($json) && !$isActuallyJson) || (!is_null($json) && $isActuallyJson); + $this->assertTrue($valid); + + // If we get valid json back, then make sure it has the `message` property. + if (!is_null($json)) { + $this->assertObjectHasProperty( + 'message', + $json, + sprintf( + "Expected decoded message to be an object with a `message` property. Received: %s", + print_r($json, true) + ) + ); + } } } diff --git a/tests/integration/lib/Rest/JobViewerTest.php b/tests/integration/lib/Rest/JobViewerTest.php index 28513e9207..2732ac0a65 100644 --- a/tests/integration/lib/Rest/JobViewerTest.php +++ b/tests/integration/lib/Rest/JobViewerTest.php @@ -18,7 +18,7 @@ class JobViewerTest extends BaseTest public function setup(): void { - $xdmodConfig = array( 'decodetextasjson' => true ); + $xdmodConfig = array( 'decodetextasjson' => true); $this->xdmodhelper = new XdmodTestHelper($xdmodConfig); } @@ -110,6 +110,9 @@ public function testDimensionValues($xdmodhelper, $dimension) ); $response = $xdmodhelper->get(self::ENDPOINT . 'dimensions/' . $dimension, $queryparams); + if (200 !== $response[1]['http_code']) { + echo var_export($response, true); + } $this->assertEquals(200, $response[1]['http_code']); $resdata = $response[0]; @@ -179,6 +182,9 @@ private function validateSingleJobSearch($searchparams, $doAuth = true) } $result = $this->xdmodhelper->get(self::ENDPOINT . 'search/jobs', $searchparams); + if (false === $result[0]['success'] ){ + echo var_export($result, true) . "\n"; + } $this->assertArrayHasKey('success', $result[0]); $this->assertTrue($result[0]['success']); $this->assertArrayHasKey('results', $result[0]); diff --git a/tests/integration/lib/Rest/WarehouseControllerProviderTest.php b/tests/integration/lib/Rest/WarehouseControllerProviderTest.php index a88cc8bddf..08bff89d6d 100644 --- a/tests/integration/lib/Rest/WarehouseControllerProviderTest.php +++ b/tests/integration/lib/Rest/WarehouseControllerProviderTest.php @@ -561,7 +561,7 @@ public function testGetAggregateDataMalformedRequests( $output ) { parent::authenticateRequestAndValidateJson( - self::$helper, + new XdmodTestHelper(), $role, $input, $output diff --git a/tests/integration/lib/Rest/WarehouseExportControllerProviderTest.php b/tests/integration/lib/Rest/WarehouseExportControllerProviderTest.php index 9c6d9455ee..14f446a92e 100644 --- a/tests/integration/lib/Rest/WarehouseExportControllerProviderTest.php +++ b/tests/integration/lib/Rest/WarehouseExportControllerProviderTest.php @@ -191,6 +191,16 @@ function ($error) { * @dataProvider provideTokenAuthTestData */ public function testGetRealmsTokenAuth($role, $tokenType) { + $find_index_by_id = function($value, $array) { + $i = 0; + foreach ($array as $item) { + if (array_key_exists('id', $item) && $item['id'] === $value) { + return $i; + } + $i += 1; + } + return false; + }; parent::runTokenAuthTest( $role, $tokenType, @@ -202,10 +212,12 @@ public function testGetRealmsTokenAuth($role, $tokenType) { 'endpoint_type' => 'rest', 'authentication_type' => 'token_optional' ], - parent::validateSuccessResponse(function ($body, $assertMessage) { + parent::validateSuccessResponse(function ($body, $assertMessage) use($find_index_by_id) { $this->assertSame(3, $body['total'], $assertMessage); - $index = 0; - foreach (['Jobs', 'Cloud', 'ResourceSpecifications'] as $realmName) { + foreach (['Jobs' => 31, 'Cloud' => 19, 'ResourceSpecifications' =>16] as $realmName => $fieldCount) { + // We can't assume that the data returned from export/realms will always be in the same order so we + // start by finding the index of the realm we're currently testing. + $index = $find_index_by_id($realmName, $body['data']); $realm = $body['data'][$index]; foreach (['id', 'name'] as $property) { $this->assertSame( @@ -231,14 +243,9 @@ public function testGetRealmsTokenAuth($role, $tokenType) { $assertMessage ); } - $index++; - } - - $counts = [31, 19, 16]; - for ($i = 0; $i < count($counts); $i++) { $this->assertCount( - $counts[$i], - $body['data'][$i]['fields'], + $fieldCount, + $body['data'][$index]['fields'], $assertMessage ); } @@ -442,10 +449,9 @@ public function testDeleteRequests($role, $httpCode, $schema) $datum['id'] = (int)$datum['id']; $ids[] = $datum['id']; } - $data = json_encode($ids); - + $data = ['ids' => json_encode($ids)]; // Delete all existing requests. - list($content, $info, $headers) = self::$helpers[$role]->delete('rest/warehouse/export/requests', null, $data); + list($content, $info, $headers) = self::$helpers[$role]->delete('rest/warehouse/export/requests', $data, $data); $this->assertMatchesRegularExpression('#\bapplication/json\b#', $headers['Content-Type'], 'Content type header'); $this->assertEquals($httpCode, $info['http_code'], 'HTTP response code'); $this->validateAgainstSchema($content, $schema); diff --git a/tests/integration/lib/TestHarness/XdmodTestHelper.php b/tests/integration/lib/TestHarness/XdmodTestHelper.php index 3608805a0d..4a669d37a4 100644 --- a/tests/integration/lib/TestHarness/XdmodTestHelper.php +++ b/tests/integration/lib/TestHarness/XdmodTestHelper.php @@ -145,6 +145,12 @@ public function authenticate($userrole) $this->userrole = $userrole; $this->setauthvariables(null); $authresult = $this->post("rest/auth/login", null, $this->config['role'][$userrole]); + if (!is_array($authresult[0]) || !array_key_exists('results', $authresult[0])) { + echo "Invalid Authentication for $userrole\n"; + echo var_export($authresult, true) . "\n"; + echo "*****\n"; + echo var_export(array_keys($authresult[0]), true) . "\n"; + } $authtokens = $authresult[0]['results']; $this->setauthvariables($authtokens['token']); } @@ -236,7 +242,8 @@ public function authenticateSSO($parameters, $includeDefault = true) */ public function authenticateDashboard($userrole) { - if (! isset($this->config['role'][$userrole])) { + $this->authenticate($userrole); + /*if (! isset($this->config['role'][$userrole])) { throw new \Exception("User role $userrole not defined in testing.json file"); } $this->userrole = $userrole; @@ -247,7 +254,7 @@ public function authenticateDashboard($userrole) ); $authresult = $this->post("internal_dashboard/user_check.php", null, $data); $cookie = isset($authresult[2]['Set-Cookie']) ? $authresult[2]['Set-Cookie'] : null; - $this->setauthvariables('', $cookie); + $this->setauthvariables('', $cookie);*/ } public function logout() diff --git a/tests/playwright/lib/internal_dashboard.selectors.ts b/tests/playwright/lib/internal_dashboard.selectors.ts index 4c49ca3742..58c5986947 100644 --- a/tests/playwright/lib/internal_dashboard.selectors.ts +++ b/tests/playwright/lib/internal_dashboard.selectors.ts @@ -61,16 +61,7 @@ const selectors = { * @returns {string} */ col_for_user: function (username, column_name) { - return `( - //div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-body")]//table//td[ - count(preceding-sibling::td) + 1 = - count(//div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-header")]//table//td[.="${column_name}"]/preceding-sibling::td) + 1 - ] - ) [ - count( - //div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-body")]//table//td[.="${username}"]/preceding::div[contains(@class, 'x-grid3-row')] - ) + 1 - ]`; + return `(//div[contains(@class, "existing_user_grid")]//div[contains(@class,"x-grid3-body")]//table//td[count(preceding-sibling::td) + 1 = count(//div[contains(@class,"existing_user_grid")]//div[contains(@class,"x-grid3-header")]//table//td[.="${column_name}"]/preceding-sibling::td) + 1 ])[count(//div[contains(@class,"existing_user_grid")]//div[contains(@class,"x-grid3-body")]//table//td[.="${username}"]/preceding::div[contains(@class,"x-grid3-row")]) + 1]` } } }, @@ -120,6 +111,9 @@ const selectors = { itemWithText: function (text) { return `${selectors.create_manage_users.current_users.settings.toolbar.actions.container}//span[.="${text}"]`; } + }, + details_header: function(user) { + return `${selectors.create_manage_users.current_users.settings.container}//span[contains(@class, "x-panel-header-text") and contains(text(), "${user}")]`; } }, inputByLabel: function (labelText, inputType) { @@ -194,6 +188,9 @@ const selectors = { userType: function () { return `${selectors.create_manage_users.new_user.container()}//input[contains(@class, "new_user_user_type")]`; }, + userTypeTrigger: function() { + return `${selectors.create_manage_users.new_user.userType()}/following-sibling::img[contains(@class, "x-form-trigger")]` + }, aclByName: function (name) { return `${selectors.create_manage_users.new_user.container()}//div[contains(@class, "admin_panel_section_role_assignment_n")]//table[contains(@class, "x-grid3-row-table")]//td[div="${name}"]/following-sibling::td//div[contains(@class, "x-grid3-cell-inner")]/div`; }, diff --git a/tests/playwright/lib/usageTab.page.ts b/tests/playwright/lib/usageTab.page.ts index e3b5ba1569..c400fa68a1 100644 --- a/tests/playwright/lib/usageTab.page.ts +++ b/tests/playwright/lib/usageTab.page.ts @@ -27,8 +27,8 @@ class Usage extends BasePage{ async selectTab(){ const xdmod = new XDMoD(this.page, this.page.baseUrl); await xdmod.selectTab('tg_usage'); - await expect(this.chartLocator).toBeVisible(); - await expect(this.maskLocator).toBeHidden(); + await expect(await this.chartLocator.count()).toBeGreaterThan(0); + await expect(this.maskLocator).toHaveCount(0); } /** @@ -120,8 +120,9 @@ class Usage extends BasePage{ * @return {Boolean} True if the node is expanded. */ async isTreeNodeExpanded(name){ - const unfoldTreeSelector = selectors.unfoldTreeNodeByName(name); - const unfoldTreeClass = await this.page.getAttribute(unfoldTreeSelector, 'class'); + const unfoldTreeSelector = this.page.locator(selectors.unfoldTreeNodeByName(name)); + await expect(unfoldTreeSelector).toBeVisible(); + const unfoldTreeClass = await unfoldTreeSelector.getAttribute('class'); return unfoldTreeClass.match(/[$ ]x-tree-node-plus[^ ]/) === null; } @@ -168,7 +169,9 @@ class Usage extends BasePage{ if (!check){ await this.expandTreeNode(topName); } - await this.page.locator(selectors.treeNodeByPath(topName, childName)).click(); + const treeNodeLocator = this.page.locator(selectors.treeNodeByPath(topName, childName)) + await expect(treeNodeLocator).toBeVisible(); + await treeNodeLocator.click(); } /** diff --git a/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts b/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts index 051436a50b..e08ad40a65 100644 --- a/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts +++ b/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts @@ -56,7 +56,7 @@ test.describe('Internal Dashboard Tests', async () => { await expect(page.locator(InternalDashboard.selectors.combo.container)).toBeVisible(); await expect(page.locator(InternalDashboard.selectors.combo.itemByText('Unknown'))).toBeVisible(); - await page.click(InternalDashboard.selectors.combo.itemByText('Unknown')); + await page.locator(InternalDashboard.selectors.combo.itemByText('Unknown')).click(); await expect(page.locator(InternalDashboard.selectors.combo.container)).toBeHidden(); const mapTo = await page.inputValue(InternalDashboard.selectors.create_manage_users.new_user.mapTo()); @@ -91,8 +91,19 @@ test.describe('Internal Dashboard Tests', async () => { await expect(page.locator(InternalDashboard.selectors.create_manage_users.new_user.aclByName('User'))).toHaveClass(/x-grid3-check-col-on/); }); + await test.step('Select User Type', async () => { + const userTypeTrigger = page.locator(InternalDashboard.selectors.create_manage_users.new_user.userTypeTrigger()); + await expect(userTypeTrigger).toBeVisible(); + await userTypeTrigger.click(); + + const externalComboOptionLocator = page.locator(InternalDashboard.selectors.combo.itemByText('External')); + await expect(externalComboOptionLocator).toBeVisible(); + await externalComboOptionLocator.click(); + await expect(externalComboOptionLocator).toBeHidden(); + }); + await test.step('Save User', async () => { - await page.click(InternalDashboard.selectors.create_manage_users.buttons.create_user()); + await page.locator(InternalDashboard.selectors.create_manage_users.buttons.create_user()).click(); await expect(page.locator(InternalDashboard.selectors.createSuccessNotification('btest'))).toBeVisible(); await expect(page.locator(InternalDashboard.selectors.createSuccessNotification('btest'))).toBeHidden(); @@ -184,8 +195,7 @@ test.describe('Internal Dashboard Tests', async () => { let dropDownValueSelector = InternalDashboard.selectors.combo.itemByText(setting.updated); let dropDownValue = page.locator(dropDownValueSelector); await expect(dropDownValue).toBeVisible(); - await page.click(dropDownValueSelector); - + await dropDownValue.click(); await expect(inputDropDown).toBeHidden(); } else if ('text' === setting.type) { const inputSelector = InternalDashboard.selectors.create_manage_users.current_users.settings.inputByLabel(setting.label, setting.type); @@ -225,18 +235,24 @@ test.describe('Internal Dashboard Tests', async () => { const displayedUserTypeSelector = InternalDashboard.selectors.create_manage_users.current_users.user_list.toolbar.buttonByLabel('Displaying', setting.original); const displayedUserType = page.locator(displayedUserTypeSelector); await expect(displayedUserType).toBeVisible(); - await page.click(displayedUserTypeSelector); + await displayedUserType.click(); const newUserTypeItemSelector = InternalDashboard.selectors.create_manage_users.current_users.user_list.dropDownItemByText(setting.updated); const newUserTypeItem = page.locator(newUserTypeItemSelector); await expect(newUserTypeItem).toBeVisible(); - await page.click(newUserTypeItemSelector); + await newUserTypeItem.click(); }); await test.step(`${setting.label}: Check that the user is listed in the Existing Users table`, async () => { await expect(page.locator(InternalDashboard.selectors.create_manage_users.current_users.user_list.col_for_user('btest'))).toBeVisible(); }); } else { + await test.step(`${setting.label}: Make sure that the user is selected`, async() => { + const userSelector = page.locator(InternalDashboard.selectors.create_manage_users.current_users.user_list.col_for_user('btest')); + await userSelector.click(); + const userDetailsLocator = page.locator(InternalDashboard.selectors.create_manage_users.current_users.settings.toolbar.details_header('Bob Test')); + await expect(userDetailsLocator).toBeVisible(); + }); await test.step(`${setting.label}: Check that ${setting.label} has been updated successfully to "${setting.updated}"`, async () => { const inputType = 'dropdown' === setting.type ? 'text' : setting.type; const selector = InternalDashboard.selectors.create_manage_users.current_users.settings.inputByLabel(setting.label, inputType); diff --git a/tests/regression/lib/Controllers/MetricExplorerChartsTest.php b/tests/regression/lib/Controllers/MetricExplorerChartsTest.php index 320852b90a..86990a871d 100644 --- a/tests/regression/lib/Controllers/MetricExplorerChartsTest.php +++ b/tests/regression/lib/Controllers/MetricExplorerChartsTest.php @@ -26,7 +26,7 @@ public static function tearDownAfterClass(): void * use this function to print out the results from the api call. This * can be used to generate new expected test results. */ - private function output($chartData) + /*private function output($chartData) { $result = array( 'total' => $chartData['totalCount'], @@ -40,7 +40,7 @@ private function output($chartData) ); } var_export($result); - } + }*/ /** * See the filterTestsProvider for instructions on how to generate @@ -108,7 +108,7 @@ private function getFiltersByValue($helper, $realm, $dimension, $values) return $filters; } - private function getDimensionValues($helper, $realm, $dimension) + private static function getDimensionValues($helper, $realm, $dimension) { $params = array( 'operation' => 'get_dimension', @@ -120,12 +120,12 @@ private function getDimensionValues($helper, $realm, $dimension) 'selectedFilterIds' => '' ); - $response = $helper->post('/controllers/metric_explorer.php', null, $params); + $response = $helper->post('controllers/metric_explorer.php', null, $params); - $this->assertEquals('application/json', $response[1]['content_type']); - $this->assertEquals(200, $response[1]['http_code']); + self::assertEquals('application/json', $response[1]['content_type']); + self::assertEquals(200, $response[1]['http_code']); - $this->assertEquals($response[0]['totalCount'], count($response[0]['data'])); + self::assertEquals($response[0]['totalCount'], count($response[0]['data'])); return $response[0]['data']; } @@ -421,7 +421,7 @@ public function remainderChartProvider() * and is intended to be run against a known working XDMoD to generate a baseline * set of values for regression testing. */ - private function generateFilterTests() + private static function generateFilterTests() { // Generate test scenario for filter tests. $baseConfig = array( @@ -441,7 +441,7 @@ private function generateFilterTests() foreach ($response[0]['results'] as $dimConfig) { $dimension = $dimConfig['id']; - $dimensionValues = $this->getDimensionValues($helper, $config['realm'], $dimension); + $dimensionValues = self::getDimensionValues($helper, $config['realm'], $dimension); $testConfig = array( 'settings' => array( @@ -475,7 +475,7 @@ private function generateFilterTests() return $output; } - public function filterTestsProvider() + public static function filterTestsProvider() { $data_file = realpath(__DIR__ . '/../../../artifacts/xdmod/regression/chartFilterTests.json'); if (file_exists($data_file)) { @@ -484,7 +484,7 @@ public function filterTestsProvider() // Generate test permutations. The expected values for the data points are not set. // this causes the test function to record the values and they are then written // to a file in the tearDownAfterClass function. - $inputs = $this->generateFilterTests(); + $inputs = self::generateFilterTests(); } $helper = new XdmodTestHelper(); diff --git a/tests/regression/lib/Controllers/UsageChartsTest.php b/tests/regression/lib/Controllers/UsageChartsTest.php index cbdff166ab..e0d041b34c 100644 --- a/tests/regression/lib/Controllers/UsageChartsTest.php +++ b/tests/regression/lib/Controllers/UsageChartsTest.php @@ -139,7 +139,7 @@ private function phash($type, $imageData) public function testChartSettings($testName, $input, $expectedHash) { $postvars = null; - $response = self::$helper->post('/controllers/user_interface.php', $postvars, $input); + $response = self::$helper->post('controllers/user_interface.php', $postvars, $input); $imageData = $response[0]; $actualHash = $this->phash($input['format'], $imageData); @@ -174,7 +174,7 @@ private function genoutput($reference, $settings, $expectedHashes) $testName = ''; foreach ($settings as $key => $value) { $reference[$key] = $value; - $testName .= "${key}=${value}/"; + $testName .= "{$key}={$value}/"; } $hash = false; diff --git a/tests/regression/lib/Controllers/UsageExplorerJobsTest.php b/tests/regression/lib/Controllers/UsageExplorerJobsTest.php index 5392441e50..52fd4fc160 100644 --- a/tests/regression/lib/Controllers/UsageExplorerJobsTest.php +++ b/tests/regression/lib/Controllers/UsageExplorerJobsTest.php @@ -10,7 +10,7 @@ class UsageExplorerJobsTest extends \PHPUnit\Framework\TestCase { /** - * @var \RegressionTestHelper + * @var RegressionTestHelper */ private static $helper; diff --git a/tests/regression/lib/TestHarness/RegressionTestHelper.php b/tests/regression/lib/TestHarness/RegressionTestHelper.php index 34dc04490b..fc706756a5 100644 --- a/tests/regression/lib/TestHarness/RegressionTestHelper.php +++ b/tests/regression/lib/TestHarness/RegressionTestHelper.php @@ -360,7 +360,7 @@ public function checkCsvExport($testName, $input, $expectedFile, $userRole) throw new SkippedTestError($fullTestName . ' intentionally skipped'); } - list($csvdata, $curldata) = self::post('/controllers/user_interface.php', null, $input); + list($csvdata, $curldata) = self::post('controllers/user_interface.php', null, $input); if (!empty(self::$timingOutputDir)) { $time_data = $fullTestName . "," . $curldata['total_time'] . "," . $curldata['starttransfer_time'] . "\n"; $outputCSV = self::$timingOutputDir . "timings.csv"; diff --git a/tests/unit/lib/DataWarehouse/VisualizationTest.php b/tests/unit/lib/DataWarehouse/VisualizationTest.php index 4cd686d7a0..8b70e9dec3 100644 --- a/tests/unit/lib/DataWarehouse/VisualizationTest.php +++ b/tests/unit/lib/DataWarehouse/VisualizationTest.php @@ -25,7 +25,8 @@ public function setup(): void 0x999900, 0xCC3300, 0x669999, 0x993333, 0x339966, 0xC42525, 0xA6C96A, 0x111111); } - public function tearDown(): void { + public function tearDown(): void + { } @@ -35,7 +36,7 @@ public function testGetLotsOfColours() $v = \DataWarehouse\Visualization::getColors($count); - $this->assertEquals(count($v), 65); + $this->assertEquals(65, count($v)); } public function testGetFewColours() @@ -61,14 +62,13 @@ public function testNoWhite() $ncolours = 10; $v = \DataWarehouse\Visualization::getColors($ncolours, 0, false); - $this->assertEquals($v, array_slice($this->expected, 1) ); + $this->assertEquals($v, array_slice($this->expected, 1)); $this->assertGreaterThanOrEqual($ncolours, count($v)); } public function testArraySizes() { - for($i = 0; $i < 300; $i++) - { + for ($i = 0; $i < 300; $i++) { $v = \DataWarehouse\Visualization::getColors($i, 0, false); $this->assertGreaterThanOrEqual($i, count($v)); diff --git a/tests/unit/lib/ETL/Configuration/JsonReferenceWithFallbackTest.php b/tests/unit/lib/ETL/Configuration/JsonReferenceWithFallbackTest.php index d8c9a2d710..6a87aeffdc 100644 --- a/tests/unit/lib/ETL/Configuration/JsonReferenceWithFallbackTest.php +++ b/tests/unit/lib/ETL/Configuration/JsonReferenceWithFallbackTest.php @@ -21,7 +21,7 @@ class JsonReferenceWithFallbackTest extends TestCase public static function setupBeforeClass(): void { - // Set up a logger so we can get warnings and error messages from the ETL infrastructure + // Set up a logger so we can get warnings and error messages from the ETL infrastructure $conf = array( 'file' => false, 'db' => false, @@ -80,7 +80,7 @@ public function provideInvalidValue() */ public function testLastFileDNE($value) { - $this->expectExceptionMessageMatches("/Failed to open file '[^']+file_does_not_exist.txt': file_get_contents\([^)]+\): failed to open stream: No such file or directory/"); + $this->expectExceptionMessageMatches("/Failed to open file '[^']+file_does_not_exist.txt': file_get_contents\([^)]+\): Failed to open stream: No such file or directory/"); $this->expectException(Exception::class); $this->runTransformTest($value); } diff --git a/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php b/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php index 4855003b8e..e266a67f52 100644 --- a/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php +++ b/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php @@ -53,7 +53,7 @@ public function testWebServerLogFile($filename, $logFormat, $expected) $endpoint->connect(); $numIterations = 0; foreach ($endpoint as $record) { - $this->assertSame($expected[$numIterations], $record); + $this->arrays_are_same($expected[$numIterations], $record); $numIterations++; } $this->assertSame( @@ -111,4 +111,28 @@ public function provideWebServerLogFile() } return $tests; } + + private function arrays_are_same( array $left, array $right, bool $exact = true) + { + if (count(array_diff(array_keys($left), array_keys($right))) > 0) { + $this->fail('Keys are different'); + } + $differences = []; + foreach($left as $lkey => $lvalue) { + $ltype = gettype($lvalue); + $rtype = gettype($right[$lkey]); + if ($ltype !== $rtype) { + $differences []= sprintf("Expected $lkey to be %s got %s", $ltype, $rtype); + if ($exact && $lvalue !== $right[$lkey]) { + $differences []= sprintf("Expected $lkey value to be %s got %s", $lvalue, $right[$lkey]); + } + } + } + if (count($differences) > 0) { + $this->fail(sprintf( + "Differences Found:\n%s", + implode("\n", $differences) + )); + } + } } diff --git a/tests/unit/lib/LogTest.php b/tests/unit/lib/LogTest.php index dab58803ae..f636ec27a0 100644 --- a/tests/unit/lib/LogTest.php +++ b/tests/unit/lib/LogTest.php @@ -39,7 +39,7 @@ public function testLogLevels($logLevel, $expectedLines) // Log messages at each level and compare the number of actual lines output to the number // expected based on the log level mask. - $logger->emerg('Emergency'); + $logger->emergency('Emergency'); $logger->alert('Alert'); $logger->critical('Critical'); $logger->error('Error'); From 08fbe03e5f64048aee5b395c46061fa3daf4db80 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 6 Oct 2025 14:22:01 -0400 Subject: [PATCH 02/83] Temporarily Admitting Defeat For whatever reason the `/about/*.php` routes would not match, even though we have other routes w/ `.php` in the url. I've updated the about url's that included `.php` to `.html`. I will revisit these changes in the future. --- html/gui/js/modules/About.js | 6 ++-- src/Controller/AboutController.php | 46 +++++++++++++++--------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/html/gui/js/modules/About.js b/html/gui/js/modules/About.js index 454f02ba8a..dbb57514cb 100644 --- a/html/gui/js/modules/About.js +++ b/html/gui/js/modules/About.js @@ -128,11 +128,11 @@ XDMoD.Module.About = Ext.extend(XDMoD.PortalModule, { this.addListener('activate', function() { var item = decodeURIComponent(CCR.tokenize(Ext.History.getToken()).params); var items = { - XDMoD: '/about/xdmod.php', + XDMoD: '/about/xdmod.html', 'Open XDMoD': '/about/openxd.html', SUPReMM: '/about/supremm.html', - Federated: '/about/federated.php', - Roadmap: '/about/roadmap.php', + Federated: '/about/federated.html', + Roadmap: '/about/roadmap.html', Team: '/about/team.html', Publications: '/about/publications.html', Presentations: '/about/presentations.html', diff --git a/src/Controller/AboutController.php b/src/Controller/AboutController.php index 9fc17bf79e..dae53ca848 100644 --- a/src/Controller/AboutController.php +++ b/src/Controller/AboutController.php @@ -7,21 +7,19 @@ use Exception; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; /** * This controller handles the urls for XDMoD's 'About' tab. */ -#[Route("/about")] class AboutController extends BaseController { /** * @return Response */ - #[Route('/xdmod', methods: ["GET"])] - #[Route('/xdmod.php', methods: ["GET"])] + #[Route('/about/xdmod', methods: ["GET"])] + #[Route('/about/xdmod.html', methods: ["GET"])] public function xdmod(): Response { return $this->render('about/xdmod.html.twig', [ @@ -32,8 +30,8 @@ public function xdmod(): Response /** * @return Response */ - #[Route('/open_xdmod', methods: ["GET"])] - #[Route('/openxd.html', methods: ["GET"])] + #[Route('/about/open_xdmod', methods: ["GET"])] + #[Route('/about/openxd.html', methods: ["GET"])] public function openXdmod(): Response { return $this->render('about/open_xdmod.html.twig'); @@ -42,8 +40,8 @@ public function openXdmod(): Response /** * @return Response */ - #[Route('/supremm', methods: ['GET'])] - #[Route('/supremm.html', methods: ['GET'])] + #[Route('/about/supremm', methods: ['GET'])] + #[Route('/about/supremm.html', methods: ['GET'])] public function supremm(): Response { return $this->render('about/supremm.html.twig'); @@ -53,8 +51,8 @@ public function supremm(): Response * @return Response * @throws Exception if unable to retrieve a connection to the 'datawarehouse' DB. */ - #[Route('/federated', methods: ["GET"])] - #[Route('/federated.php', methods: ["GET"])] + #[Route('/about/federated', methods: ["GET"])] + #[Route('/about/federated.html', methods: ["GET"])] public function federated(): Response { $parameters = []; @@ -102,21 +100,23 @@ public function federated(): Response /** * @return Response */ - #[Route('/roadmap', methods: ['GET'])] - #[Route('/roadmap.php', methods: ["GET"])] + #[Route('/about/roadmap', methods: ['GET'])] + #[Route('/about/roadmap.html', methods: ["GET"])] public function roadmap(): Response { + $header = $this->getConfigValue('roadmap', 'header'); + $url = $this->getConfigValue('roadmap', 'url'); return $this->render('about/roadmap.html.twig', [ - 'header' => $this->getConfigValue('roadmap', 'header'), - 'url' => $this->getConfigValue('roadmap', 'url') + 'header' => $header, + 'url' => $url ]); } /** * @return Response */ - #[Route('/team', methods: ['GET'])] - #[Route('/team.html', methods: ['GET'])] + #[Route('/about/team', methods: ['GET'])] + #[Route('/about/team.html', methods: ['GET'])] public function team(): Response { return $this->render('about/team.html.twig'); @@ -125,8 +125,8 @@ public function team(): Response /** * @return Response */ - #[Route('/publications', methods: ['GET'])] - #[Route('/publications.html', methods: ['GET'])] + #[Route('/about/publications', methods: ['GET'])] + #[Route('/about/publications.html', methods: ['GET'])] public function publications(): Response { return $this->render('about/publications.html.twig'); @@ -135,8 +135,8 @@ public function publications(): Response /** * @return Response */ - #[Route('/links', methods: ['GET'])] - #[Route('/links.html', methods: ['GET'])] + #[Route('/about/links', methods: ['GET'])] + #[Route('/about/links.html', methods: ['GET'])] public function links(): Response { return $this->render('about/links.html.twig'); @@ -146,7 +146,7 @@ public function links(): Response * @param string $xdmodType * @return Response */ - #[Route('/release_notes/{xdmodType}', methods: ['GET'])] + #[Route('/about/release_notes/{xdmodType}', methods: ['GET'])] public function releaseNotes(string $xdmodType): Response { if (str_contains($xdmodType, '.')) { @@ -169,8 +169,8 @@ public function releaseNotes(string $xdmodType): Response * @param Request $request * @return Response */ - #[Route('/presentations', methods: ['GET'])] - #[Route('/presentations.html', methods: ['GET'])] + #[Route('/about/presentations', methods: ['GET'])] + #[Route('/about/presentations.html', methods: ['GET'])] public function teamPresentations(Request $request): Response { return $this->render('about/presentations.html.twig'); From 35135a1276e344934181a08e36c06ca2e4773762 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 14:06:32 -0500 Subject: [PATCH 03/83] Removing unneeded files --- classes/Rest/Controllers/BaseControllerProvider.php | 0 classes/Rest/Controllers/WarehouseExportControllerProvider.php | 0 classes/Rest/XdmodApplicationFactory.php | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 classes/Rest/Controllers/BaseControllerProvider.php delete mode 100644 classes/Rest/Controllers/WarehouseExportControllerProvider.php delete mode 100644 classes/Rest/XdmodApplicationFactory.php diff --git a/classes/Rest/Controllers/BaseControllerProvider.php b/classes/Rest/Controllers/BaseControllerProvider.php deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/classes/Rest/Controllers/WarehouseExportControllerProvider.php b/classes/Rest/Controllers/WarehouseExportControllerProvider.php deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/classes/Rest/XdmodApplicationFactory.php b/classes/Rest/XdmodApplicationFactory.php deleted file mode 100644 index e69de29bb2..0000000000 From 557df61b583b4525909c9e972bda4ec76746cef1 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 14:07:00 -0500 Subject: [PATCH 04/83] Updating logging location These changes update so that Symfony code logs to /var/log/xdmod/exceptions.log --- .env | 1 + config/packages/monolog.yaml | 17 +++++++---------- config/services.yaml | 1 + 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.env b/.env index 50e3ef8974..7cc2298afd 100644 --- a/.env +++ b/.env @@ -6,3 +6,4 @@ DATABASE_URL= GOOGLE_RECAPTCHA_SITE_KEY= GOOGLE_RECAPTCHA_SECRET= ###< google/recaptcha ### +XDMOD_LOG_DIR=/var/log/xdmod diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 8c9efa91e0..1caa402816 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -7,17 +7,9 @@ when@dev: handlers: main: type: stream - path: "%kernel.logs_dir%/%kernel.environment%.log" + path: "%log_dir%/exceptions.log" level: debug 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 @@ -34,7 +26,7 @@ when@test: channels: ["!event"] nested: type: stream - path: "%kernel.logs_dir%/%kernel.environment%.log" + path: "%log_dir%/exceptions.log" level: debug when@prod: @@ -51,6 +43,11 @@ when@prod: path: php://stderr level: debug formatter: monolog.formatter.json + file: + type: stream + path: '%log_dir%/exceptions.log' + level: warning + channels: ['!event'] console: type: console process_psr_3_messages: false diff --git a/config/services.yaml b/config/services.yaml index 10235a1ce8..e9e586117d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,6 +22,7 @@ parameters: system_username: attribute: 'system_username' default: '$username' + log_dir: '%env(XDMOD_LOG_DIR)%' google_recaptcha_site_key: '%env(GOOGLE_RECAPTCHA_SITE_KEY)%' center_related_acls: - cd From d324ed013a0e20ec8e3c9e7e8c1ec22070be1a49 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:25:10 -0500 Subject: [PATCH 05/83] this file was overwriting the log settings --- config/packages/dev/monolog.yaml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 config/packages/dev/monolog.yaml diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml deleted file mode 100644 index 6e8b2baf7d..0000000000 --- a/config/packages/dev/monolog.yaml +++ /dev/null @@ -1,17 +0,0 @@ -monolog: - handlers: - main: - type: stream - path: "%kernel.logs_dir%/%kernel.environment%.log" - level: warning - # 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 From 37a77ec582a5e7e94d3852d6ebc2fcee27eee076 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:26:11 -0500 Subject: [PATCH 06/83] First set of changes per code review @jpwhite These are readability changes meant to make it clear to the reader that the roles are in fact xdmod roles which are different then Symfony Roles. --- src/Entity/User.php | 52 ++++----------------------- src/Security/UsernameUserProvider.php | 14 ++++---- 2 files changed, 13 insertions(+), 53 deletions(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index ad5dc14e33..a5160928c7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -21,12 +21,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * @var array */ - protected $roles; + protected $xdRoles; protected $userId; - protected $samlAttributes; - protected $token; /** @@ -34,38 +32,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ protected $password; - /** - * @var string - */ - protected $salt; - - /** - * @var PasswordHasherInterface - */ - protected $hasher; - /** * @param string $username * @param array $roles * @param int $userId * @param string $token * @param string|null $password - * @param string|null $salt */ public function __construct( string $username, array $roles, int $userId = -1, string $token = '', - ?string $password = '', - ?string $salt = null) + ?string $password = '') { $this->username = $username; - $this->roles = $roles; + $this->xdRoles = $roles; $this->userId = $userId; $this->token = $token; $this->password = $password; - $this->salt = $salt; } @@ -74,10 +59,10 @@ public function __construct( **/ public function getRoles(): array { - $roles = $this->roles; + $roles = $this->xdRoles; // guarantee every user at least has ROLE_USER $roles[] = 'ROLE_USER'; - if (in_array('mgr', $this->roles)) { + if (in_array('mgr', $this->xdRoles)) { $roles[] = 'ROLE_ALLOWED_TO_SWITCH'; $roles[] = 'ROLE_ADMIN'; } @@ -93,14 +78,6 @@ public function getPassword(): ?string return $this->password; } - /** - * {@inheritDoc} - */ - public function getSalt(): ?string - { - return $this->salt; - } - /** * @inheritDoc **/ @@ -146,15 +123,7 @@ public function getToken(): string */ public function isPublicUser(): bool { - return in_array('pub', $this->roles); - } - - /** - * @return User - */ - public static function getPublicUser(): User - { - return new User('Public User', ['pub']); + return in_array('pub', $this->xdRoles); } /** @@ -171,13 +140,4 @@ public static function fromXDUser(\XDUser $xdUser): User $xdUser->getPassword() ); } - - /** - * @param array $attributes - * @return void - */ - public function setSamlAttributes(array $attributes): void - { - $this->samlAttributes = $attributes; - } } diff --git a/src/Security/UsernameUserProvider.php b/src/Security/UsernameUserProvider.php index 6722daf386..875cd37303 100644 --- a/src/Security/UsernameUserProvider.php +++ b/src/Security/UsernameUserProvider.php @@ -62,27 +62,27 @@ public function loadUserByIdentifier(string $identifier): UserInterface $this->logger->debug("Loading User By Identifier: $identifier"); $isSamlUser = $this->classesContains('saml', (new \Exception())->getTrace()); try { - $user = XDUser::getUserByUserName($identifier); + $xdUser = XDUser::getUserByUserName($identifier); - if ($isSamlUser && $user->getUserType() !== SSO_USER_TYPE) { + if ($isSamlUser && $xdUser->getUserType() !== SSO_USER_TYPE) { $this->logger->error('SSO User attempted to log in as a local user.'); throw new InsufficientAuthenticationException(); } } catch (\Exception $e) { $this->logger->debug("Loading User By Id instead"); - $user = XDUser::getUserByID($identifier); - if ($isSamlUser && isset($user) && $user->getUserType() !== SSO_USER_TYPE) { + $xdUser = XDUser::getUserByID($identifier); + if ($isSamlUser && isset($xdUser) && $xdUser->getUserType() !== SSO_USER_TYPE) { $this->logger->error('SSO User attempted to log in as a local user.'); throw new InsufficientAuthenticationException(); } - if (!isset($user)) { + if (!isset($xdUser)) { $this->logger->debug(sprintf('User %s not found', $identifier)); throw new UserNotFoundException("Unable to find User identified by $identifier"); } } - $this->logger->debug("XDUser found by username: {$user->getUserID()} {$user->getUsername()}"); - $foundUser = User::fromXDUser($user); + $this->logger->debug("XDUser found by username: {$xdUser->getUserID()} {$xdUser->getUsername()}"); + $foundUser = User::fromXDUser($xdUser); $this->logger->debug(sprintf('Final User Found: %s %s', $foundUser->getUserIdentifier(), $foundUser->getPassword())); return $foundUser; } From e4d0d69366be76513c834789dfe844205352387d Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:32:10 -0500 Subject: [PATCH 07/83] This test doesn't have anything to test anymore --- .../Controllers/BaseControllerTest.php | 197 ------------------ 1 file changed, 197 deletions(-) delete mode 100644 tests/unit/lib/NewRest/Controllers/BaseControllerTest.php diff --git a/tests/unit/lib/NewRest/Controllers/BaseControllerTest.php b/tests/unit/lib/NewRest/Controllers/BaseControllerTest.php deleted file mode 100644 index aa81352500..0000000000 --- a/tests/unit/lib/NewRest/Controllers/BaseControllerTest.php +++ /dev/null @@ -1,197 +0,0 @@ -getAttributes($user); - $request = $this->getRequest($attributes); - - $baseController = $this->getMockForAbstractClass('Rest\Controllers\BaseControllerProvider'); - $exception = null; - - try { - if ($requestedAcl !== null) { - $authorized = $baseController->authorize($request, array($requestedAcl)); - } else { - $authorized = $baseController->authorize($request); - } - - $this->assertEquals($authorized, $user); - } catch (\Exception $e) { - $exception = $e; - } - - $exceptionClass = $exception !== null ? get_class($exception) : null; - $message = $exception !== null ? $exception->getMessage() : null; - - $this->assertEquals($exceptionClass, $expectedException); - $this->assertEquals($message, $expectedMessage); - - } - - /** - * @param $attributes - * @return \PHPUnit\Framework\MockObject\MockObject - */ - public function getRequest($attributes) - { - $builder = $this->createMock('Symfony\Component\HttpFoundation\Request'); - $builder->attributes = $attributes; - return $builder; - } - - - /** - * @param $user - * @return \PHPUnit\Framework\MockObject\MockObject - */ - public function getAttributes($user) - { - $builder = $this->getMockBuilder('\Symfony\Component\HttpFoundation\ParameterBag'); - $builder->onlyMethods(array('get')); - $mock = $builder->getMock(); - $mock->method('get') - ->with($this->equalTo(BaseControllerProvider::_USER)) - ->willReturn($user); - return $mock; - } - - - /** - * The Data Provider for the before / after tests. - * - * @return array in the form of: - * array( - * array( - * mockUser, - * array('requested', 'acls') - * ), - * ... - * ) - */ - public function generateUserDataSet() - { - $mgr = $this->createUser(array('mgr', 'usr')); - $cd = $this->createUser(array('cd', 'usr')); - $pi = $this->createUser(array('pi', 'usr')); - $usr = $this->createUser(array('usr'), 'usr'); - $sab = $this->createUser(array('usr', 'sab')); - $pub = $this->createUser(array('pub')); - - $accessDeniedException = 'Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException'; - $unauthorizedException = 'Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException'; - - $notAuthorized = BaseControllerProvider::EXCEPTION_MESSAGE; - - $tests = array( - array($mgr, null, null, null), - array($mgr, ROLE_ID_MANAGER, null, null), - - array($cd, null, null, null), - array($cd, ROLE_ID_CENTER_DIRECTOR, null, null), - array($cd, ROLE_ID_MANAGER, $accessDeniedException, $notAuthorized), - - array($pi, null, null, null), - array($pi, ROLE_ID_PRINCIPAL_INVESTIGATOR, null, null), - array($pi, ROLE_ID_USER, null, null), - array($pi, ROLE_ID_MANAGER, $accessDeniedException, $notAuthorized), - array($pi, ROLE_ID_CENTER_DIRECTOR, $accessDeniedException, $notAuthorized), - - array($usr, null, null, null), - array($usr, ROLE_ID_USER, null, null), - array($usr, ROLE_ID_MANAGER, $accessDeniedException, $notAuthorized), - array($usr, ROLE_ID_CENTER_DIRECTOR, $accessDeniedException, $notAuthorized), - array($usr, ROLE_ID_PRINCIPAL_INVESTIGATOR, $accessDeniedException, $notAuthorized), - - array($sab, null, null, null), - array($sab, 'sab', null, null), - array($sab, ROLE_ID_USER, null, null), - array($sab, ROLE_ID_MANAGER, $accessDeniedException, $notAuthorized), - array($sab, ROLE_ID_CENTER_DIRECTOR, $accessDeniedException, $notAuthorized), - array($sab, ROLE_ID_PRINCIPAL_INVESTIGATOR, $accessDeniedException, $notAuthorized), - - array($pub, null, $unauthorizedException, $notAuthorized), - array($pub, ROLE_ID_PUBLIC, null, null), - array($pub, ROLE_ID_USER, $unauthorizedException, $notAuthorized), - array($pub, ROLE_ID_CENTER_DIRECTOR, $unauthorizedException, $notAuthorized), - array($pub, ROLE_ID_MANAGER, $unauthorizedException, $notAuthorized), - array($pub, ROLE_ID_PRINCIPAL_INVESTIGATOR, $unauthorizedException, $notAuthorized) - - ); - return $tests; - } - - /** - * Used to create a mock XDUser object suitable for use in both versions of - * the isAuthorized functions. - * - * @param array $roles an array of strings representing the - * roles / acls this user is assigned - * @return \PHPUnit_Framework_MockObject_MockObject - */ - protected function createUser(array $roles) - { - $builder = $this->getMockBuilder('\XDUser') - ->disableOriginalConstructor() - ->onlyMethods( - array( - 'getRoles', - 'isManager', - 'isPublicUser', - 'hasAcl', - 'hasAcls', - '__toString' - ) - ); - $stub = $builder->getMock(); - $stub->method('getRoles')->willReturn($roles); - $stub->method('isManager')->willReturnCallback(function () use ($roles) { - return in_array(ROLE_ID_MANAGER, $roles); - }); - $stub->method('isPublicUser')->willReturnCallback(function () use ($roles) { - return in_array(ROLE_ID_PUBLIC, $roles); - }); - $stub->method('hasAcl')->willReturnCallback(function () use ($roles) { - $args = func_get_args(); - if (count($args) >= 1) { - $arg = $args[0]; - return in_array($arg, $roles); - } - return false; - }); - $stub->method('__toString')->willReturn(json_encode(array( - 'roles' => $roles, - 'is_manager' => in_array(ROLE_ID_MANAGER, $roles), - 'is_public_user' => in_array(ROLE_ID_PUBLIC, $roles) - ))); - $stub->method('hasAcls')->willreturnCallback( - function () use ($roles) { - $args = func_get_args(); - if (count($args) >= 1 && is_array($args[0])) { - $requested = $args[0]; - $total = 0; - foreach ($requested as $value) { - $found = in_array($value, $roles); - $total += $found === true - ? 1 - : 0; - } - return $total === count($requested); - } - return false; - } - ); - return $stub; - } -} From c66d5d1d9fe354408f7a57dc9f71293e17c3e811 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:32:36 -0500 Subject: [PATCH 08/83] Making sure that APP_SECRET is added to .en --- open_xdmod/modules/xdmod/xdmod.spec.in | 3 +++ 1 file changed, 3 insertions(+) diff --git a/open_xdmod/modules/xdmod/xdmod.spec.in b/open_xdmod/modules/xdmod/xdmod.spec.in index 615817a3d3..97f2a5a3c7 100644 --- a/open_xdmod/modules/xdmod/xdmod.spec.in +++ b/open_xdmod/modules/xdmod/xdmod.spec.in @@ -67,6 +67,9 @@ done # Ensure the var directory is owned by apache so it can be written to. chown apache:xdmod %{_datadir}/%{name}/var +# Ensure we have a default APP_SECRET value in the .env file. +echo "APP_SECRET=$(date | sha512sum | cut -d' ' -f 1)" >> %{_datadir}/%{name}/.env + if [ "$1" -ge 2 ]; then echo "Run xdmod-upgrade to complete the Open XDMoD upgrade process." echo "Refer to http://open.xdmod.org/upgrade.html for more details." From 6292ec6ea50a6dd13872e66c98287a336c108ac4 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:55:35 -0500 Subject: [PATCH 09/83] Removing unused directory / file --- src/Repository/.gitignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/Repository/.gitignore diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 From 27b7fd4eca6405085075d992680b4c76f91b3104 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Tue, 4 Nov 2025 15:57:01 -0500 Subject: [PATCH 10/83] more updates per @jpwhite4 --- src/Controller/OrganizationController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Controller/OrganizationController.php b/src/Controller/OrganizationController.php index ac1109fdbe..0833a77b2c 100644 --- a/src/Controller/OrganizationController.php +++ b/src/Controller/OrganizationController.php @@ -98,12 +98,12 @@ public function getMembers(Request $request): Response /** * * @param Request $request - * @param ?string $memberId + * @param string $memberId * @return Response * @throws Exception */ #[Route('{prefix}/organizations/members/{memberId}/status', requirements: ['prefix' => '.*'], methods: ['POST'])] - public function getMemberStatus(Request $request, ?string $memberId): Response + public function getMemberStatus(Request $request, string $memberId): Response { $user = $this->authorize($request, $this->getParameter('center_related_acls'), true); @@ -154,12 +154,12 @@ public function getMemberStatus(Request $request, ?string $memberId): Response /** * @param Request $request - * @param ?string $memberId + * @param string $memberId * @return Response * @throws Exception */ #[Route('{prefix}/organizations/members/{memberId}/upgrade', requirements: ['prefix' => '.*'], methods: ['POST'])] - public function upgradeMember(Request $request, ?string $memberId): Response + public function upgradeMember(Request $request, string $memberId): Response { $this->logger->error('Upgrading Member Id: ' . var_export($memberId, true)); try { From 223f7047a7ce9fb7fbdbf2c9293db852f6832bed Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 12:04:21 -0500 Subject: [PATCH 11/83] Reverting changes to docker-compose --- tests/playwright/Docker/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/playwright/Docker/docker-compose.yml b/tests/playwright/Docker/docker-compose.yml index eb7970ddf6..3617c93cc1 100644 --- a/tests/playwright/Docker/docker-compose.yml +++ b/tests/playwright/Docker/docker-compose.yml @@ -23,6 +23,7 @@ services: stdin_open: true tty: true ipc: host + init: true links: - xdmod depends_on: From 403da7e7f056673b3b51706e3b45cb9cb327becf Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 13:03:47 -0500 Subject: [PATCH 12/83] CircleCI Updates to install PHP 8.2 These changes ensures that we have PHP 8.2 installed w/ all of the dependencies in the XDMoD docker container. Once the XDMoD image is updated to PHP 8.2 by default then these can be removed. --- .circleci/config.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e5a5c100d2..3a99d007ba 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,6 +34,17 @@ jobs: - run: name: Generate Cert for XDMoD command: docker exec xdmod /usr/bin/openssl req -new -key /etc/pki/tls/private/localhost.key -x509 -sha256 -days 365 -set_serial $RANDOM -extensions v3_req -out /etc/pki/tls/certs/localhost.crt -subj "/C=XX/L=Default City/O=Default Company Ltd" + - run: + name: Update PHP to PHP8.2 + command: | + docker exec xdmod dnf module reset -y php + docker exec xdmod dnf module enable -y php:8.2 + docker exec xdmod dnf install -y php-devel openssl-devel + docker exec xdmod dnf update -y php php-common php-opcache php-cli php-gd php-curl php-pear php-zip php-gmp php-pdo php-xml php-mbstring php-mysqlnd php-pecl-apcu php-pecl-json php-pear + docker exec xdmod pecl uninstall mongodb-1.18.1 + docker exec xdmod pecl install mongodb-1.18.1 + docker exec xdmod pecl install zip + docker exec xdmod dnf remove -y php-devel openssl-devel - run: name: Copy Files for Playwright and XDMoD containers command: | From 64daafdeb4a47ae6afcbfbf9b1dfd921d6818ee8 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 13:05:04 -0500 Subject: [PATCH 13/83] Only copy the files necessary for playwright This change ensures that we only copy the playwright related files into the playwright container. While it may only save a second or two, it also makes it clear to anybody who works / reads this later that the full xdmod src is not necessary for the Playwright Tests. --- .circleci/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a99d007ba..53e3ab201e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,7 +49,8 @@ jobs: name: Copy Files for Playwright and XDMoD containers command: | docker cp ~/project xdmod:/root/xdmod - docker cp ~/project playwright:/root/xdmod + docker exec playwright mkdir -p /root/xdmod/tests/playwright + docker cp ~/project/tests/playwright playwright:/root/xdmod/tests/playwright - run: name: Create test result directories command: | From 4ba0ad8b39144a6b9f9eb0b0e356c212e395e439 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 16:13:37 -0500 Subject: [PATCH 14/83] Adding a few more dirs for playwright tests It turns out that we needed a few more directories for playwright tests to be happy, these are them. --- .circleci/config.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 53e3ab201e..0c0b9176df 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,8 +49,10 @@ jobs: name: Copy Files for Playwright and XDMoD containers command: | docker cp ~/project xdmod:/root/xdmod - docker exec playwright mkdir -p /root/xdmod/tests/playwright - docker cp ~/project/tests/playwright playwright:/root/xdmod/tests/playwright + docker exec playwright mkdir -p /root/xdmod/tests/ /root/xdmod/tests/artifacts/xdmod/ + docker cp ~/project/tests/playwright playwright:/root/xdmod/tests/ + docker cp ~/project/tests/ci playwright:/root/xdmod/tests/ + docker cp ~/project/tests/artifacts/xdmod/ui playwright:/root/xdmod/tests/artifacts/xdmod/ - run: name: Create test result directories command: | From d1cefdab64ed4eac5f3c274d7272c17890729567 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 16:16:22 -0500 Subject: [PATCH 15/83] Monolog Updates These are all changes related to account for PHP 8.2 being more strict about types, as well as moving away from having our own logging levels to just using Monolog Level values. `classes/CCR/CCRDBFormatter`: - Updated the type of `$record` to `LogRecord` from `array` so that it matches `Monolog\Formatter\NormalizerFormatter::format`. `classes/CCR/CCRDBHandler`: - Updated the `$level` default value to be `Monolog\Level:Debug` as opposed to out custom `DEBUG` level. - Removed extraneous code / variables from the `write` function. `classes/CCR/CCRLineFormatter`: - Updated the type of `$record` to `LogRecord` from `array` so that it matches `Monolog\Formatter\LineFormatter::format`. `classes/CCR/Log.php`: - Changed from using custom int log levels (0-7) to the int levels that Monolog provides. - Removed the `convertToMonologLevel` and `convertToCCRLevel` functions and their usage since they are no longer needed ( because we're standardizing on Monolog levels now ). `classes/CCR/Loggable.php`: - Removed usage of the `convertToMonologLevel` function. `tests/component/lib/ETL/IngestorTest.php`: - By default Monolog outputs it's log levels in upper case and since we've chosen to go with Monolog defaults elsewhere we are here as well. `tests/component/lib/LoggerTest.php`: - Updated to use default Monolog log levels ( e.g. Upper Case ). - Updated to use the default integer values for Monolog log levels. --- classes/CCR/CCRDBFormatter.php | 3 +- classes/CCR/CCRDBHandler.php | 15 ++----- classes/CCR/CCRLineFormatter.php | 3 +- classes/CCR/Log.php | 54 ++++++------------------ classes/CCR/Loggable.php | 2 +- tests/component/lib/ETL/IngestorTest.php | 10 ++--- tests/component/lib/LoggerTest.php | 20 ++++----- 7 files changed, 36 insertions(+), 71 deletions(-) diff --git a/classes/CCR/CCRDBFormatter.php b/classes/CCR/CCRDBFormatter.php index c34ff313d6..c2e3f1ebab 100644 --- a/classes/CCR/CCRDBFormatter.php +++ b/classes/CCR/CCRDBFormatter.php @@ -3,6 +3,7 @@ namespace CCR; use Monolog\Formatter\NormalizerFormatter; +use Monolog\LogRecord; class CCRDBFormatter extends NormalizerFormatter { @@ -12,7 +13,7 @@ class CCRDBFormatter extends NormalizerFormatter * all of the properties from the context. If the message is an empty * string the message property is not added. */ - public function format(array $record) + public function format(LogRecord $record) { $vars = parent::format($record); diff --git a/classes/CCR/CCRDBHandler.php b/classes/CCR/CCRDBHandler.php index 88c2c366cf..3c89d34075 100644 --- a/classes/CCR/CCRDBHandler.php +++ b/classes/CCR/CCRDBHandler.php @@ -49,9 +49,9 @@ class CCRDBHandler extends AbstractProcessingHandler * @throws Exception If the 'database' property of the logger section in portal_settings.ini is not present or if its value is empty. * @throws Exception If the 'table' property of the logger section in portal_settings.ini is not present or if its value is empty. */ - public function __construct(iDatabase $db = null, $schema = null, $table = null, $level = Log::DEBUG, $bubble = true) + public function __construct(iDatabase $db = null, $schema = null, $table = null, $level = Level::Debug, $bubble = true) { - parent::__construct(Level::fromValue(Log::convertToMonologLevel($level)), $bubble); + parent::__construct(Level::fromValue($level), $bubble); if (!isset($db)) { $db = DB::factory('logger'); @@ -75,19 +75,12 @@ public function __construct(iDatabase $db = null, $schema = null, $table = null, */ protected function write(LogRecord $record): void { - $message = array_merge( - [ - 'message' => $record->message - ], - $record->context - ); - $sql = sprintf("INSERT INTO %s.%s (id, logtime, ident, priority, message) VALUES(:id, NOW(), :ident, :priority, :message)", $this->schema, $this->table); $params = [ ':id' => $this->getNextId(), ':ident' => $record['channel'], - ':priority' => Log::convertToCCRLevel($record['level']), - ':message' => json_encode($message, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ':priority' => $record['level'], + ':message' => $record['formatted'] ]; $this->db->execute($sql, $params); } diff --git a/classes/CCR/CCRLineFormatter.php b/classes/CCR/CCRLineFormatter.php index ff9dcc162e..a795d17fbf 100644 --- a/classes/CCR/CCRLineFormatter.php +++ b/classes/CCR/CCRLineFormatter.php @@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\NormalizerFormatter; +use Monolog\LogRecord; use Monolog\Utils; class CCRLineFormatter extends LineFormatter @@ -45,7 +46,7 @@ protected function toJson($data, $ignoreErrors = false): string * string and context object. If either the context is empty or the message * is an empty string they are ommitted. */ - public function format(array $record) + public function format(LogRecord $record): string { $vars = NormalizerFormatter::format($record); diff --git a/classes/CCR/Log.php b/classes/CCR/Log.php index d30a9d8065..96970033c5 100644 --- a/classes/CCR/Log.php +++ b/classes/CCR/Log.php @@ -24,14 +24,14 @@ class Log const LINE_FORMAT = "%datetime% [%level_name%] %message%\n"; const TIME_FORMAT = 'Y-m-d H:i:s'; - const EMERG = 0; - const ALERT = 1; - const CRIT = 2; - const ERR = 3; - const WARNING = 4; - const NOTICE = 5; - const INFO = 6; - const DEBUG = 7; + const EMERG = \Monolog\Level::Emergency->value; + const ALERT = \Monolog\Level::Alert->value; + const CRIT = \Monolog\Level::Critical->value; + const ERR = \Monolog\Level::Error->value; + const WARNING = \Monolog\Level::Warning->value; + const NOTICE = \Monolog\Level::Notice->value; + const INFO = \Monolog\Level::Info->value; + const DEBUG = \Monolog\Level::Debug->value; private static $logLevels = array( self::EMERG => \Monolog\Level::Emergency->value, @@ -211,7 +211,7 @@ protected static function getConsoleHandler($ident, array $conf) { $consoleLogLevel = $conf['consoleLogLevel'] ?? self::getDefaultLogLevel('console'); - $handler = new StreamHandler('php://stdout', self::convertToMonologLevel($consoleLogLevel)); + $handler = new StreamHandler('php://stdout', $consoleLogLevel); $handler->setFormatter(new CCRLineFormatter($conf['lineFormat'], $conf['timeFormat'], true)); return $handler; @@ -240,7 +240,7 @@ protected static function getFileHandler($ident, array $conf) $file = $conf['file'] ?? LOG_DIR . '/' . strtolower(preg_replace('/\W/', '_', $ident)) . '.log'; $filePermission = $conf['mode'] ?? 0660; - $handler = new StreamHandler($file, self::convertToMonologLevel($fileLogLevel), true, $filePermission); + $handler = new StreamHandler($file, $fileLogLevel, true, $filePermission); $handler->setFormatter(new CCRLineFormatter($conf['lineFormat'], $conf['timeFormat'], true)); return $handler; @@ -264,7 +264,7 @@ protected static function getDbHandler($ident, array $conf) { $dbLogLevel = $conf['dbLogLevel'] ?? self::getDefaultLogLevel('db'); - $handler = new CCRDBHandler(null, null, null, self::convertToMonologLevel($dbLogLevel)); + $handler = new CCRDBHandler(null, null, null, $dbLogLevel); $handler->setFormatter(new CCRDBFormatter()); return $handler; @@ -295,7 +295,7 @@ protected static function getMailHandler($ident, array $conf) $subject = $conf['emailSubject'] ?? self::getConfiguration('email_subject'); $maxColumnWidth = array_key_exists('maxColumnWidth', $conf) ? $conf['maxColumnWidth'] : 70; - return new NativeMailerHandler($to, $subject, $from, self::convertToMonologLevel($mailLogLevel), true, $maxColumnWidth); + return new NativeMailerHandler($to, $subject, $from, $mailLogLevel, true, $maxColumnWidth); } /** @@ -331,36 +331,6 @@ protected static function getDefaultLogLevel($logType) return $level; } - /** - * Convert a \Monolog\Logger log level value to a CCR\Log log level. - * - * @param int $monologLevel the Monolog log level to be converted to a CCR log level. - * @return int the CCR log level that corresponds to the provided $monologLevel. - * @throws Exception if the provided $monologLevel is not found. - */ - public static function convertToCCRLevel($monologLevel) - { - if (array_key_exists($monologLevel, self::$flippedLogLevels)) { - return self::$flippedLogLevels[$monologLevel]; - } - throw new Exception(sprintf('Unknown Monolog Log Level %s', $monologLevel)); - } - - /** - * Convert a \CCR\Log log level value to a \Monolog\Logger log level. - * - * @param int $ccrLevel - * @return int the Monolog log level that corresponds to the provided $ccrLevel - * @throws Exception if the provided $ccrlLevel is not found. - */ - public static function convertToMonologLevel($ccrLevel) - { - if (array_key_exists($ccrLevel, self::$logLevels)) { - return self::$logLevels[$ccrLevel]; - } - throw new Exception(sprintf('Unknown CCR Log Level %s', $ccrLevel)); - } - /** * Retrieves the specified $option from the logger section of portal_settings.ini * diff --git a/classes/CCR/Loggable.php b/classes/CCR/Loggable.php index 36640979e9..6189eea375 100644 --- a/classes/CCR/Loggable.php +++ b/classes/CCR/Loggable.php @@ -141,7 +141,7 @@ public function logAndThrowException($message, array $options = null) $logMessage['message'] = $message; - $this->logger->log(Log::convertToMonologLevel($logLevel), '', $logMessage); + $this->logger->log($logLevel, '', $logMessage); throw new Exception($message, $code); } diff --git a/tests/component/lib/ETL/IngestorTest.php b/tests/component/lib/ETL/IngestorTest.php index 8d724beab3..6f2a65dae2 100644 --- a/tests/component/lib/ETL/IngestorTest.php +++ b/tests/component/lib/ETL/IngestorTest.php @@ -80,7 +80,7 @@ public function testSqlWarnings() { $numWarnings = 0; if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -103,7 +103,7 @@ public function testHideSqlWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - $this->assertDoesNotMatchRegularExpression('/\[warning\]/', $line); + $this->assertDoesNotMatchRegularExpression('/\[WARNING\]/', $line); } } @@ -129,7 +129,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -145,7 +145,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -194,7 +194,7 @@ public function testStructuredFileIngestorWithSameFile() { $recordsLoaded = array(); foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[notice]') ) { + if ( false !== strpos($line, '[NOTICE]') ) { $matches = array(); if ( preg_match('/xdmod.structured-file.read-people-([0-9])/', $line, $matches) ) { $number = $matches[1]; diff --git a/tests/component/lib/LoggerTest.php b/tests/component/lib/LoggerTest.php index 5e960832fb..6541879777 100644 --- a/tests/component/lib/LoggerTest.php +++ b/tests/component/lib/LoggerTest.php @@ -10,10 +10,10 @@ class LoggerTest extends BaseTest public function provideFileOutput() { return array( - array('debug', 'message field', array('other' => 1.2), '/\[debug\] message field \(other: 1.2\)$/'), - array('info', 'single line string', array(), '/\[info\] single line string$/'), - array('warning', '', array('other' => 'comp123'), '/\[warning\] \(other: comp123\)$/'), - array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[error\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') + array('debug', 'message field', array('other' => 1.2), '/\[DEBUG\] message field \(other: 1.2\)$/'), + array('info', 'single line string', array(), '/\[INFO\] single line string$/'), + array('warning', '', array('other' => 'comp123'), '/\[WARNING\] \(other: comp123\)$/'), + array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[ERROR\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') ); } @@ -43,9 +43,9 @@ public function testFileOutput($level, $message, $context, $expectedRegex) public function provideDbOutput() { return array( - array('debug', 'message field', array('other' => 1.2), 7, '{"message":"message field","other":1.2}'), - array('info', 'single line string', array(), 6, '{"message":"single line string"}'), - array('warning', '', array('other' => 'comp123'), 4, '{"other":"comp123"}') + array('debug', 'message field', array('other' => 1.2), 100, '{"message":"message field","other":1.2}'), + array('info', 'single line string', array(), 200, '{"message":"single line string"}'), + array('warning', '', array('other' => 'comp123'), 300, '{"other":"comp123"}') ); } @@ -99,7 +99,7 @@ public function testDbExceptionFormat() $logoutput = $db->query("SELECT priority, message FROM mod_logger.log_table WHERE ident = '" . $ident . "' AND id > :start_id ORDER BY id ASC", $initial_vals[0]); - $this->assertEquals('3', $logoutput[0]['priority']); + $this->assertEquals('400', $logoutput[0]['priority']); $exceptionSerialization = json_decode($logoutput[0]['message'], true); $this->assertArrayNotHasKey('message', $exceptionSerialization); @@ -131,11 +131,11 @@ public function testCombinedOutput() $logger->debug('message portion', array('context' => 'portion')); $output = file_get_contents($conf['file']); - $this->assertStringEndsWith("[debug] message portion (context: portion)\n", $output); + $this->assertStringEndsWith("[DEBUG] message portion (context: portion)\n", $output); $logoutput = $db->query("SELECT priority, message FROM mod_logger.log_table WHERE ident = 'combined-test' AND id > :start_id ORDER BY id ASC", $initial_vals[0]); $this->assertEquals('{"message":"message portion","context":"portion"}', $logoutput[0]['message']); - $this->assertEquals('7', $logoutput[0]['priority']); + $this->assertEquals('100', $logoutput[0]['priority']); } } From 08f86f14a974d78c5e09ac70e1734961419deac3 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 5 Nov 2025 16:46:34 -0500 Subject: [PATCH 16/83] Minor updates to `post` tests `tests/post/lib/Controllers/MetricExplorerControllerTest.php`: - Just removing the `/` from the begining of the URLs to play nice with Symfony routing. `tests/post/lib/Rest/JobViewerTest.php`: - PHP 8.2 does not like dynamically created properties, this change fixes that. --- tests/post/lib/Controllers/MetricExplorerControllerTest.php | 4 ++-- tests/post/lib/Rest/JobViewerTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/post/lib/Controllers/MetricExplorerControllerTest.php b/tests/post/lib/Controllers/MetricExplorerControllerTest.php index fe83bcb39e..773f19992a 100644 --- a/tests/post/lib/Controllers/MetricExplorerControllerTest.php +++ b/tests/post/lib/Controllers/MetricExplorerControllerTest.php @@ -25,7 +25,7 @@ public function testFilterEncoding() 'search_text' =>'fa' ); - $response = $helper->post('/controllers/metric_explorer.php', null, $params); + $response = $helper->post('controllers/metric_explorer.php', null, $params); $this->assertEquals('application/json', $response[1]['content_type']); $this->assertEquals(200, $response[1]['http_code']); @@ -85,7 +85,7 @@ public function testRawDataEncoding() } EOF; - $response = $helper->post('/controllers/metric_explorer.php', null, json_decode($config)); + $response = $helper->post('controllers/metric_explorer.php', null, json_decode($config)); $this->assertEquals('application/json', $response[1]['content_type']); $this->assertEquals(200, $response[1]['http_code']); diff --git a/tests/post/lib/Rest/JobViewerTest.php b/tests/post/lib/Rest/JobViewerTest.php index 533f6f894e..dc02b3e213 100644 --- a/tests/post/lib/Rest/JobViewerTest.php +++ b/tests/post/lib/Rest/JobViewerTest.php @@ -9,6 +9,8 @@ class JobViewerTest extends BaseTest { const ENDPOINT = 'rest/v0.1/warehouse/'; + private $xdmodhelper; + public function setup(): void { $xdmodConfig = array( 'decodetextasjson' => true ); From b944d9a8900dda185b9e4cf8de25623173686951 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Thu, 6 Nov 2025 10:23:48 -0500 Subject: [PATCH 17/83] Removing Unneeded Code --- .../PasswordHashers/DefaultPasswordHasher.php | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/Security/PasswordHashers/DefaultPasswordHasher.php diff --git a/src/Security/PasswordHashers/DefaultPasswordHasher.php b/src/Security/PasswordHashers/DefaultPasswordHasher.php deleted file mode 100644 index 1a2324a232..0000000000 --- a/src/Security/PasswordHashers/DefaultPasswordHasher.php +++ /dev/null @@ -1,28 +0,0 @@ - Date: Thu, 6 Nov 2025 10:24:58 -0500 Subject: [PATCH 18/83] Removing unused code --- html/gui/general/login.php | 125 ------------------------------------- 1 file changed, 125 deletions(-) delete mode 100644 html/gui/general/login.php diff --git a/html/gui/general/login.php b/html/gui/general/login.php deleted file mode 100644 index 70073a0cb9..0000000000 --- a/html/gui/general/login.php +++ /dev/null @@ -1,125 +0,0 @@ -isSamlConfigured()) { - $xdmodUser = $auth->getXdmodAccount(); - if ($xdmodUser->getAccountStatus()) { - $formal_name = $xdmodUser->getFormalName(); - $xdmodUser->postLogin(); - \xd_rest\setCookies(); - } else { - $message = 'Your account is currently inactive, please contact an administrator.'; - } - } -} catch (Exception $e) { - $message = $e->getMessage(); -} -// Used for Single Sign On or samlErrors -?> - - - - - - - - - -

    - -
    - Contact a system administrator. -

    - - - - - -
    - - - - -
    -

    - Welcome, -

    -

    - -

    - -
    -
    - - - - Date: Thu, 6 Nov 2025 10:26:35 -0500 Subject: [PATCH 19/83] removing unused code --- html/about/roadmap.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 html/about/roadmap.php diff --git a/html/about/roadmap.php b/html/about/roadmap.php deleted file mode 100644 index e69de29bb2..0000000000 From 2a842a624ddeede5da2811110a5288a26993a10f Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Thu, 6 Nov 2025 14:40:15 -0500 Subject: [PATCH 20/83] Token Helper Updates These changes update `src/Security/Helpers/Tokens.php` with the latest logic from `classes/Models/Services/Tokens.php` with one minor modification. The callers of `src/Security/Helpers/Tokens::authenticate` expect that if there is no token present, null will be returned and they can fall back to standard user / password authentication, but the code in `classes/Models/Services/Tokens.php` only ever threw exceptions. I added a `$strict` arguemnt to the `Helpers/Tokens::authenticate` function that is true by default ( which replicates the original behavior of `Services/Tokens` ) but set it to false for all the new rest endpoints that utilized the old `authenticateToken` function. I also removed the `classes/Models/Services/Tokens.php` class as it's no longer used. --- classes/Models/Services/Tokens.php | 249 ------------------- src/Controller/MetricExplorerController.php | 4 +- src/Controller/UserInterfaceController.php | 2 +- src/Controller/WarehouseController.php | 14 +- src/Controller/WarehouseExportController.php | 2 +- src/Security/Helpers/Tokens.php | 236 ++++++++++++++---- tests/integration/lib/TokenAuthTest.php | 2 +- 7 files changed, 199 insertions(+), 310 deletions(-) delete mode 100644 classes/Models/Services/Tokens.php diff --git a/classes/Models/Services/Tokens.php b/classes/Models/Services/Tokens.php deleted file mode 100644 index b3c2d97e83..0000000000 --- a/classes/Models/Services/Tokens.php +++ /dev/null @@ -1,249 +0,0 @@ -headers->has('Authorization')) { - $token = self::getTokenFromHeader($request->headers->get('Authorization')); - } - // If the token is not in the header, then fall back to extracting from - // the GET/POST params. - if (empty($token)) { - $token = $request->get('Bearer'); - } - // If we still haven't found a token, then authentication fails. - if (empty($token)) { - self::throwUnauthorized(self::MISSING_TOKEN_MESSAGE); - } - return self::authenticateToken($token, $request->getPathInfo()); - } - - /** - * This function is a stop-gap that is meant to be used to protect controller endpoints until they can be moved to - * the new REST stack. - * - * @return XDUser the successfully authenticated user. - * - * @throws \Exception if unable to retrieve a database connection. - * @throws UnauthorizedHttpException if the token is missing, malformed, invalid, or expired. - */ - public static function authenticateController() - { - $token = null; - // Try to extract the token from the header. - $headers = getallheaders(); - if (!empty($headers['Authorization'])) { - $token = self::getTokenFromHeader($headers['Authorization']); - } - // If the token is not in the header, then fall back to extracting from - // the GET/POST params. - if (empty($token)) { - if (isset($_GET['Bearer']) && is_string($_GET['Bearer'])) { - $token = $_GET['Bearer']; - } elseif (isset($_POST['Bearer']) && is_string($_POST['Bearer'])) { - $token = $_POST['Bearer']; - } - } - // If we still haven't found a token, then authentication fails. - if (empty($token)) { - self::throwUnauthorized(self::MISSING_TOKEN_MESSAGE); - } - return self::authenticateToken($token); - } - - /** - * Authenticate either an API token or a JSON Web Token. - * - * @param string $rawToken - * @param string | null $endpoint the endpoint being requested, used only for logging. - * - * @return XDUser the successfully authenticated user. - * - * @throws \Exception if unable to retrieve a database connection. - * @throws UnauthorizedHttpException if the token is missing, malformed, invalid, or expired. - */ - private static function authenticateToken(string $rawToken, string $endpoint = null) - { - // Determine token type - $tokenParts = explode('.', $rawToken); - $tokenPartsSize = sizeof($tokenParts); - if ($tokenPartsSize === 2) { - $userId = $tokenParts[0]; - $token = $tokenParts[1]; - $tokenType = 'API token'; - $authenticatedUser = self::authenticateAPIToken($userId, $token); - } elseif ($tokenPartsSize === 3) { - $tokenType = 'JSON Web Token'; - $authenticatedUser = self::authenticateJSONWebToken($rawToken); - } else { - self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); - } - - // Log the request so we can count it in our reporting of usage of the - // Data Analytics Framework. - $logger = Log::factory( - 'daf', - [ - 'console' => false, - 'file' => false, - 'mail' => false - ] - ); - - $logger->info( - 'User ' . $authenticatedUser->getUserName() - . ' (' . $authenticatedUser->getUserID() . ')' - . ' requested ' - . (!is_null($endpoint) ? $endpoint : $_SERVER['SCRIPT_NAME']) - . ' with ' . $tokenType - . ' using ' . $_SERVER['HTTP_USER_AGENT'] - ); - - return $authenticatedUser; - } - - /** - * Authenticate a user using an API token. - * - * @param string $userId - * @param string $token - * - * @return XDUser The successfully authenticated user. - * - * @throws UnauthorizedHttpException if the token is malformed, invalid, or expired - */ - private static function authenticateAPIToken($userId, $token) - { - $db = DB::factory('database'); - $query = <<query($query, array(':user_id' => $userId)); - - if (count($row) === 0) { - self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); - } - - $expectedToken = $row[0]['token']; - $expiresOn = $row[0]['expires_on']; - $dbUserId = $row[0]['user_id']; - - // Check that expected token isn't expired. - $now = new \DateTime(); - $expires = new \DateTime($expiresOn); - if ($expires < $now) { - self::throwUnauthorized(self::EXPIRED_TOKEN_MESSAGE); - } - - // finally check that the provided token matches its stored hash. - if (!password_verify($token, $expectedToken)) { - self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); - } - - return XDUser::getUserByID($dbUserId); - } - - /** - * Authenticate a user using a JSON Web Token. - * - * @param string $jwt - * - * @return XDUser The successfully authenticated user. - * - * @throws UnauthorizedHttpException if the token is invalid or expired - * - */ - private static function authenticateJSONWebToken($jwt) - { - try { - $claims = JsonWebToken::decode($jwt); - } catch (UnexpectedValueException | SignatureInvalidException $e) { - self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); - } catch (ExpiredException $e) { - self::throwUnauthorized(self::EXPIRED_TOKEN_MESSAGE); - } - $username = $claims->sub; - - $db = DB::factory('database'); - $query = <<query($query, array(':username' => $username)); - if (count($row) !== 1) { - self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); - } - return XDUser::getUserByUserName($username); - } - - /** - * Extract the bearer token from an authorization header string. - * - * @param string $header - * @return string | null the token if the header has the 'Bearer' key, null otherwise. - */ - public static function getTokenFromHeader(string $header) - { - if (0 !== strpos($header, 'Bearer ')) { - return null; - } - return substr($header, strlen('Bearer') + 1); - } - - /** - * Throw a 401 Unauthorized exception with the given message and indicating - * that a Bearer token should be used for authentication. - * - * @param string $message - * @throws UnauthorizedHttpException - */ - public static function throwUnauthorized($message) - { - throw new UnauthorizedHttpException('Bearer', $message); - } -} diff --git a/src/Controller/MetricExplorerController.php b/src/Controller/MetricExplorerController.php index 57c0cff39f..9deafcc83b 100644 --- a/src/Controller/MetricExplorerController.php +++ b/src/Controller/MetricExplorerController.php @@ -439,7 +439,7 @@ public function getData(Request $request): Response public function getDimensionValues(Request $request): Response { try { - $user = $this->tokenHelper->authenticateToken($request); + $user = $this->tokenHelper->authenticate($request, false); // If token authentication failed then fallback to the standard session based authentication method. if ($user === null) { @@ -492,7 +492,7 @@ public function getDimensionValues(Request $request): Response public function getDwDescriptors(Request $request): Response { try { - $user = $this->tokenHelper->authenticateToken($request); + $user = $this->tokenHelper->authenticate($request, false); // If token authentication failed then fallback to the standard session based authentication method. if ($user === null) { diff --git a/src/Controller/UserInterfaceController.php b/src/Controller/UserInterfaceController.php index 3cc8f4d59e..d7bf71d519 100644 --- a/src/Controller/UserInterfaceController.php +++ b/src/Controller/UserInterfaceController.php @@ -106,7 +106,7 @@ public function getCharts(Request $request): Response { $this->logger->debug('Calling Get Charts'); try { - $user = $this->tokenHelper->authenticateToken($request); + $user = $this->tokenHelper->authenticate($request, false); // If token authentication failed then fallback to the standard session based authentication method. if ($user === null) { diff --git a/src/Controller/WarehouseController.php b/src/Controller/WarehouseController.php index 455c9b8671..770319d4e9 100644 --- a/src/Controller/WarehouseController.php +++ b/src/Controller/WarehouseController.php @@ -36,6 +36,7 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; +use Twig\Environment; use UserStorage; use XDUser; use function xd_response\buildError; @@ -2153,13 +2154,12 @@ private function getUserStore(XDUser $user, string $realm): UserStorage #[Route('{prefix}/warehouse/raw-data', requirements: ['prefix' => '.*'], methods: ['GET'])] public function getRawData(Request $request): Response { - $this->logger->debug('Getting Raw Data!'); - $this->logger->debug('Authenticating User By Token'); - $user = parent::authenticateToken($request); + $user = $this->tokenHelper->authenticate($request, false); + /*TODO: Validate that this is supposed to be here. */ if ($user === null) { $this->logger->error('Unable to authenticate user by token'); - return $this->json(buildError(new Exception('No Token Provided.')), 401, [ + return $this->json(buildError(new Exception('No token provided.')), 401, [ 'WWW-Authenticate' => 'Bearer' ]); } @@ -2232,6 +2232,8 @@ public function getRawData(Request $request): Response } /** + * Specifically for the Data Analytics Framework + * * @param Request $request * @return Response */ @@ -2239,7 +2241,7 @@ public function getRawData(Request $request): Response #[Route('{prefix}/warehouse/resources', requirements: ['prefix' => '.*'], methods: ['GET'])] public function getResources(Request $request): Response { - Tokens::authenticate($request); + $this->tokenHelper->authenticate($request); $config = \Configuration\XdmodConfiguration::assocArrayFactory('resource_metadata.json', CONFIG_DIR); @@ -2665,7 +2667,7 @@ protected function sendAttachment(string $content, string $filename, string $mim #[Route('{prefix}/warehouse/raw-data/limit', requirements: ['prefix' => '.*'], methods: ['GET'])] public function getRawDataLimit(Request $request): JsonResponse { - parent::authenticateToken($request); + $this->tokenHelper->authenticate($request); $limit = $this->getConfiguredRawDataLimit(); diff --git a/src/Controller/WarehouseExportController.php b/src/Controller/WarehouseExportController.php index 58e6cc90a8..e9def15fff 100644 --- a/src/Controller/WarehouseExportController.php +++ b/src/Controller/WarehouseExportController.php @@ -70,7 +70,7 @@ public function getRealms(Request $request): Response // We need to wrap the token authentication because we want the token authentication to be optional, proceeding // to the normal session authentication if a token is not provided. try { - $user = $this->tokenHelper->authenticateToken($request); + $user = $this->tokenHelper->authenticate($request, false); } catch (Exception $e) { // NOOP } diff --git a/src/Security/Helpers/Tokens.php b/src/Security/Helpers/Tokens.php index f8ee9909af..544c9201b7 100644 --- a/src/Security/Helpers/Tokens.php +++ b/src/Security/Helpers/Tokens.php @@ -3,11 +3,14 @@ namespace Access\Security\Helpers; use CCR\DB; -use DateTime; -use Exception; +use CCR\Log; +use Firebase\JWT\SignatureInvalidException; +use Firebase\JWT\ExpiredException; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Models\Services\JsonWebToken; +use UnexpectedValueException; use XDUser; /** @@ -16,6 +19,11 @@ */ class Tokens { + + const MISSING_TOKEN_MESSAGE = 'No token provided.'; + const INVALID_TOKEN_MESSAGE = 'Invalid token.'; + const EXPIRED_TOKEN_MESSAGE = 'Token has expired.'; + /** * This is the key that will be used when adding an API Token to a request's headers. */ @@ -41,7 +49,7 @@ public function __construct(LoggerInterface $logger) * XDUser object will be returned for the provided $userId. If not, an exception will be thrown. * * @param int|string $userId The id used to look up the the users hashed token. - * @param string $password The value to be checked against the retrieved hashed token. + * @param string $token The value to be checked against the retrieved hashed token. * * @return XDUser for the provided $userId, if the authentication is successful else an exception will be thrown. * @@ -50,7 +58,7 @@ public function __construct(LoggerInterface $logger) * if the stored token for $userId has expired, or * if the provided $token doesn't match the stored hash. */ - public function authenticate($userId, string $password): ?XDUser + /*public function authenticate($userId, string $token): ?XDUser { $this->logger->info(sprintf('Beginning Authentication for %s', $userId)); @@ -85,14 +93,14 @@ public function authenticate($userId, string $password): ?XDUser } // finally check that the provided token matches it's stored hash. - if (!password_verify($password, $expectedToken)) { + if (!password_verify($token, $expectedToken)) { $this->logger->debug(sprintf('User\'s (%s) token is invalid.', $userId)); throw new UnauthorizedHttpException(Tokens::HEADER_KEY, 'Invalid token.'); } // and if we've made it this far we can safely return the requested Users data. return XDUser::getUserByID($dbUserId); - } + }*/ /** * This function is a stop-gap that is meant to be used to protect controller endpoints until they can be moved to @@ -100,71 +108,199 @@ public function authenticate($userId, string $password): ?XDUser * * @return XDUser|null if the authentication is successful then an XDUser instance for the authenticated user will * be returned, if the authentication is not successful then null will be returned. + * + * @throws \Exception if there is a problem w/ authenticating the token for this request. */ - public function authenticateToken(Request $request): ?XDUser + public function authenticate(Request $request, $strict = true): ?XDUser { - $this->logger->info('Beginning Token Authentication'); - - $rawToken = self::getRawToken($request); - if (empty($rawToken)) { - // we want to the token authentication to be optional so instead of throwing an exception we return null. - // This allows us to provide token authentication to existing endpoints without impeding their normal use. - return null; + $token = null; + // Try to extract the token from the header. + if ($request->headers->has('Authorization')) { + $token = self::getTokenFromHeader($request->headers->get('Authorization')); + } + // If the token is not in the header, then fall back to extracting from + // the GET/POST params. + if (empty($token)) { + $token = $request->get('Bearer'); } - // We expect the token to be in the form /^(\d+).(.*)$/ so just make sure it at least has the required delimiter. - $delimPosition = strpos($rawToken, Tokens::DELIMITER); - if ($delimPosition === false) { - // Same as above, token authentication is optional so we return null instead of throwing an exception. + // If we still haven't found a token, then authentication fails. + if (empty($token)) { + // if we're being strict about things, throw an exception + if ($strict) { + self::throwUnauthorized(self::MISSING_TOKEN_MESSAGE); + } + + // else, this is for endpoints that have optional token authentication. By returning null we allow normal + // authentication to continue. return null; } - $userId = substr($rawToken, 0, $delimPosition); - $token = substr($rawToken, $delimPosition + 1); + return self::authenticateToken($token, $request->getPathInfo()); + } - try { - return Tokens::authenticate($userId, $token); - } catch (Exception $e) { - // and again, same as above. - return null; + /** + * Authenticate either an API token or a JSON Web Token. + * + * @param string $rawToken + * @param string | null $endpoint the endpoint being requested, used only for logging. + * + * @return XDUser the successfully authenticated user. + * + * @throws \Exception if unable to retrieve a database connection. + * @throws UnauthorizedHttpException if the token is missing, malformed, invalid, or expired. + */ + private static function authenticateToken(string $rawToken, string $endpoint = null): XDUser + { + // Determine token type + $tokenParts = explode('.', $rawToken); + $tokenPartsSize = sizeof($tokenParts); + if ($tokenPartsSize === 2) { + $userId = $tokenParts[0]; + $token = $tokenParts[1]; + $tokenType = 'API token'; + $authenticatedUser = self::authenticateAPIToken($userId, $token); + } elseif ($tokenPartsSize === 3) { + $tokenType = 'JSON Web Token'; + $authenticatedUser = self::authenticateJSONWebToken($rawToken); + } else { + self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); } + + // Log the request so we can count it in our reporting of usage of the + // Data Analytics Framework. + $logger = Log::factory( + 'daf', + [ + 'console' => false, + 'file' => false, + 'mail' => false + ] + ); + + $logger->info( + 'User ' . $authenticatedUser->getUserName() + . ' (' . $authenticatedUser->getUserID() . ')' + . ' requested ' + . (!is_null($endpoint) ? $endpoint : $_SERVER['SCRIPT_NAME']) + . ' with ' . $tokenType + . ' using ' . $_SERVER['HTTP_USER_AGENT'] + ); + + return $authenticatedUser; } /** - * Attempt to retrieve the raw API Token from one of the following sources: - * - Headers - * - GET Parameters - * - POST Parameters + * Authenticate a user using an API token. + * + * @param string $userId + * @param string $token * - * @return null|string returns the api token if found else it returns null. + * @return XDUser The successfully authenticated user. + * + * @throws UnauthorizedHttpException if the token is malformed, invalid, or expired + * @throws \Exception if there is an error encountered constructing the $expires DateTime. */ - private function getRawToken(Request $request): ?string + private static function authenticateAPIToken($userId, $token): XDUser { - // Try to find the token in the `Authorization` header. - $headers = getallheaders(); - if (!empty($headers['Authorization'])) { - $authorizationHeader = $headers['Authorization']; - if (is_string($authorizationHeader) && strpos($authorizationHeader, Tokens::HEADER_KEY) !== false) { - $this->logger->info('Valid token found in Header'); - // The format for including the token in the header is slightly different then when included as a get or - // post parameter. Here the value will be in the form: `Bearer ` - return substr( - $authorizationHeader, - strpos($authorizationHeader, Tokens::HEADER_KEY) + strlen(Tokens::HEADER_KEY) + 1 - ); - } + $db = DB::factory('database'); + $query = <<query($query, array(':user_id' => $userId)); + + if (count($row) === 0) { + self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); } - // If it's not in the headers, try $_GET - if (isset($_GET[Tokens::HEADER_KEY]) && is_string($_GET[Tokens::HEADER_KEY])) { - return $_GET[Tokens::HEADER_KEY]; + $expectedToken = $row[0]['token']; + $expiresOn = $row[0]['expires_on']; + $dbUserId = $row[0]['user_id']; + + // Check that expected token isn't expired. + $now = new \DateTime(); + $expires = new \DateTime($expiresOn); + if ($expires < $now) { + self::throwUnauthorized(self::EXPIRED_TOKEN_MESSAGE); } - if (isset($_POST[Tokens::HEADER_KEY]) && is_string($_POST[Tokens::HEADER_KEY])) { - return $_POST[Tokens::HEADER_KEY]; + // finally check that the provided token matches its stored hash. + if (!password_verify($token, $expectedToken)) { + self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); } - return null; + return XDUser::getUserByID($dbUserId); + } + + /** + * Authenticate a user using a JSON Web Token. + * + * @param string $jwt + * + * @return XDUser The successfully authenticated user. + * + * @throws UnauthorizedHttpException if the token is invalid or expired + * @throws \Exception if there is a problem decoding $jwt + * @throws \Exception if there is a problem retrieving a connection to the database. + * @throws \Exception if a user is not found for the provided $jwt. + */ + private static function authenticateJSONWebToken($jwt): XDUser + { + try { + $claims = JsonWebToken::decode($jwt); + } catch (ExpiredException $e) { + self::throwUnauthorized(self::EXPIRED_TOKEN_MESSAGE); + } catch (UnexpectedValueException | SignatureInvalidException $e) { + self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); + } + $username = $claims->sub; + + $db = DB::factory('database'); + $query = <<query($query, array(':username' => $username)); + if (count($row) !== 1) { + self::throwUnauthorized(self::INVALID_TOKEN_MESSAGE); + } + return XDUser::getUserByUserName($username); + } + + /** + * Extract the bearer token from an authorization header string. + * + * @param string $header + * @return string | null the token if the header has the 'Bearer' key, null otherwise. + */ + public static function getTokenFromHeader(string $header): ?string + { + if (!str_starts_with($header, 'Bearer ')) { + return null; + } + return substr($header, strlen('Bearer') + 1); + } + + /** + * Throw a 401 Unauthorized exception with the given message and indicating + * that a Bearer token should be used for authentication. + * + * @param string $message + * @throws UnauthorizedHttpException + */ + public static function throwUnauthorized(string $message) + { + throw new UnauthorizedHttpException('Bearer', $message); } } diff --git a/tests/integration/lib/TokenAuthTest.php b/tests/integration/lib/TokenAuthTest.php index 81d2b82bb6..45f6bc869a 100644 --- a/tests/integration/lib/TokenAuthTest.php +++ b/tests/integration/lib/TokenAuthTest.php @@ -4,7 +4,7 @@ use CCR\DB; use Exception; -use Models\Services\Tokens; +use Access\Security\Helpers\Tokens; use IntegrationTests\TestHarness\XdmodTestHelper; /** From e0cf52396a5895b2d5a503806bfd8506d0211509 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Thu, 6 Nov 2025 14:52:39 -0500 Subject: [PATCH 21/83] Revert "Monolog Updates" This reverts commit a3c558a139f1ac8268f500bdecf6fa53be509710. --- classes/CCR/CCRDBFormatter.php | 3 +- classes/CCR/CCRDBHandler.php | 15 +++++-- classes/CCR/CCRLineFormatter.php | 3 +- classes/CCR/Log.php | 54 ++++++++++++++++++------ classes/CCR/Loggable.php | 2 +- tests/component/lib/ETL/IngestorTest.php | 10 ++--- tests/component/lib/LoggerTest.php | 20 ++++----- 7 files changed, 71 insertions(+), 36 deletions(-) diff --git a/classes/CCR/CCRDBFormatter.php b/classes/CCR/CCRDBFormatter.php index c2e3f1ebab..c34ff313d6 100644 --- a/classes/CCR/CCRDBFormatter.php +++ b/classes/CCR/CCRDBFormatter.php @@ -3,7 +3,6 @@ namespace CCR; use Monolog\Formatter\NormalizerFormatter; -use Monolog\LogRecord; class CCRDBFormatter extends NormalizerFormatter { @@ -13,7 +12,7 @@ class CCRDBFormatter extends NormalizerFormatter * all of the properties from the context. If the message is an empty * string the message property is not added. */ - public function format(LogRecord $record) + public function format(array $record) { $vars = parent::format($record); diff --git a/classes/CCR/CCRDBHandler.php b/classes/CCR/CCRDBHandler.php index 3c89d34075..88c2c366cf 100644 --- a/classes/CCR/CCRDBHandler.php +++ b/classes/CCR/CCRDBHandler.php @@ -49,9 +49,9 @@ class CCRDBHandler extends AbstractProcessingHandler * @throws Exception If the 'database' property of the logger section in portal_settings.ini is not present or if its value is empty. * @throws Exception If the 'table' property of the logger section in portal_settings.ini is not present or if its value is empty. */ - public function __construct(iDatabase $db = null, $schema = null, $table = null, $level = Level::Debug, $bubble = true) + public function __construct(iDatabase $db = null, $schema = null, $table = null, $level = Log::DEBUG, $bubble = true) { - parent::__construct(Level::fromValue($level), $bubble); + parent::__construct(Level::fromValue(Log::convertToMonologLevel($level)), $bubble); if (!isset($db)) { $db = DB::factory('logger'); @@ -75,12 +75,19 @@ public function __construct(iDatabase $db = null, $schema = null, $table = null, */ protected function write(LogRecord $record): void { + $message = array_merge( + [ + 'message' => $record->message + ], + $record->context + ); + $sql = sprintf("INSERT INTO %s.%s (id, logtime, ident, priority, message) VALUES(:id, NOW(), :ident, :priority, :message)", $this->schema, $this->table); $params = [ ':id' => $this->getNextId(), ':ident' => $record['channel'], - ':priority' => $record['level'], - ':message' => $record['formatted'] + ':priority' => Log::convertToCCRLevel($record['level']), + ':message' => json_encode($message, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ]; $this->db->execute($sql, $params); } diff --git a/classes/CCR/CCRLineFormatter.php b/classes/CCR/CCRLineFormatter.php index a795d17fbf..ff9dcc162e 100644 --- a/classes/CCR/CCRLineFormatter.php +++ b/classes/CCR/CCRLineFormatter.php @@ -4,7 +4,6 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\NormalizerFormatter; -use Monolog\LogRecord; use Monolog\Utils; class CCRLineFormatter extends LineFormatter @@ -46,7 +45,7 @@ protected function toJson($data, $ignoreErrors = false): string * string and context object. If either the context is empty or the message * is an empty string they are ommitted. */ - public function format(LogRecord $record): string + public function format(array $record) { $vars = NormalizerFormatter::format($record); diff --git a/classes/CCR/Log.php b/classes/CCR/Log.php index 96970033c5..d30a9d8065 100644 --- a/classes/CCR/Log.php +++ b/classes/CCR/Log.php @@ -24,14 +24,14 @@ class Log const LINE_FORMAT = "%datetime% [%level_name%] %message%\n"; const TIME_FORMAT = 'Y-m-d H:i:s'; - const EMERG = \Monolog\Level::Emergency->value; - const ALERT = \Monolog\Level::Alert->value; - const CRIT = \Monolog\Level::Critical->value; - const ERR = \Monolog\Level::Error->value; - const WARNING = \Monolog\Level::Warning->value; - const NOTICE = \Monolog\Level::Notice->value; - const INFO = \Monolog\Level::Info->value; - const DEBUG = \Monolog\Level::Debug->value; + const EMERG = 0; + const ALERT = 1; + const CRIT = 2; + const ERR = 3; + const WARNING = 4; + const NOTICE = 5; + const INFO = 6; + const DEBUG = 7; private static $logLevels = array( self::EMERG => \Monolog\Level::Emergency->value, @@ -211,7 +211,7 @@ protected static function getConsoleHandler($ident, array $conf) { $consoleLogLevel = $conf['consoleLogLevel'] ?? self::getDefaultLogLevel('console'); - $handler = new StreamHandler('php://stdout', $consoleLogLevel); + $handler = new StreamHandler('php://stdout', self::convertToMonologLevel($consoleLogLevel)); $handler->setFormatter(new CCRLineFormatter($conf['lineFormat'], $conf['timeFormat'], true)); return $handler; @@ -240,7 +240,7 @@ protected static function getFileHandler($ident, array $conf) $file = $conf['file'] ?? LOG_DIR . '/' . strtolower(preg_replace('/\W/', '_', $ident)) . '.log'; $filePermission = $conf['mode'] ?? 0660; - $handler = new StreamHandler($file, $fileLogLevel, true, $filePermission); + $handler = new StreamHandler($file, self::convertToMonologLevel($fileLogLevel), true, $filePermission); $handler->setFormatter(new CCRLineFormatter($conf['lineFormat'], $conf['timeFormat'], true)); return $handler; @@ -264,7 +264,7 @@ protected static function getDbHandler($ident, array $conf) { $dbLogLevel = $conf['dbLogLevel'] ?? self::getDefaultLogLevel('db'); - $handler = new CCRDBHandler(null, null, null, $dbLogLevel); + $handler = new CCRDBHandler(null, null, null, self::convertToMonologLevel($dbLogLevel)); $handler->setFormatter(new CCRDBFormatter()); return $handler; @@ -295,7 +295,7 @@ protected static function getMailHandler($ident, array $conf) $subject = $conf['emailSubject'] ?? self::getConfiguration('email_subject'); $maxColumnWidth = array_key_exists('maxColumnWidth', $conf) ? $conf['maxColumnWidth'] : 70; - return new NativeMailerHandler($to, $subject, $from, $mailLogLevel, true, $maxColumnWidth); + return new NativeMailerHandler($to, $subject, $from, self::convertToMonologLevel($mailLogLevel), true, $maxColumnWidth); } /** @@ -331,6 +331,36 @@ protected static function getDefaultLogLevel($logType) return $level; } + /** + * Convert a \Monolog\Logger log level value to a CCR\Log log level. + * + * @param int $monologLevel the Monolog log level to be converted to a CCR log level. + * @return int the CCR log level that corresponds to the provided $monologLevel. + * @throws Exception if the provided $monologLevel is not found. + */ + public static function convertToCCRLevel($monologLevel) + { + if (array_key_exists($monologLevel, self::$flippedLogLevels)) { + return self::$flippedLogLevels[$monologLevel]; + } + throw new Exception(sprintf('Unknown Monolog Log Level %s', $monologLevel)); + } + + /** + * Convert a \CCR\Log log level value to a \Monolog\Logger log level. + * + * @param int $ccrLevel + * @return int the Monolog log level that corresponds to the provided $ccrLevel + * @throws Exception if the provided $ccrlLevel is not found. + */ + public static function convertToMonologLevel($ccrLevel) + { + if (array_key_exists($ccrLevel, self::$logLevels)) { + return self::$logLevels[$ccrLevel]; + } + throw new Exception(sprintf('Unknown CCR Log Level %s', $ccrLevel)); + } + /** * Retrieves the specified $option from the logger section of portal_settings.ini * diff --git a/classes/CCR/Loggable.php b/classes/CCR/Loggable.php index 6189eea375..36640979e9 100644 --- a/classes/CCR/Loggable.php +++ b/classes/CCR/Loggable.php @@ -141,7 +141,7 @@ public function logAndThrowException($message, array $options = null) $logMessage['message'] = $message; - $this->logger->log($logLevel, '', $logMessage); + $this->logger->log(Log::convertToMonologLevel($logLevel), '', $logMessage); throw new Exception($message, $code); } diff --git a/tests/component/lib/ETL/IngestorTest.php b/tests/component/lib/ETL/IngestorTest.php index 6f2a65dae2..8d724beab3 100644 --- a/tests/component/lib/ETL/IngestorTest.php +++ b/tests/component/lib/ETL/IngestorTest.php @@ -80,7 +80,7 @@ public function testSqlWarnings() { $numWarnings = 0; if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[WARNING]') ) { + if ( false !== strpos($line, '[warning]') ) { $numWarnings++; } } @@ -103,7 +103,7 @@ public function testHideSqlWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - $this->assertDoesNotMatchRegularExpression('/\[WARNING\]/', $line); + $this->assertDoesNotMatchRegularExpression('/\[warning\]/', $line); } } @@ -129,7 +129,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[WARNING]') ) { + if ( false !== strpos($line, '[warning]') ) { $numWarnings++; } } @@ -145,7 +145,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[WARNING]') ) { + if ( false !== strpos($line, '[warning]') ) { $numWarnings++; } } @@ -194,7 +194,7 @@ public function testStructuredFileIngestorWithSameFile() { $recordsLoaded = array(); foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[NOTICE]') ) { + if ( false !== strpos($line, '[notice]') ) { $matches = array(); if ( preg_match('/xdmod.structured-file.read-people-([0-9])/', $line, $matches) ) { $number = $matches[1]; diff --git a/tests/component/lib/LoggerTest.php b/tests/component/lib/LoggerTest.php index 6541879777..5e960832fb 100644 --- a/tests/component/lib/LoggerTest.php +++ b/tests/component/lib/LoggerTest.php @@ -10,10 +10,10 @@ class LoggerTest extends BaseTest public function provideFileOutput() { return array( - array('debug', 'message field', array('other' => 1.2), '/\[DEBUG\] message field \(other: 1.2\)$/'), - array('info', 'single line string', array(), '/\[INFO\] single line string$/'), - array('warning', '', array('other' => 'comp123'), '/\[WARNING\] \(other: comp123\)$/'), - array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[ERROR\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') + array('debug', 'message field', array('other' => 1.2), '/\[debug\] message field \(other: 1.2\)$/'), + array('info', 'single line string', array(), '/\[info\] single line string$/'), + array('warning', '', array('other' => 'comp123'), '/\[warning\] \(other: comp123\)$/'), + array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[error\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') ); } @@ -43,9 +43,9 @@ public function testFileOutput($level, $message, $context, $expectedRegex) public function provideDbOutput() { return array( - array('debug', 'message field', array('other' => 1.2), 100, '{"message":"message field","other":1.2}'), - array('info', 'single line string', array(), 200, '{"message":"single line string"}'), - array('warning', '', array('other' => 'comp123'), 300, '{"other":"comp123"}') + array('debug', 'message field', array('other' => 1.2), 7, '{"message":"message field","other":1.2}'), + array('info', 'single line string', array(), 6, '{"message":"single line string"}'), + array('warning', '', array('other' => 'comp123'), 4, '{"other":"comp123"}') ); } @@ -99,7 +99,7 @@ public function testDbExceptionFormat() $logoutput = $db->query("SELECT priority, message FROM mod_logger.log_table WHERE ident = '" . $ident . "' AND id > :start_id ORDER BY id ASC", $initial_vals[0]); - $this->assertEquals('400', $logoutput[0]['priority']); + $this->assertEquals('3', $logoutput[0]['priority']); $exceptionSerialization = json_decode($logoutput[0]['message'], true); $this->assertArrayNotHasKey('message', $exceptionSerialization); @@ -131,11 +131,11 @@ public function testCombinedOutput() $logger->debug('message portion', array('context' => 'portion')); $output = file_get_contents($conf['file']); - $this->assertStringEndsWith("[DEBUG] message portion (context: portion)\n", $output); + $this->assertStringEndsWith("[debug] message portion (context: portion)\n", $output); $logoutput = $db->query("SELECT priority, message FROM mod_logger.log_table WHERE ident = 'combined-test' AND id > :start_id ORDER BY id ASC", $initial_vals[0]); $this->assertEquals('{"message":"message portion","context":"portion"}', $logoutput[0]['message']); - $this->assertEquals('100', $logoutput[0]['priority']); + $this->assertEquals('7', $logoutput[0]['priority']); } } From 53cd71730e7bbb6f8bdeaf495a47491ff798cf38 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 13:33:24 -0500 Subject: [PATCH 22/83] Removing a debug artifact --- html/gui/js/dashboard/test-failed-1.png | Bin 108428 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 html/gui/js/dashboard/test-failed-1.png diff --git a/html/gui/js/dashboard/test-failed-1.png b/html/gui/js/dashboard/test-failed-1.png deleted file mode 100644 index 851c9d3eb73cedac3913dece2b2db98ec9d6fec1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 108428 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%0D^>IX6ze@7jALW)r_FJJZjI0 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 From ec419258f3b3fc1d248c4c3fb0b8a740aeed80e0 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:03:03 -0500 Subject: [PATCH 23/83] reverting previous change to ArrayIngestor --- classes/DB/ArrayIngestor.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/classes/DB/ArrayIngestor.php b/classes/DB/ArrayIngestor.php index 214fe824e8..50350c515a 100644 --- a/classes/DB/ArrayIngestor.php +++ b/classes/DB/ArrayIngestor.php @@ -23,12 +23,12 @@ class ArrayIngestor implements Ingestor function __construct( iDatabase $dest_db, - $insert_table, array $source_data = array(), + $insert_table, array $insert_fields = array(), array $post_ingest_update_statements = array(), - $delete_statement = null, - $count_statement = null + $delete_statement = null, + $count_statement = null ) { $this->_dest_db = $dest_db; From 12de03acdee1a6902fa53b733e4ee6b53b4cae80 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:05:01 -0500 Subject: [PATCH 24/83] Monolog PHP 8.2 Updates These changes are the minimum required to get our logging to work w/ PHP 8.2 and the latest version of Monolog. They consist of the following: `classes/CCR/CCRDBFormatter.php`: - Updating the type of the `$record` format to `Monolog\LogRecord`. This is to match the function signature of `Monolog\Formatter\NormalizerFormatter` `classes/CCR/CCRLineFormatter.php`: - Updating the type of the `$record` format to `Monolog\LogRecord`. This is to match the function signature of `Monolog\Formatter\LineFormatter`. `classes/CCR/CCRDBHandler.php`: - This code is no longer needed as we do all the formatting for db log records in `CCRDBFormatter`. `classes/CCR/Log.php`: - Since we handle the conversion of the log level in the constructor of `CCRDBHandler`, this code can be removed. `tests/component/lib/ETL/IngestorTest.php`: - The default format for log level in Monolog messages is to be upper case. We previously expected them to be lower case which is likely a hold over from old PSR logging. We've decided to use standards where ever it makes sense / can, hence this change. `tests/component/lib/LoggerTest.php`: - Same reason / changes as for `IngestorTest`. --- classes/CCR/CCRDBFormatter.php | 3 ++- classes/CCR/CCRDBHandler.php | 9 +-------- classes/CCR/CCRLineFormatter.php | 3 ++- classes/CCR/Log.php | 2 +- tests/component/lib/ETL/IngestorTest.php | 10 +++++----- tests/component/lib/LoggerTest.php | 10 +++++----- 6 files changed, 16 insertions(+), 21 deletions(-) diff --git a/classes/CCR/CCRDBFormatter.php b/classes/CCR/CCRDBFormatter.php index c34ff313d6..c2e3f1ebab 100644 --- a/classes/CCR/CCRDBFormatter.php +++ b/classes/CCR/CCRDBFormatter.php @@ -3,6 +3,7 @@ namespace CCR; use Monolog\Formatter\NormalizerFormatter; +use Monolog\LogRecord; class CCRDBFormatter extends NormalizerFormatter { @@ -12,7 +13,7 @@ class CCRDBFormatter extends NormalizerFormatter * all of the properties from the context. If the message is an empty * string the message property is not added. */ - public function format(array $record) + public function format(LogRecord $record) { $vars = parent::format($record); diff --git a/classes/CCR/CCRDBHandler.php b/classes/CCR/CCRDBHandler.php index 88c2c366cf..a67bf5ff51 100644 --- a/classes/CCR/CCRDBHandler.php +++ b/classes/CCR/CCRDBHandler.php @@ -75,19 +75,12 @@ public function __construct(iDatabase $db = null, $schema = null, $table = null, */ protected function write(LogRecord $record): void { - $message = array_merge( - [ - 'message' => $record->message - ], - $record->context - ); - $sql = sprintf("INSERT INTO %s.%s (id, logtime, ident, priority, message) VALUES(:id, NOW(), :ident, :priority, :message)", $this->schema, $this->table); $params = [ ':id' => $this->getNextId(), ':ident' => $record['channel'], ':priority' => Log::convertToCCRLevel($record['level']), - ':message' => json_encode($message, JSON_NUMERIC_CHECK | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ':message' => $record['formatted'] ]; $this->db->execute($sql, $params); } diff --git a/classes/CCR/CCRLineFormatter.php b/classes/CCR/CCRLineFormatter.php index ff9dcc162e..a795d17fbf 100644 --- a/classes/CCR/CCRLineFormatter.php +++ b/classes/CCR/CCRLineFormatter.php @@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter; use Monolog\Formatter\NormalizerFormatter; +use Monolog\LogRecord; use Monolog\Utils; class CCRLineFormatter extends LineFormatter @@ -45,7 +46,7 @@ protected function toJson($data, $ignoreErrors = false): string * string and context object. If either the context is empty or the message * is an empty string they are ommitted. */ - public function format(array $record) + public function format(LogRecord $record): string { $vars = NormalizerFormatter::format($record); diff --git a/classes/CCR/Log.php b/classes/CCR/Log.php index d30a9d8065..bd2d7f8ef6 100644 --- a/classes/CCR/Log.php +++ b/classes/CCR/Log.php @@ -264,7 +264,7 @@ protected static function getDbHandler($ident, array $conf) { $dbLogLevel = $conf['dbLogLevel'] ?? self::getDefaultLogLevel('db'); - $handler = new CCRDBHandler(null, null, null, self::convertToMonologLevel($dbLogLevel)); + $handler = new CCRDBHandler(null, null, null, $dbLogLevel); $handler->setFormatter(new CCRDBFormatter()); return $handler; diff --git a/tests/component/lib/ETL/IngestorTest.php b/tests/component/lib/ETL/IngestorTest.php index 8d724beab3..6f2a65dae2 100644 --- a/tests/component/lib/ETL/IngestorTest.php +++ b/tests/component/lib/ETL/IngestorTest.php @@ -80,7 +80,7 @@ public function testSqlWarnings() { $numWarnings = 0; if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -103,7 +103,7 @@ public function testHideSqlWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - $this->assertDoesNotMatchRegularExpression('/\[warning\]/', $line); + $this->assertDoesNotMatchRegularExpression('/\[WARNING\]/', $line); } } @@ -129,7 +129,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -145,7 +145,7 @@ public function testHideSqlWarningCodes() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[warning]') ) { + if ( false !== strpos($line, '[WARNING]') ) { $numWarnings++; } } @@ -194,7 +194,7 @@ public function testStructuredFileIngestorWithSameFile() { $recordsLoaded = array(); foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if ( false !== strpos($line, '[notice]') ) { + if ( false !== strpos($line, '[NOTICE]') ) { $matches = array(); if ( preg_match('/xdmod.structured-file.read-people-([0-9])/', $line, $matches) ) { $number = $matches[1]; diff --git a/tests/component/lib/LoggerTest.php b/tests/component/lib/LoggerTest.php index 5e960832fb..2020e6ae47 100644 --- a/tests/component/lib/LoggerTest.php +++ b/tests/component/lib/LoggerTest.php @@ -10,10 +10,10 @@ class LoggerTest extends BaseTest public function provideFileOutput() { return array( - array('debug', 'message field', array('other' => 1.2), '/\[debug\] message field \(other: 1.2\)$/'), - array('info', 'single line string', array(), '/\[info\] single line string$/'), - array('warning', '', array('other' => 'comp123'), '/\[warning\] \(other: comp123\)$/'), - array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[error\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') + array('debug', 'message field', array('other' => 1.2), '/\[DEBUG\] message field \(other: 1.2\)$/'), + array('info', 'single line string', array(), '/\[INFO\] single line string$/'), + array('warning', '', array('other' => 'comp123'), '/\[WARNING\] \(other: comp123\)$/'), + array('error', '', array('exceptiontest' => new \Exception('Test Line Exception')), '/\[ERROR\] \(exceptiontest: .*' . str_replace('/', '\\/', __FILE__) . ':' . __LINE__ . '\)\W\[stacktrace\]/') ); } @@ -131,7 +131,7 @@ public function testCombinedOutput() $logger->debug('message portion', array('context' => 'portion')); $output = file_get_contents($conf['file']); - $this->assertStringEndsWith("[debug] message portion (context: portion)\n", $output); + $this->assertStringEndsWith("[DEBUG] message portion (context: portion)\n", $output); $logoutput = $db->query("SELECT priority, message FROM mod_logger.log_table WHERE ident = 'combined-test' AND id > :start_id ORDER BY id ASC", $initial_vals[0]); From 813de9ff8bae85f24a499c7b0afbe04e18572518 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:32:13 -0500 Subject: [PATCH 25/83] This change is no longer necessary --- classes/DataWarehouse/Export/RealmManager.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/classes/DataWarehouse/Export/RealmManager.php b/classes/DataWarehouse/Export/RealmManager.php index 1e044f671d..29a9c0c220 100644 --- a/classes/DataWarehouse/Export/RealmManager.php +++ b/classes/DataWarehouse/Export/RealmManager.php @@ -50,12 +50,7 @@ function ($realm) use ($exportable) { // Use array_values to remove gaps in keys that may have been // introduced by the use of array_filter. - $values = array_values($realms); - - // We force sorting in descending order due to the differences in sorting from PHP7.2 to PHP8.0 - usort($values, fn($left, $right) => strcmp($left->getName(), $right->getName()) * -1); - - return $values; + return array_values($realms); } /** From 0ce99b0a93a24eebb2ac64faa59c654aa1dcf908 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:34:32 -0500 Subject: [PATCH 26/83] Changes per code review by @jpwhite4 --- src/Controller/MetricExplorerController.php | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/Controller/MetricExplorerController.php b/src/Controller/MetricExplorerController.php index 9deafcc83b..818a44699a 100644 --- a/src/Controller/MetricExplorerController.php +++ b/src/Controller/MetricExplorerController.php @@ -395,6 +395,7 @@ public function getData(Request $request): Response $m = new \DataWarehouse\Access\MetricExplorer($_REQUEST); try { $result = $m->get_data($user); + return new Response($result['results'], 200, $result['headers']); } catch (Exception $e) { return $this->json( [ @@ -404,26 +405,6 @@ public function getData(Request $request): Response 400 ); } - - - $format = $this->getStringParam($request, 'format'); - if ($format === 'png' - || $format === 'pdf' - || $format === 'svg' - || $format === 'png_inline' - || $format === 'svg_inline' - || $format === '_internal' - || $format === 'csv' - || $format === 'xml' - || $format === 'json') { - $response = new Response($result['results']); - } else { - $response = $this->json(json_decode($result['results'])); - } - - $response->headers->add($result['headers']); - - return $response; } From 740568327f15276555ec9d09292cb0ae8d0aa005 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:47:49 -0500 Subject: [PATCH 27/83] Migrating / Removing Error.js.php The functionality contained in this file has been moved to HomeController / index.html.twig. --- html/gui/js/Error.js.php | 20 -------------------- src/Controller/HomeController.php | 3 ++- templates/index.html.twig | 9 ++++++++- 3 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 html/gui/js/Error.js.php diff --git a/html/gui/js/Error.js.php b/html/gui/js/Error.js.php deleted file mode 100644 index 41c7b9a19c..0000000000 --- a/html/gui/js/Error.js.php +++ /dev/null @@ -1,20 +0,0 @@ - -/** - * Error.js.php - * - * Defines the error codes used by XDMoD. This file is generated from - * server-side error code definitions in the PHP XDError class. - */ - -Ext.namespace('XDMoD.Error'); - - $errorCode) { - echo "XDMoD.Error.$errorName = $errorCode;\n"; -} diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index f9e95b57d9..24766cf402 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -177,7 +177,8 @@ public function index(Request $request): Response 'sso_show_local_login' => $ssoSettings['show_local_login'], 'sso_direct_link' => $ssoSettings['direct_link'], 'is_jupyter_configured' => $jupyterIsEnabled, - 'jupyter_hub_url' => $jupyterHubURL + 'jupyter_hub_url' => $jupyterHubURL, + 'error_codes' => \XDError::getErrorCodes() ]; $logoData = $this->getLogoData(); diff --git a/templates/index.html.twig b/templates/index.html.twig index 783f66e910..642d58edb6 100644 --- a/templates/index.html.twig +++ b/templates/index.html.twig @@ -44,7 +44,14 @@ {% endif %} - + + + From 4b70102ab7dc8aa198bad4b44d6b8b447893c1c5 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:50:37 -0500 Subject: [PATCH 28/83] Leaving xsede things to the xsede module --- src/Controller/AboutController.php | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/Controller/AboutController.php b/src/Controller/AboutController.php index dae53ca848..41adc87e34 100644 --- a/src/Controller/AboutController.php +++ b/src/Controller/AboutController.php @@ -143,26 +143,12 @@ public function links(): Response } /** - * @param string $xdmodType * @return Response */ - #[Route('/about/release_notes/{xdmodType}', methods: ['GET'])] - public function releaseNotes(string $xdmodType): Response + #[Route('/about/release_notes/xdmod', methods: ['GET'])] + public function releaseNotes(): Response { - if (str_contains($xdmodType, '.')) { - $parts = explode('.', $xdmodType); - $xdmodType = $parts[0]; - } - if (!in_array($xdmodType, ['xdmod', 'xsede'])) { - throw new BadRequestHttpException('Invalid XDMoD installation type specified.'); - } - - $xsedeInstall = $this->getConfigValue('features', 'xsede', false); - if (!$xsedeInstall && $xdmodType === 'xsede') { - throw new BadRequestHttpException('Invalid XDMoD installation type xsede specified.'); - } - - return $this->render("about/{$xdmodType}_release_notes.html.twig"); + return $this->render("about/xdmod_release_notes.html.twig"); } /** From 9a09785581c0296291990a9dcb270193d6e3557f Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:52:14 -0500 Subject: [PATCH 29/83] file is no longer needed --- libraries/rest.php | 52 ---------------------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 libraries/rest.php diff --git a/libraries/rest.php b/libraries/rest.php deleted file mode 100644 index 0754b1aaa3..0000000000 --- a/libraries/rest.php +++ /dev/null @@ -1,52 +0,0 @@ - Date: Fri, 7 Nov 2025 14:53:47 -0500 Subject: [PATCH 30/83] Removing unused classes --- classes/XDController.php | 82 ---------------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 classes/XDController.php diff --git a/classes/XDController.php b/classes/XDController.php deleted file mode 100644 index 8edc4f177d..0000000000 --- a/classes/XDController.php +++ /dev/null @@ -1,82 +0,0 @@ -_requirements = $requirements; - $this->_registered_operations = array(); - - $this->_operation_handler_directory = $basePath.'/'.substr(basename($_SERVER["SCRIPT_NAME"]), 0, -4); - - }//construct - - // --------------------------- - - public function registerOperation($operation) { - - $this->_registered_operations[] = $operation; - - }//registerOperation - - // --------------------------- - - public function invoke($method, $session_variable = 'xdUser') { - - - xd_security\enforceUserRequirements($this->_requirements, $session_variable); - - // -------------------- - - $params = array('operation' => RESTRICTION_OPERATION); - - $isValid = xd_security\secureCheck($params, $method); - - if (!$isValid) { - $returnData['status'] = 'operation_not_specified'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'operation_not_specified'; - $returnData['data'] = array(); - xd_controller\returnJSON($returnData); - }; - - // -------------------- - - if(!in_array($_REQUEST['operation'], $this->_registered_operations)){ - $returnData['status'] = 'invalid_operation_specified'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'invalid_operation_specified'; - $returnData['data'] = array(); - xd_controller\returnJSON($returnData); - } - - $operation_handler = $this->_operation_handler_directory.'/'.$_REQUEST['operation'].'.php'; - - if (file_exists($operation_handler)){ - include $operation_handler; - } - else{ - $returnData['status'] = 'operation_not_defined'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'operation_not_defined'; - $returnData['data'] = array(); - xd_controller\returnJSON($returnData); - } - - }//invoke - - }//XDController From a6cc40e1ee3e2a022061ecb70199dee5f0526adc Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 14:56:54 -0500 Subject: [PATCH 31/83] Should have been removed with the refactoring of Error.js.php --- libraries/response.php | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/libraries/response.php b/libraries/response.php index e7aaf47a74..a1badc19ba 100644 --- a/libraries/response.php +++ b/libraries/response.php @@ -67,24 +67,3 @@ function presentError($error) { xd_controller\returnJSON(buildError($error)); } - -/** - * Sets response headers appropriate for dynamically-generated JavaScript. - * - * @param boolean $allow_caching Allow the generated JavaScript to be cached. - * (Defaults to false.) - */ -function useDynamicJavascriptHeaders($allow_caching = false) { - // Set the content type of the response to JavaScript. - header('Content-Type: application/javascript'); - - // If desired, prevent this response from being cached. - // See Example #2: http://php.net/manual/en/function.header.php - // See: http://stackoverflow.com/a/13640164 - if (!$allow_caching) { - header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - header("Cache-Control: post-check=0, pre-check=0", false); - header("Expires: Sat, 26 Jul 1997 05:00:00 GMT"); - header("Pragma: no-cache"); - } -} From 83e67cc4b8cad84e3b862dbf57a97e74f0f35848 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 7 Nov 2025 15:41:51 -0500 Subject: [PATCH 32/83] Updates to take file removals in to account So, these changes should have been included when removing the last couple of files. --- composer.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/composer.json b/composer.json index 9a99c0cbd2..7dfaa1954d 100644 --- a/composer.json +++ b/composer.json @@ -248,7 +248,6 @@ "configuration/constants.php", "libraries/response.php", "libraries/web_message.php", - "libraries/rest.php", "libraries/versioning.php", "libraries/date.php", "libraries/utilities.php", @@ -263,7 +262,6 @@ "classes/XDChartPool.php", "classes/XDStatistics.php", "classes/SessionExpiredException.php", - "classes/XDController.php", "classes/XDUser.php", "classes/UniqueException.php", "classes/XDError.php", @@ -291,7 +289,6 @@ "Realm\\": "classes/Realm/", "ReportTemplates\\": "classes/ReportTemplates/", "Reports\\": "classes/Reports/", - "Rest\\": "classes/Rest/", "User\\": "classes/User/", "Xdmod\\": "classes/Xdmod/", "Access\\": "src/" From 615515e1be14535560a88137bf9ae2df675490ae Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 09:59:31 -0500 Subject: [PATCH 33/83] Migrating / Removing code from security.php So this is kind of a half-step commit, ideally there should be one basic way (process) to get / authenticate a user. To get to this point will require some additional work. As a step in the right direction I've pulled out the `detectUser` function ( and the funcitons it depends on ) from `libraries/security.php` and moved them to `src/BaseController.php` so that authentication related code is in one place ( that place being the "new" Symfony code ) and the Symfony controllers that still used `detectUser` have been updated to use new `BaseController::detectUser` function. A future commit ( or maybe PR ) will contain the migration of this code to be more Symfony specific. --- libraries/security.php | 399 -------------------- src/Controller/BaseController.php | 113 +++++- src/Controller/MetricExplorerController.php | 13 +- src/Controller/ReportBuilderController.php | 6 +- 4 files changed, 121 insertions(+), 410 deletions(-) diff --git a/libraries/security.php b/libraries/security.php index 3f4e79cc04..a2b9a8eff1 100644 --- a/libraries/security.php +++ b/libraries/security.php @@ -74,402 +74,3 @@ function start_session() } } - -/** - * @param array $failover_methods - * - * @return XDUser - * @throws SessionExpiredException - */ -function detectUser($failover_methods = array()) -{ - - // - Attempt to get a logged in user - // - Should a logged in user not exist, inspect $failover_methods to - // determine the next kind of user to fetch - try { - $user = getLoggedInUser(); - } catch (Exception $e) { - if (count($failover_methods) == 0) { - // Previously: Exception with 'Session Expired', No Logged In User code - throw new \SessionExpiredException(); - } - $session = SessionSingleton::getSession(); - switch ($failover_methods[0]) { - case XDUser::PUBLIC_USER: - if ( - (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || - ($session->has('public_session_token')) - ) { - return XDUser::getPublicUser(); - } else { - // Previously: Exception with 'Session Expired', No Public User code - throw new \SessionExpiredException($e->getMessage()); - } - break; - case XDUser::INTERNAL_USER: - try { - return getInternalUser(); - } catch (Exception $e) { - if ( - isset($failover_methods[1]) - && $failover_methods[1] == XDUser::PUBLIC_USER - ) { - if ( - (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || - ($session->has('public_session_token')) - ) { - return XDUser::getPublicUser(); - } else { - // Previously: Exception with 'Session Expired', No Public User code - throw new \SessionExpiredException(); - } - } else { - // Previously: Exception with 'Session Expired', No Internal User code - throw new \SessionExpiredException(); - } - } - break; - default: - // Previously: Exception with 'Session Expired', No Logged In User code - throw new \SessionExpiredException(); - break; - } - } - - return $user; -} - -/** - * This is merely to check if a dashboard user has logged in (and not - * make use of the respective XDUser object) - * - * @return XDUser - * @throws SessionExpiredException - */ -function assertDashboardUserLoggedIn() -{ - try { - return getDashboardUser(); - } catch (SessionExpiredException $see) { - // TODO: Refactor generic catch block below to handle specific exceptions, - // which would allow this block to be removed. - throw $see; - } catch (Exception $e) { - \xd_controller\returnJSON(array( - 'success' => false, - 'status' => $e->getMessage(), - )); - exit; - } -} - -/** - * @return XDUser An instance of XDUser pertaining to the dashboard - * user. - * - * @throws Exception If: - * - The session variable pertaining to the dashboard user does not - * exist. - * - The user_id stored in the session variable does not map to a - * valid XDUser. - * - The user does not have manager privileges. - */ -function getDashboardUser() -{ - - $session = SessionSingleton::getSession(); - $dashboardUserId = $session->get('xdDashboardUser'); - if (!isset($dashboardUserId)) { - throw new \SessionExpiredException('Dashboard session expired'); - } - - $user = XDUser::getUserByID($dashboardUserId); - - if ($user == NULL) { - throw new Exception('User does not exist'); - } - - if (!$user->isManager()) { - throw new Exception('Permissions do not allow you to access the dashboard'); - } - - return $user; -} - -/** - * @return XDUser - * - * @throws Exception - */ -function getLoggedInUser() -{ - $session = SessionSingleton::getSession(); - // This is where the - $sessionUserId = $session->get('xdUser'); - if (empty($sessionUserId)) { - throw new Exception('Session Expired', 2); - } - $user = XDUser::getUserByID($sessionUserId); - - if ($user == NULL) { - throw new Exception('User does not exist'); - } - - return $user; -} - -/** - * @return XDUser - * - * @throws Exception - */ -function getInternalUser() -{ - - if ( - isset($_SERVER['REMOTE_ADDR']) - && $_SERVER['REMOTE_ADDR'] == '127.0.0.1' - && isset($_REQUEST['user_id']) - ) { - $user = XDUser::getUserByID($_REQUEST['user_id']); - - if ($user == NULL) { - throw new Exception('Internal user does not exist'); - } - } else { - throw new Exception('Internal user not specified'); - } - - return $user; -} - -/** - * @param array $requirements - * @param string $session_variable - * @throws SessionExpiredException - */ -function enforceUserRequirements($requirements, $session_variable = 'xdUser') -{ - $returnData = array(); - - $session = SessionSingleton::getSession(); - if (in_array(STATUS_LOGGED_IN, $requirements)) { - $sessionUserId = $session->get($session_variable); - if (!isset($sessionUserId)) { - throw new \SessionExpiredException(); - } - - $user = XDUser::getUserByID($sessionUserId); - - if ($user == NULL) { - $returnData['status'] = 'user_does_not_exist'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'user_does_not_exist'; - $returnData['data'] = array(); - \xd_controller\returnJSON($returnData); - } - - // Manager subsumes 'Science Advisory Board Member' role - if ($user->isManager()) { - \xd_utilities\remove_element_by_value($requirements, SAB_MEMBER); - } - - if (in_array(SAB_MEMBER, $requirements)) { - - // This user must be a member of the Science Advisory Board - if (!$user->hasAcl('sab')) { - $returnData['status'] = 'not_sab_member'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'not_sab_member'; - $returnData['data'] = array(); - \xd_controller\returnJSON($returnData); - } - } - - if (in_array(STATUS_MANAGER_ROLE, $requirements)) { - if (!($user->isManager())) { - $returnData['status'] = 'not_a_manager'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'not_a_manager'; - $returnData['data'] = array(); - \xd_controller\returnJSON($returnData); - } - } - - if (in_array(STATUS_CENTER_DIRECTOR_ROLE, $requirements)) { - if (!$user->hasAcl(ROLE_ID_CENTER_DIRECTOR)) { - $returnData['status'] = 'not_a_center_director'; - $returnData['success'] = false; - $returnData['totalCount'] = 0; - $returnData['message'] = 'not_a_center_director'; - $returnData['data'] = array(); - \xd_controller\returnJSON($returnData); - } - } - } -} - -/** - * Ensures that all of the $_REQUEST[keys] in $required_params conform - * to their respective patterns (e.g. $required_params - * = array('uid' => RESTRICTION_UID) : $_REQUEST['uid'] has to comply - * with the pattern in RESTRICTION_UID - * - * If $enforce_all is set to 'false', then secureCheck will return an - * integer indicating how many of the params qualify (this is used for - * cases in which at least one parameter is required, but not all) - * - * @param array $required_params - * @param string $m - * @param bool $enforce_all - */ -function secureCheck(&$required_params, $m, $enforce_all = true) -{ - - // ${'_'.$m}['param'] <-- should be working, but doesn't inside this - // function - - $qualifyingParams = 0; - - if ($m == 'GET') { - $param_array = $_GET; - } - if ($m == 'POST') { - $param_array = $_POST; - } - if ($m == 'REQUEST') { - $param_array = $_REQUEST; - } - - foreach ($required_params as $param => $pattern) { - if (!isset($param_array[$param])) { - if ($enforce_all) { - return false; - } - if (!$enforce_all) { - continue; - } - } - - $param_array[$param] - = preg_replace('/\s+/', ' ', $param_array[$param]); - - if (preg_match($pattern, $param_array[$param]) == 0) { - if ($enforce_all) { - return false; - } - if (!$enforce_all) { - continue; - } - } - - $qualifyingParams++; - } - - if ($enforce_all) { - return true; - } - if (!$enforce_all) { - return $qualifyingParams; - } -} - -/** - * @param array $requiredParams - */ -function assertParametersSet($requiredParams = array()) -{ - foreach ($requiredParams as $k => $v) { - if (!is_int($k)) { - - // $k represents the name of the param - // $v represents the format of the value that param must conform - // to (a regex) - $param_name = $k; - $pattern = $v; - } else { - - // $v represents the name of the param - $param_name = $v; - $pattern = '/.*/'; - } - - assertParameterSet($param_name, $pattern); - } -} - -/** - * Provides a checkstop when a required argument has not been supplied - * in a web request (using GET or POST). - * - * @param string $param_name Parameter name. - * @param string $pattern Pattern parameter must match. - * @param bool $compress_whitespace True if any whitespace in the - * parameter value should be replaced with a single space - * (default: true). - * - * @return string The parameter value. - */ -function assertParameterSet( - $param_name, - $pattern = '/.*/', - $compress_whitespace = true -) -{ - if (!isset($_REQUEST[$param_name])) { - \xd_response\presentError("'$param_name' not specified."); - } - - $param_value = $_REQUEST[$param_name]; - - if ($compress_whitespace) { - $param_value = preg_replace('/\s+/', ' ', $param_value); - } - - $match = preg_match($pattern, $param_value); - - if ($match === false) { - \xd_response\presentError("Failed to assert '$param_name'."); - } elseif ($match == 0) { - \xd_response\presentError("Invalid value specified for '$param_name'."); - } - - return $param_value; -} - -/** - * Assert that a request parameter is set and is also a valid email address. - * - * @param string $param_name Parameter name. - * @return string The parameter value. - */ -function assertEmailParameterSet($param_name) -{ - if (!isset($_REQUEST[$param_name])) { - \xd_response\presentError("'$param_name' not specified."); - } - - $param_value = $_REQUEST[$param_name]; - - if (!isEmailValid($param_value)) { - \xd_response\presentError("Failed to assert '$param_name'."); - } - - return $param_value; -} - -/** - * Determine if an email address is valid. - * - * @param string $email Email address to validate. - * @return bool True if the email address is valid. - */ -function isEmailValid($email) -{ - $validator = new \Egulias\EmailValidator\EmailValidator(); - return $validator->isValid($email, new RFCValidation()); -} diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index 3135846443..c6ac878a51 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Twig\Environment; +use xd_security\SessionSingleton; use XDUser; /** @@ -151,9 +152,119 @@ protected function getXDUser(Session $session): XDUser return $user; } - protected function getDashboardUser(Session $session) + /** + * @param Request $request + * @param string[] $failover_methods + * @return XDUser + * @throws \SessionExpiredException + */ + protected function detectUser(Request $request, array $failover_methods = []): XDUser { + $session = $request->getSession(); + try { + $user = $this->getLoggedInUser($session); + } catch (Exception $e) { + if (count($failover_methods) == 0) { + // Previously: Exception with 'Session Expired', No Logged In User code + throw new \SessionExpiredException(); + } + + switch ($failover_methods[0]) { + case XDUser::PUBLIC_USER: + if ( + (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || + ($session->has('public_session_token')) + ) { + return XDUser::getPublicUser(); + } else { + // Previously: Exception with 'Session Expired', No Public User code + throw new \SessionExpiredException($e->getMessage()); + } + break; + case XDUser::INTERNAL_USER: + try { + return $this->getInternalUser($request); + } catch (Exception $e) { + if ( + isset($failover_methods[1]) + && $failover_methods[1] == XDUser::PUBLIC_USER + ) { + if ( + (isset($_REQUEST['public_user']) && $_REQUEST['public_user'] === 'true') || + ($session->has('public_session_token')) + ) { + return XDUser::getPublicUser(); + } else { + // Previously: Exception with 'Session Expired', No Public User code + throw new \SessionExpiredException(); + } + } else { + // Previously: Exception with 'Session Expired', No Internal User code + throw new \SessionExpiredException(); + } + } + default: + // Previously: Exception with 'Session Expired', No Logged In User code + throw new \SessionExpiredException(); + } + } + return $user; + } + + /** + * Ported from libraries/security.php::getLoggedInUser, modified to use Symfony Session as opposed to the + * SessionSingleton. + * + * @param Session $session + * + * @return XDUser + * + * @throws Exception if no 'xdUser' session parameter exists. + * @throws Exception if unable to find a record in moddb.Users for the id present in the 'xdUser' session parameter. + */ + protected function getLoggedInUser(Session $session): XDUser + { + // This is where the + $sessionUserId = $session->get('xdUser'); + if (empty($sessionUserId)) { + throw new Exception('Session Expired', 2); + } + $user = XDUser::getUserByID($sessionUserId); + + if ($user == NULL) { + throw new Exception('User does not exist'); + } + + return $user; + } + + + /** + * @param Request $request + * @return XDUser + * @throws Exception if there is no record in moddb.Users for the value of the user_id request param. + * @throws Exception if there is no user_id request param. + */ + protected function getInternalUser(Request $request): XDUser + { + $userId = $request->get('user_id'); + + if ( + $request->server->has('REMOTE_ADDR') + && $request->server->get('REMOTE_ADDR') == '127.0.0.1' + && isset($userId) + ) { + $user = XDUser::getUserByID($userId); + + if ($user == NULL) { + throw new Exception('Internal user does not exist'); + } + } else { + throw new Exception('Internal user not specified'); + } + + return $user; } diff --git a/src/Controller/MetricExplorerController.php b/src/Controller/MetricExplorerController.php index 818a44699a..85ae0d738f 100644 --- a/src/Controller/MetricExplorerController.php +++ b/src/Controller/MetricExplorerController.php @@ -384,13 +384,12 @@ private function removeRoleFromQuery(XDUser $user, array &$query) * * @param Request $request * @return Response - * @throws SessionExpiredException if unable to successfully retrieve the currently logged in user. * @throws Exception if there is a problem with the processing of the get_data function. */ #[Route('{prefix}/metrics/explorer/data', requirements: ['prefix' => '.*'], methods: ['POST', 'GET'])] public function getData(Request $request): Response { - $user = \xd_security\detectUser([XDUser::INTERNAL_USER, XDUser::PUBLIC_USER]); + $user = $this->detectUser($request, [XDUser::INTERNAL_USER, XDUser::PUBLIC_USER]); $m = new \DataWarehouse\Access\MetricExplorer($_REQUEST); try { @@ -424,7 +423,7 @@ public function getDimensionValues(Request $request): Response // If token authentication failed then fallback to the standard session based authentication method. if ($user === null) { - $user = \xd_security\detectUser(array(\XDUser::PUBLIC_USER)); + $user = $this->detectUser($request, array(\XDUser::PUBLIC_USER)); } } catch (Exception $e) { return $this->json( @@ -477,7 +476,7 @@ public function getDwDescriptors(Request $request): Response // If token authentication failed then fallback to the standard session based authentication method. if ($user === null) { - $user = \xd_security\getLoggedInUser(); + $user = $this->getLoggedInUser($request->getSession()); } } catch (Exception $e) { return $this->json( @@ -647,7 +646,7 @@ public function getFilters(Request $request): Response $returnData = []; try { - $user = \xd_security\getLoggedInUser(); + $user = $this->getLoggedInUser($request->getSession()); $userProfile = $user->getProfile(); $filters = $userProfile->fetchValue('filters'); @@ -687,12 +686,12 @@ public function getFilters(Request $request): Response /** * @param Request $request * @return Response - * @throws SessionExpiredException|AccessDeniedException + * @throws Exception if there is a problem retrieving a user for the request. */ #[Route('{prefix}/metrics/explorer/raw_data', requirements: ['prefix' => '.*'], methods: ['POST'])] public function getRawData(Request $request): Response { - $user = \xd_security\detectUser(array(XDUser::INTERNAL_USER, XDUser::PUBLIC_USER)); + $user = $this->detectUser($request, array(XDUser::INTERNAL_USER, XDUser::PUBLIC_USER)); try { $config = []; diff --git a/src/Controller/ReportBuilderController.php b/src/Controller/ReportBuilderController.php index 51a2894144..813f1793a1 100644 --- a/src/Controller/ReportBuilderController.php +++ b/src/Controller/ReportBuilderController.php @@ -80,7 +80,7 @@ public function index(Request $request): Response public function getReports(Request $request): Response { try { - $user = \xd_security\detectUser([XDUser::PUBLIC_USER]); + $user = $this->detectUser($request, [XDUser::PUBLIC_USER]); } catch(Exception $e) { return $this->json(buildError($e), 401); } @@ -104,7 +104,7 @@ public function getReports(Request $request): Response public function getAvailableCharts(Request $request): Response { try { - $user = \xd_security\detectUser([XDUser::PUBLIC_USER]); + $user = $this->detectUser($request, [XDUser::PUBLIC_USER]); } catch(Exception $e) { return $this->json(buildError($e), 401); } @@ -474,7 +474,7 @@ public function removeChartFromPool(Request $request): Response public function getTemplates(Request $request): Response { try { - $user = \xd_security\getLoggedInUser(); + $user = $this->getLoggedInUser($request->getSession()); } catch (Exception $e) { return $this->json(buildError($e), 401); } From a0e133aa87c209da4f2e8935129db01772f38d19 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 14:11:07 -0500 Subject: [PATCH 34/83] Updates to address user logged in related problems These changes should address the following asana tasks: - https://app.asana.com/1/14740318781074/project/1211850781356424/task/1211881442164997?focus=true - https://app.asana.com/1/14740318781074/project/1211850781356424/task/1211881442164990?focus=true The problems that were encountered were that certain css files were not present when viewing the page before logging in. The ultimate cause of the problem was that the logic used to determine whether or not a user was logged in had changed. Previously it was based on the presence of the `xdUser` session variable, in the new code it was based on if an XDUser was returned from the `getXDUser` function ( i.e. was a null returned ), which was always the case. The fix was to add a new template variable `user_logged_in` and set it based on if there was an `xdUser` session variable ( and if the user is not public, since the public user can't be logged in ) and then use this new boolean variable where we had previously tested for `user is not null`. After the above fix went in there was a problem w/ SSO Login. The observed behevaior was that SSO Login would be successful, but the sign in link would not change to the logged in users name. The fix for this was to make sure that the `xdUser` session variable is set in `BaseController.php::getXDUser`. --- src/Controller/BaseController.php | 19 +++++++------ src/Controller/HomeController.php | 6 ++--- templates/index.html.twig | 44 +++++++++++++++---------------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index c6ac878a51..b1587340f2 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -133,23 +133,26 @@ protected function getUserFromRequest(Request $request) */ protected function getXDUser(Session $session): XDUser { - $user = $this->getUser(); - if (!isset($user)) { + $symfonyUser = $this->getUser(); + if (!isset($symfonyUser)) { if ($session->has('xdUser')) { - $user = XDUser::getUserByID($session->get('xdUser')); + $xdUser = XDUser::getUserByID($session->get('xdUser')); } elseif ($session->has('xdmod_token')) { - $user = XDUser::getUserByToken($session->get('xdmod_token')); + $xdUser = XDUser::getUserByToken($session->get('xdmod_token')); } else { if (!$session->has('public_session_token')) { $session->set('public_session_token', 'public-' . microtime(true) . '-' . uniqid()); } - $user = XDUser::getPublicUser(); + $xdUser = XDUser::getPublicUser(); } - } else { - $user = XDUser::getUserByUserName($user->getUserIdentifier()); + $xdUser = XDUser::getUserByUserName($symfonyUser->getUserIdentifier()); } - return $user; + + if (!$xdUser->isPublicUser()) { + $session->set('xdUser', $xdUser->getUserID()); + } + return $xdUser; } /** diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 24766cf402..83c807477a 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -84,9 +84,9 @@ public function index(Request $request): Response $response = new RedirectResponse("$returnTo"); return $response; } - $user = $this->getXDUser($session); - $session->set('xdUser', $user->getUserID()); + $user = $this->getXDUser($session); + $userLoggedIn = $session->has('xdUser') && !$user->isPublicUser(); $realms = array_reduce(Realms::getRealms(), function ($carry, Realm $item) { $carry [] = $item->getName(); @@ -135,8 +135,8 @@ public function index(Request $request): Response $jupyterHubURL = ''; } - $params = [ + 'user_logged_in' => $userLoggedIn, 'user' => $user, 'person_name' => sprintf('%s, %s', $personInfo[0]['last_name'], $personInfo[0]['first_name']), 'title' => \xd_utilities\getConfiguration('general', 'title'), diff --git a/templates/index.html.twig b/templates/index.html.twig index 642d58edb6..4a224cdcc8 100644 --- a/templates/index.html.twig +++ b/templates/index.html.twig @@ -33,13 +33,13 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} - {% if user is not null %} + {% if user_logged_in %} {% endif %} @@ -73,15 +73,15 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} - {% if user is not null %} + {% if user_logged_in %} {% endif %} - {% if user is not null %} + {% if user_logged_in %} @@ -108,7 +108,7 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} @@ -121,7 +121,7 @@ - {% if user is null %} + {% if not user_logged_in %} {% endif %} @@ -144,7 +144,7 @@ CCR.xdmod.short_version = "{{ xdmod_portal_version_short }}"; CCR.xdmod.ui.username = '{{ user ? user.getUsername : '__public__' }}'; - {% if user is not null %} + {% if user_logged_in %} CCR.xdmod.ui.fullName = "{{ user.getFormalName }}"; CCR.xdmod.ui.usertype = "{{ user.getUserType }}"; CCR.xdmod.ui.userIsSSO = {{ user.isSSOUser ? 'true' : 'false' }}; @@ -187,7 +187,7 @@ {# Profile Editor #} - {% if user is not null %} + {% if user_logged_in %} @@ -203,7 +203,7 @@ - {% if user is not null %} + {% if user_logged_in %} @@ -229,7 +229,7 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} @@ -239,7 +239,7 @@ - {% if user is not null %} + {% if user_logged_in %} @@ -255,7 +255,7 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} @@ -272,7 +272,7 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} @@ -280,18 +280,18 @@ - {% if user is null %} + {% if not user_logged_in %} {% endif %} - {% if user is not null %} + {% if user_logged_in %} {% endif %} - {% if user is not null and user_dashboard %} + {% if user_logged_in and user_dashboard %} {% else %} @@ -299,13 +299,13 @@ - {% if user is not null %} + {% if user_logged_in %} {% endif %} - {% if user is not null %} + {% if user_logged_in %} @@ -345,7 +345,7 @@ {% endif %} - {% if user is not null and not is_public_user %} + {% if user_logged_in and not is_public_user %} {{ asset_paths | raw }} {% endif %} @@ -355,7 +355,7 @@ var xsedeProfilePrompt = function () { }; - {% if user is null %} + {% if not user_logged_in %} Ext.onReady(xdmodviewer.init, xdmodviewer); {% elseif not profile_editor_init_flag is empty %} xsedeProfilePrompt = function () { @@ -371,7 +371,7 @@ {% endif %} - {% if user is not null %} + {% if user_logged_in %} {% endif %} {% if captcha_site_key|length > 0 %} From 72b5e931f55318c84084b4c9c7a1f2edb42ab5f1 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 14:26:49 -0500 Subject: [PATCH 35/83] Fix for broken admin dashboard button --- html/gui/js/CCR.js | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/html/gui/js/CCR.js b/html/gui/js/CCR.js index e970329c10..40f5731cfb 100644 --- a/html/gui/js/CCR.js +++ b/html/gui/js/CCR.js @@ -1623,25 +1623,8 @@ CCR.xdmod.initDashboard = function () { // Opening the window before the AJAX request is necessary to prevent // it being treated as a popup. Solution from: http://stackoverflow.com/a/20822754 - var dashboardWindow = window.open("", "_blank"); + var dashboardWindow = window.open("/internal_dashboard", "_blank"); dashboardWindow.focus(); - - Ext.Ajax.request({ - url: 'controllers/dashboard_launch.php', - method: 'POST', - callback: function (options, success, response) { - if (success && CCR.checkJSONResponseSuccess(response)) { - dashboardWindow.location.href = 'internal_dashboard'; - } - else { - dashboardWindow.close(); - window.focus(); - CCR.xdmod.ui.presentFailureResponse(response, { - title: 'XDMoD Dashboard' - }); - } - } - }); }; //CCR.xdmod.initDashboard // ----------------------------------- From bbc8db7a452f19b7b37f2d7177818983e74279cc Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 14:38:02 -0500 Subject: [PATCH 36/83] Fix for error viewing logs in internal dashboard This is caused by `$request->get('logLevels');` returning an array as opposed into a string, so when we requested it as a string it freaked out. I've gotten around this by just using request->get('logLevels'); instead of any of the the getparam functions. --- src/Controller/InternalDashboard/LogController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/InternalDashboard/LogController.php b/src/Controller/InternalDashboard/LogController.php index a2543c3668..4dbbae7a7f 100644 --- a/src/Controller/InternalDashboard/LogController.php +++ b/src/Controller/InternalDashboard/LogController.php @@ -91,7 +91,7 @@ public function getMessages(Request $request): Response $params[] = $ident; } - $logLevels = $this->getStringParam($request, 'logLevels'); + $logLevels = $request->get( 'logLevels'); if (isset($logLevels) && is_array($logLevels)) { $clauses[] = sprintf( 'priority IN (%s)', From 9af854610d4dbe0599430ccc1e169bd266dc88eb Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 18:58:02 -0500 Subject: [PATCH 37/83] updating the user dashboard logic --- templates/index.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/index.html.twig b/templates/index.html.twig index 4a224cdcc8..228ffbbaf8 100644 --- a/templates/index.html.twig +++ b/templates/index.html.twig @@ -291,7 +291,7 @@ {% endif %} - {% if user_logged_in and user_dashboard %} + {% if user_logged_in and user_dashboard and user.getPersonID() != PERSON_ID_UNASSOCIATED %} {% else %} From a77fb6b5e743088ebcdcb15691f80a05f2d64aac Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 10 Nov 2025 20:43:18 -0500 Subject: [PATCH 38/83] ignoring warnings in php_errors.log --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0c0b9176df..c0ee185928 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -208,7 +208,7 @@ jobs: - run: name: Ensure that no PHP command-line errors were generated command: | - docker exec xdmod /bin/bash -c "if [ -s /var/log/php_errors.log ]; then cat /var/log/php_errors.log; false; fi" + docker exec xdmod /bin/bash -c "if [ -s /var/log/php_errors.log ]; then cat /var/log/php_errors.log | grep -v 'Warning'; false; fi" - store_artifacts: path: /tmp/screenshots - store_artifacts: From 25bf91152bdaf870bc90455730e5294b3929cc4f Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 11:04:23 -0500 Subject: [PATCH 39/83] These headers are no longer necessary Or at least, they do not cause issues when running our normal suite of automated tests or when logging in via SSO locally. --- html/index.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/html/index.php b/html/index.php index 2d50cd7282..1150617fb9 100644 --- a/html/index.php +++ b/html/index.php @@ -5,12 +5,6 @@ require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; -# These are only here temporarily, -# put together which headers / values are set based on config options. -header('Access-Control-Allow-Origin: *'); -header("Access-Control-Allow-Headers: *"); -header("Access-Control-Allow-Methods: *"); -header("Allow: *"); // Configurable constants --------------------------- $orgConfig = \Configuration\XdmodConfiguration::assocArrayFactory( From 2d72ba7cd58fcf4bc49f239de7b3d220e98d3399 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 11:05:23 -0500 Subject: [PATCH 40/83] Update to allow SSO to work locally for dev --- tests/ci/samlSetup.sh | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/ci/samlSetup.sh b/tests/ci/samlSetup.sh index 9c9b2d8af7..0080e82dc3 100755 --- a/tests/ci/samlSetup.sh +++ b/tests/ci/samlSetup.sh @@ -9,13 +9,15 @@ DEFAULT_VENDOR_DIR=$DEFAULT_INSTALL_DIR/vendor INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR} VENDOR_DIR=${VENDOR_DIR:-$DEFAULT_VENDOR_DIR} -HOSTNAME="localhost:8080" +HOSTNAME="" +PORT="" # valid values: local, keycloak DEFAULT_TYPE=local -while getopts h:t: flag +while getopts h:p:t: flag do case "${flag}" in h) HOSTNAME=${OPTARG};; + p) PORT=${PORT:-${OPTARG}};; t) TYPE=${DEFAULT_TYPE:-${OPTARG}};; *) echo "Invalid argument"; exit 1; esac @@ -285,11 +287,21 @@ EOF # Configure SimplesamlPHP, stops / starts httpd as appropriate configureSimplesamlPHP; - ACS_URL=https://$HOSTNAME/simplesaml/module.php/saml/sp/saml2-acs.php/xdmod-sp AUD_URL=https://$HOSTNAME/xdmod-sp + # The ACS url is the only one that needs the port specified. + if [ -n "$PORT" ]; then + HOSTNAME="${HOSTNAME}:${PORT}" + fi + + log "setup" "Spinning up for $HOSTNAME" + ACS_URL=https://$HOSTNAME/simplesaml/module.php/saml/sp/saml2-acs.php/xdmod-sp + + log "setup" "Starting local IDP" node app.js --acs "$ACS_URL" --aud "$AUD_URL" --httpsPrivateKey idp-private-key.pem --httpsCert idp-public-cert.pem --https true > /var/log/xdmod/samlidp.log 2>&1 & + EXIT_CODE=$? + exit $EXIT_CODE } keycloakSSO() { @@ -298,6 +310,9 @@ keycloakSSO() { if [[ "$TYPE" == 'local' ]]; then + log "settings" "Type: $TYPE" + log "settings" "Host: $HOSTNAME" + log "settings" "Port: $PORT" localSSO elif [ "$TYPE" == "keycloak" ]; then keycloakSSO From 5dc1da633df5fbff8decc5683df91be957317664 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 11:05:56 -0500 Subject: [PATCH 41/83] Removing unneccessary code We no longer need to pre-auth a user before serving them the information, the authentication / authorization is a function of the backend route and if a user isn't authorized to view the information then the route is responsible for handling that. --- html/gui/js/dashboard/UserStats.js | 40 +++--------------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/html/gui/js/dashboard/UserStats.js b/html/gui/js/dashboard/UserStats.js index 58be28840f..8fb04e0c6f 100644 --- a/html/gui/js/dashboard/UserStats.js +++ b/html/gui/js/dashboard/UserStats.js @@ -472,42 +472,10 @@ XDMoD.UserStatsComponents.ClientActivity = Ext.extend(Ext.Panel, { * @param string metricSet An identifier for a set of metrics to load. */ var changePage = function (metricSet) { - // Cancel the in-progress request, if any. - if (activeChangePageRequest) { - Ext.Ajax.abort(activeChangePageRequest); - activeChangePageRequest = null; - } - - // Check that the user is authenticated. If so, then change the page. - activeChangePageRequest = Ext.Ajax.request({ - url: '../internal_dashboard/controllers/user_auth.php', - params: { - operation: 'session_check', - public_user: false, - session_user_id_type: "Dashboard" - }, - - callback: function (options, success, response) { - activeChangePageRequest = null; - - if (success) { - success = CCR.checkJSONResponseSuccess(response); - } - - if (!success) { - CCR.xdmod.ui.presentFailureResponse(response, { - title: self.title, - wrapperMessage: "Failed to authenticate user." - }); - return; - } - - var frameParams = Ext.urlEncode({ - disp: metricSet - }); - document.getElementById(frame_ref).src = 'analytics/index.php?' + frameParams; - } - }); + var frameParams = Ext.urlEncode({ + disp: metricSet + }); + document.getElementById(frame_ref).src = 'analytics/index.php?' + frameParams; }; // --------------------------------- From ce93bf01ffb55a46040634bb308dea7b5e8c9ac6 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 11:56:14 -0500 Subject: [PATCH 42/83] Migrating from new code to CCR\ namespace --- bin/console | 2 +- composer.json | 5 ++--- config/packages/framework.yaml | 2 +- config/packages/maker.yaml | 2 +- config/packages/security.yaml | 12 ++++++------ config/routes.yaml | 2 +- config/routes/routes.yaml | 2 +- config/services.yaml | 17 +++++++++++------ html/index.php | 2 +- src/Controller/AboutController.php | 2 +- src/Controller/AccountController.php | 2 +- src/Controller/AdminController.php | 2 +- src/Controller/AuthenticationController.php | 4 ++-- src/Controller/BaseController.php | 4 ++-- src/Controller/ChartPoolController.php | 2 +- src/Controller/DashboardController.php | 2 +- src/Controller/HomeController.php | 4 ++-- .../InternalDashboardController.php | 4 ++-- .../InternalDashboard/LogController.php | 4 ++-- .../InternalDashboard/MailerController.php | 4 ++-- .../InternalDashboard/SABUserController.php | 4 ++-- .../InternalDashboard/SummaryController.php | 4 ++-- .../InternalDashboard/UserAdminController.php | 4 ++-- .../InternalDashboard/UserVisitController.php | 4 ++-- src/Controller/MailController.php | 2 +- src/Controller/MetricExplorerController.php | 2 +- src/Controller/OrganizationController.php | 2 +- src/Controller/PasswordResetController.php | 2 +- src/Controller/PersonController.php | 2 +- src/Controller/ReportBuilderController.php | 2 +- src/Controller/ResourceController.php | 4 ++-- src/Controller/UserController.php | 2 +- src/Controller/UserInterfaceController.php | 2 +- src/Controller/WarehouseController.php | 2 +- src/Controller/WarehouseExportController.php | 4 ++-- src/Entity/User.php | 2 +- src/Errors/ErrorController.php | 2 +- src/EventListeners/LogoutListener.php | 2 +- src/Kernel.php | 2 +- src/Security/AccessDeniedHandler.php | 2 +- .../Authenticators/FormLoginAuthenticator.php | 2 +- .../SimpleSamlPhpAuthenticator.php | 4 ++-- src/Security/Helpers/Tokens.php | 2 +- src/Security/TokenUserProvider.php | 4 ++-- src/Security/UsernameUserProvider.php | 4 ++-- tests/integration/lib/TokenAuthTest.php | 2 +- 46 files changed, 77 insertions(+), 73 deletions(-) diff --git a/bin/console b/bin/console index 30269605c7..41c154d710 100755 --- a/bin/console +++ b/bin/console @@ -1,7 +1,7 @@ #!/usr/bin/env php Date: Wed, 12 Nov 2025 12:57:39 -0500 Subject: [PATCH 43/83] removing unneeded script property --- templates/index.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/index.html.twig b/templates/index.html.twig index 228ffbbaf8..cca763adee 100644 --- a/templates/index.html.twig +++ b/templates/index.html.twig @@ -375,7 +375,7 @@ {% endif %} {% if captcha_site_key|length > 0 %} - + {% endif %} From a185a1fb3c65ec96114544251dd61d60232e4592 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 15:00:34 -0500 Subject: [PATCH 44/83] Zero out the php_errors log before main run There are some warnings / deprecations that end up here during the upgrade of PHP and related librarires that was causing the CI build to fail. Since these messages are not actually related to the running of XDMoD I've chosen to truncate the file. --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c0ee185928..756700f19d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,6 +45,7 @@ jobs: docker exec xdmod pecl install mongodb-1.18.1 docker exec xdmod pecl install zip docker exec xdmod dnf remove -y php-devel openssl-devel + docker exec xdmod bash -c ">/var/log/php_errors.log" - run: name: Copy Files for Playwright and XDMoD containers command: | From 6d64d12a4922dbf9befcd4da3eb7e73b447679ad Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Wed, 12 Nov 2025 15:02:44 -0500 Subject: [PATCH 45/83] mark /config files as config no replace --- open_xdmod/modules/xdmod/xdmod.spec.in | 1 + 1 file changed, 1 insertion(+) diff --git a/open_xdmod/modules/xdmod/xdmod.spec.in b/open_xdmod/modules/xdmod/xdmod.spec.in index 97f2a5a3c7..4778a1fea2 100644 --- a/open_xdmod/modules/xdmod/xdmod.spec.in +++ b/open_xdmod/modules/xdmod/xdmod.spec.in @@ -102,6 +102,7 @@ rm -rf $RPM_BUILD_ROOT %config(noreplace) %{_sysconfdir}/%{name}/etl/ %config(noreplace) %{_sysconfdir}/logrotate.d/%{name} %config(noreplace) %{_sysconfdir}/cron.d/%{name} +%config(noreplace) %{_datadir}/%{name}/config/ %dir %attr(0570,apache,xdmod) %{xdmod_export_dir} From 38e3657265950e3441ec58ce6e2a12b75b211b7c Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Thu, 13 Nov 2025 13:53:29 -0500 Subject: [PATCH 46/83] Updating sso logic to match what we had before --- src/Controller/HomeController.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index ccdb2f18b2..0bfe55def8 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -96,17 +96,12 @@ public function index(Request $request): Response $features = $this->getFeatures(); $isSSOConfigured = false; - $ssoLoginLink = [ - 'organization' => [ - 'en' => 'Test Organization', - 'icon' => '' - ] - ]; + $ssoLoginLink = []; $ssoSettings = $this->getParameter('sso'); try { $auth = new XDSamlAuthentication(); + $isSSOConfigured = $auth->isSamlConfigured(); $ssoLoginLink = $auth->getLoginLink(); - $isSSOConfigured = true; } catch (\Exception $e) { $this->logger->error($e->getMessage(), [$e]); } From 1de83f5a072cab5cd2ee6eb3cd3cb1dc13738ce9 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 14 Nov 2025 14:45:52 -0500 Subject: [PATCH 47/83] Removing APP_SECRET population APP_SECRET population will be moved so that it's handled as part of the General Setup ( option 1 in xdmod-setup ). --- open_xdmod/modules/xdmod/xdmod.spec.in | 4 ---- 1 file changed, 4 deletions(-) diff --git a/open_xdmod/modules/xdmod/xdmod.spec.in b/open_xdmod/modules/xdmod/xdmod.spec.in index 4778a1fea2..42712cb1e2 100644 --- a/open_xdmod/modules/xdmod/xdmod.spec.in +++ b/open_xdmod/modules/xdmod/xdmod.spec.in @@ -67,9 +67,6 @@ done # Ensure the var directory is owned by apache so it can be written to. chown apache:xdmod %{_datadir}/%{name}/var -# Ensure we have a default APP_SECRET value in the .env file. -echo "APP_SECRET=$(date | sha512sum | cut -d' ' -f 1)" >> %{_datadir}/%{name}/.env - if [ "$1" -ge 2 ]; then echo "Run xdmod-upgrade to complete the Open XDMoD upgrade process." echo "Refer to http://open.xdmod.org/upgrade.html for more details." @@ -88,7 +85,6 @@ rm -rf $RPM_BUILD_ROOT %defattr(-,root,root,-) %{_libdir}/%{name}/ %{_datadir}/%{name}/ -%{_datadir}/%{name}/.env %{_docdir}/%{name}-%{version}__PRERELEASE__/ From 2c320c252beab9b23d79232648d4981c3c2c1c05 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 14 Nov 2025 14:48:51 -0500 Subject: [PATCH 48/83] Updating .env file generation These changes update the way that the `.env` file is generated. - `.env` is now excluded from being included when building an rpm or source install. - `.env` is now created using a new template `templates/env.template` at the end of the `1. General Setup` process in `xdmod-setup`. - We also use Symfony to pre-compile the `.env` file so that it's not read on every request. The xdmod-setup-start.tcl file has been updated to take this second file generation into account. --- classes/OpenXdmod/Setup/GeneralSetup.php | 18 ++++++++++++++++++ open_xdmod/modules/xdmod/build.json | 4 +--- templates/env.template | 9 +++++++++ tests/ci/scripts/xdmod-setup-start.tcl | 2 ++ 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 templates/env.template diff --git a/classes/OpenXdmod/Setup/GeneralSetup.php b/classes/OpenXdmod/Setup/GeneralSetup.php index 19c6403441..5eaa99f88b 100644 --- a/classes/OpenXdmod/Setup/GeneralSetup.php +++ b/classes/OpenXdmod/Setup/GeneralSetup.php @@ -5,6 +5,8 @@ namespace OpenXdmod\Setup; +use Xdmod\Template; + /** * General setup. */ @@ -124,5 +126,21 @@ public function handle() ); $this->saveIniConfig($settings, 'portal_settings'); + + $envTemplate = new Template('env'); + $envTemplate->apply([ + 'app_secret' => hash('sha512', time()) + ]); + $this->saveTemplate($envTemplate, BASE_DIR . '/.env'); + + $cmdBase = 'APP_ENV=prod APP_DEBUG=0'; + $console = BIN_DIR .'/console'; + + // Make sure to clear the cache before dumping the dotenv so we start clean. + $this->executeCommand("$cmdBase $console cache:clear"); + + // Dump dotenv data so we don't read .env each time in prod. + // Note: this means that if you want to start debugging stuff you'll need to delete the generated .env. + $this->executeCommand("$cmdBase $console dotenv:dump"); } } diff --git a/open_xdmod/modules/xdmod/build.json b/open_xdmod/modules/xdmod/build.json index e4b34712ad..dd3613ec1d 100644 --- a/open_xdmod/modules/xdmod/build.json +++ b/open_xdmod/modules/xdmod/build.json @@ -27,7 +27,7 @@ "/user_manual_builder" ], "exclude_patterns": [ - "#^\\/\\.(?!env).*#", + "#/\\.#", "#\\.eslintrc\\.json#", "#xdmod-.*\\.rpm$#", "#xdmod-.*\\.tar\\.gz$#", @@ -40,8 +40,6 @@ }, "file_maps": { "data": [ - {"bin/console": true }, - {".env": true}, "classes", "etl", "libraries", diff --git a/templates/env.template b/templates/env.template new file mode 100644 index 0000000000..1551b4f615 --- /dev/null +++ b/templates/env.template @@ -0,0 +1,9 @@ +# XDMoD related env variables # +XDMOD_LOG_DIR=/var/log/xdmod + +# Symfony related env variables # +DATABASE_URL= +GOOGLE_RECAPTCHA_SITE_KEY= +GOOGLE_RECAPTCHA_SECRET= +APP_SECRET=[:app_secret:] +APP_ENV=prod diff --git a/tests/ci/scripts/xdmod-setup-start.tcl b/tests/ci/scripts/xdmod-setup-start.tcl index a7a7dda4c9..8bcfdba4e4 100644 --- a/tests/ci/scripts/xdmod-setup-start.tcl +++ b/tests/ci/scripts/xdmod-setup-start.tcl @@ -22,6 +22,8 @@ provideInput {Center Logo Path:} {} provideInput {Enable Dashboard Tab*} {off} confirmFileWrite yes enterToContinue +confirmFileWrite yes +enterToContinue selectMenuOption 2 answerQuestion {DB Hostname or IP} localhost From d9babc7581de817b0516cea6ef6459a1c7eb7f2d Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 14 Nov 2025 14:56:34 -0500 Subject: [PATCH 49/83] Updating console to work w/ source installs This change allows for the `bin/console` file to be used while installed via RPM or Source *and* while doing development work within the XDMoD git repo. --- bin/console | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bin/console b/bin/console index 41c154d710..beee10dad2 100755 --- a/bin/console +++ b/bin/console @@ -4,11 +4,33 @@ use CCR\Kernel; use Symfony\Bundle\FrameworkBundle\Console\Application; -if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) { +/* Since we want to be able to use this file in at least the following cases: + * - in the working repository for dev purposes + * - while installed via rpm ( location: /usr/bin/console, in $PATH by default ) + * - while installed via source ( location: /usr/local/xdmod/bin, not in $PATH by default ) + * + * We need to be able to find the `vendor/autoload_runtime.php` in a couple different places. + * - `'__XDMOD_SHARE_PATH__/vendor/autoload_runtime.php'` is for when XDMoD is installed from source or by RPM + * - `dirname(__DIR__).'/vendor/autoload_runtime.php'` is for when doing dev work in the XDMoD git repo. + */ +$files = [ + '__XDMOD_SHARE_PATH__/vendor/autoload_runtime.php', + dirname(__DIR__).'/vendor/autoload_runtime.php' +]; + +$file = null; +foreach($files as $potentialFile) { + if (is_file($potentialFile)) { + $file = $potentialFile; + break; + } +} + +if ($file === null) { throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".'); } -require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; +require_once $file; return function (array $context) { $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); From 4bddaea9636337a56a582bd2b359ccfe5bf14c22 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Fri, 14 Nov 2025 17:03:13 -0500 Subject: [PATCH 50/83] Split out Symfony from XDMoD templates --- src/Controller/AboutController.php | 20 +++++++++---------- src/Controller/HomeController.php | 2 +- .../InternalDashboardController.php | 4 ++-- .../InternalDashboard/UserAdminController.php | 4 ++-- src/Controller/PasswordResetController.php | 4 ++-- .../{ => twig}/about/federated.html.twig | 0 templates/{ => twig}/about/links.html.twig | 0 .../{ => twig}/about/open_xdmod.html.twig | 0 .../{ => twig}/about/presentations.html.twig | 0 .../{ => twig}/about/publications.html.twig | 0 templates/{ => twig}/about/roadmap.html.twig | 0 templates/{ => twig}/about/supremm.html.twig | 0 templates/{ => twig}/about/team.html.twig | 0 templates/{ => twig}/about/xdmod.html.twig | 0 .../about/xdmod_release_notes.html.twig | 0 templates/{ => twig}/base.html.twig | 0 .../{ => twig}/emails/new_user.html.twig | 0 .../emails/password_reset.html.twig | 0 templates/{ => twig}/index.html.twig | 0 .../{ => twig}/internal_dashboard.html.twig | 0 .../internal_dashboard_login.html.twig | 0 templates/{ => twig}/password_reset.html.twig | 0 .../password_reset_expired.html.twig | 0 23 files changed, 17 insertions(+), 17 deletions(-) rename templates/{ => twig}/about/federated.html.twig (100%) rename templates/{ => twig}/about/links.html.twig (100%) rename templates/{ => twig}/about/open_xdmod.html.twig (100%) rename templates/{ => twig}/about/presentations.html.twig (100%) rename templates/{ => twig}/about/publications.html.twig (100%) rename templates/{ => twig}/about/roadmap.html.twig (100%) rename templates/{ => twig}/about/supremm.html.twig (100%) rename templates/{ => twig}/about/team.html.twig (100%) rename templates/{ => twig}/about/xdmod.html.twig (100%) rename templates/{ => twig}/about/xdmod_release_notes.html.twig (100%) rename templates/{ => twig}/base.html.twig (100%) rename templates/{ => twig}/emails/new_user.html.twig (100%) rename templates/{ => twig}/emails/password_reset.html.twig (100%) rename templates/{ => twig}/index.html.twig (100%) rename templates/{ => twig}/internal_dashboard.html.twig (100%) rename templates/{ => twig}/internal_dashboard_login.html.twig (100%) rename templates/{ => twig}/password_reset.html.twig (100%) rename templates/{ => twig}/password_reset_expired.html.twig (100%) diff --git a/src/Controller/AboutController.php b/src/Controller/AboutController.php index 5ae1a12584..f506e0f73d 100644 --- a/src/Controller/AboutController.php +++ b/src/Controller/AboutController.php @@ -22,7 +22,7 @@ class AboutController extends BaseController #[Route('/about/xdmod.html', methods: ["GET"])] public function xdmod(): Response { - return $this->render('about/xdmod.html.twig', [ + return $this->render('twig/about/xdmod.html.twig', [ 'xdmod_version' => \xd_versioning\getPortalVersion(true) ]); } @@ -34,7 +34,7 @@ public function xdmod(): Response #[Route('/about/openxd.html', methods: ["GET"])] public function openXdmod(): Response { - return $this->render('about/open_xdmod.html.twig'); + return $this->render('twig/about/open_xdmod.html.twig'); } /** @@ -44,7 +44,7 @@ public function openXdmod(): Response #[Route('/about/supremm.html', methods: ['GET'])] public function supremm(): Response { - return $this->render('about/supremm.html.twig'); + return $this->render('twig/about/supremm.html.twig'); } /** @@ -94,7 +94,7 @@ public function federated(): Response $parameters['instances'] = $instances; } - return $this->render('about/federated.html.twig', $parameters); + return $this->render('twig/about/federated.html.twig', $parameters); } /** @@ -106,7 +106,7 @@ public function roadmap(): Response { $header = $this->getConfigValue('roadmap', 'header'); $url = $this->getConfigValue('roadmap', 'url'); - return $this->render('about/roadmap.html.twig', [ + return $this->render('twig/about/roadmap.html.twig', [ 'header' => $header, 'url' => $url ]); @@ -119,7 +119,7 @@ public function roadmap(): Response #[Route('/about/team.html', methods: ['GET'])] public function team(): Response { - return $this->render('about/team.html.twig'); + return $this->render('twig/about/team.html.twig'); } /** @@ -129,7 +129,7 @@ public function team(): Response #[Route('/about/publications.html', methods: ['GET'])] public function publications(): Response { - return $this->render('about/publications.html.twig'); + return $this->render('twig/about/publications.html.twig'); } /** @@ -139,7 +139,7 @@ public function publications(): Response #[Route('/about/links.html', methods: ['GET'])] public function links(): Response { - return $this->render('about/links.html.twig'); + return $this->render('twig/about/links.html.twig'); } /** @@ -148,7 +148,7 @@ public function links(): Response #[Route('/about/release_notes/xdmod', methods: ['GET'])] public function releaseNotes(): Response { - return $this->render("about/xdmod_release_notes.html.twig"); + return $this->render("twig/about/xdmod_release_notes.html.twig"); } /** @@ -159,6 +159,6 @@ public function releaseNotes(): Response #[Route('/about/presentations.html', methods: ['GET'])] public function teamPresentations(Request $request): Response { - return $this->render('about/presentations.html.twig'); + return $this->render('twig/about/presentations.html.twig'); } } diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 0bfe55def8..f9e0ad45f6 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -184,7 +184,7 @@ public function index(Request $request): Response $params['img_data'] = $imgData; } - return $this->render('index.html.twig', $params); + return $this->render('twig/index.html.twig', $params); } diff --git a/src/Controller/InternalDashboard/InternalDashboardController.php b/src/Controller/InternalDashboard/InternalDashboardController.php index 1981169b88..7df6ceb2cc 100644 --- a/src/Controller/InternalDashboard/InternalDashboardController.php +++ b/src/Controller/InternalDashboard/InternalDashboardController.php @@ -63,12 +63,12 @@ public function index(Request $request): Response ]; if ($user->isPublicUser()) { - return $this->render('internal_dashboard_login.html.twig', $parameters); + return $this->render('twig/internal_dashboard_login.html.twig', $parameters); } else { if (!$user->hasAcl('mgr')) { return $this->redirect($this->generateUrl('xdmod_home')); } - return $this->render('internal_dashboard.html.twig', $parameters); + return $this->render('twig/internal_dashboard.html.twig', $parameters); } } diff --git a/src/Controller/InternalDashboard/UserAdminController.php b/src/Controller/InternalDashboard/UserAdminController.php index cfcf12fed7..11c5e30aaa 100644 --- a/src/Controller/InternalDashboard/UserAdminController.php +++ b/src/Controller/InternalDashboard/UserAdminController.php @@ -884,7 +884,7 @@ private function sendPasswordResetEmail(XDUser $user): void $subject = sprintf('%s: Password Reset', \xd_utilities\getConfiguration('general', 'title')); $body = $this->twig->render( - 'emails/password_reset.html.twig', + 'twig/emails/password_reset.html.twig', [ 'first_name' => $user->getFirstName(), 'username' => $user->getUsername(), @@ -960,7 +960,7 @@ private function generateNewUserEmail(\XDUser $newUser): array return [ sprintf('%s: Account Created', $pageTitle), $this->twig->render( - 'emails/new_user.html.twig', + 'twig/emails/new_user.html.twig', [ 'page_title' => $pageTitle, 'site_address' => $siteAddress, diff --git a/src/Controller/PasswordResetController.php b/src/Controller/PasswordResetController.php index 725064608f..24a8aaa598 100644 --- a/src/Controller/PasswordResetController.php +++ b/src/Controller/PasswordResetController.php @@ -41,7 +41,7 @@ public function index(Request $request): Response if ($validationCheck['status'] === INVALID || !in_array($mode, self::$validModes)) { return $this->render( - '::password_reset_expired.html.twig', + 'twig/password_reset_expired.html.twig', [ 'site_address' => $site_address = \xd_utilities\getConfigurationUrlBase('general', 'site_address') ] @@ -49,7 +49,7 @@ public function index(Request $request): Response } return $this->render( - '::password_reset.html.twig', + '/twig/password_reset.html.twig', [ 'rid' => $rid, 'mode' => $mode, diff --git a/templates/about/federated.html.twig b/templates/twig/about/federated.html.twig similarity index 100% rename from templates/about/federated.html.twig rename to templates/twig/about/federated.html.twig diff --git a/templates/about/links.html.twig b/templates/twig/about/links.html.twig similarity index 100% rename from templates/about/links.html.twig rename to templates/twig/about/links.html.twig diff --git a/templates/about/open_xdmod.html.twig b/templates/twig/about/open_xdmod.html.twig similarity index 100% rename from templates/about/open_xdmod.html.twig rename to templates/twig/about/open_xdmod.html.twig diff --git a/templates/about/presentations.html.twig b/templates/twig/about/presentations.html.twig similarity index 100% rename from templates/about/presentations.html.twig rename to templates/twig/about/presentations.html.twig diff --git a/templates/about/publications.html.twig b/templates/twig/about/publications.html.twig similarity index 100% rename from templates/about/publications.html.twig rename to templates/twig/about/publications.html.twig diff --git a/templates/about/roadmap.html.twig b/templates/twig/about/roadmap.html.twig similarity index 100% rename from templates/about/roadmap.html.twig rename to templates/twig/about/roadmap.html.twig diff --git a/templates/about/supremm.html.twig b/templates/twig/about/supremm.html.twig similarity index 100% rename from templates/about/supremm.html.twig rename to templates/twig/about/supremm.html.twig diff --git a/templates/about/team.html.twig b/templates/twig/about/team.html.twig similarity index 100% rename from templates/about/team.html.twig rename to templates/twig/about/team.html.twig diff --git a/templates/about/xdmod.html.twig b/templates/twig/about/xdmod.html.twig similarity index 100% rename from templates/about/xdmod.html.twig rename to templates/twig/about/xdmod.html.twig diff --git a/templates/about/xdmod_release_notes.html.twig b/templates/twig/about/xdmod_release_notes.html.twig similarity index 100% rename from templates/about/xdmod_release_notes.html.twig rename to templates/twig/about/xdmod_release_notes.html.twig diff --git a/templates/base.html.twig b/templates/twig/base.html.twig similarity index 100% rename from templates/base.html.twig rename to templates/twig/base.html.twig diff --git a/templates/emails/new_user.html.twig b/templates/twig/emails/new_user.html.twig similarity index 100% rename from templates/emails/new_user.html.twig rename to templates/twig/emails/new_user.html.twig diff --git a/templates/emails/password_reset.html.twig b/templates/twig/emails/password_reset.html.twig similarity index 100% rename from templates/emails/password_reset.html.twig rename to templates/twig/emails/password_reset.html.twig diff --git a/templates/index.html.twig b/templates/twig/index.html.twig similarity index 100% rename from templates/index.html.twig rename to templates/twig/index.html.twig diff --git a/templates/internal_dashboard.html.twig b/templates/twig/internal_dashboard.html.twig similarity index 100% rename from templates/internal_dashboard.html.twig rename to templates/twig/internal_dashboard.html.twig diff --git a/templates/internal_dashboard_login.html.twig b/templates/twig/internal_dashboard_login.html.twig similarity index 100% rename from templates/internal_dashboard_login.html.twig rename to templates/twig/internal_dashboard_login.html.twig diff --git a/templates/password_reset.html.twig b/templates/twig/password_reset.html.twig similarity index 100% rename from templates/password_reset.html.twig rename to templates/twig/password_reset.html.twig diff --git a/templates/password_reset_expired.html.twig b/templates/twig/password_reset_expired.html.twig similarity index 100% rename from templates/password_reset_expired.html.twig rename to templates/twig/password_reset_expired.html.twig From 8055b1684bda0961408931a0f26548c927e03a8b Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 1 Dec 2025 11:13:17 -0500 Subject: [PATCH 51/83] Add DotEnv creation to upgrade path --- .../Migration/DotEnvConfigMigration.php | 49 +++++++++++++++++++ .../OpenXdmod/Migration/MigrationFactory.php | 1 + config/services.yaml | 1 + 3 files changed, 51 insertions(+) create mode 100644 classes/OpenXdmod/Migration/DotEnvConfigMigration.php diff --git a/classes/OpenXdmod/Migration/DotEnvConfigMigration.php b/classes/OpenXdmod/Migration/DotEnvConfigMigration.php new file mode 100644 index 0000000000..c64cd1ec62 --- /dev/null +++ b/classes/OpenXdmod/Migration/DotEnvConfigMigration.php @@ -0,0 +1,49 @@ +apply([ + 'app_secret' => hash('sha512', time()) + ]); + file_put_contents(BASE_DIR . '/.env', $envTemplate->getContents()); + + $cmdBase = 'APP_ENV=prod APP_DEBUG=0'; + $console = BIN_DIR .'/console'; + + // Make sure to clear the cache before dumping the dotenv so we start clean. + $this->executeCommand("$cmdBase $console cache:clear"); + + // Dump dotenv data so we don't read .env each time in prod. + // Note: this means that if you want to start debugging stuff you'll need to delete the generated .env. + $this->executeCommand("$cmdBase $console dotenv:dump"); + } + } + + protected function executeCommand($command) + { + $output = array(); + $returnVar = 0; + + exec($command . ' 2>&1', $output, $returnVar); + + if ($returnVar != 0) { + $msg = "Command exited with non-zero return status:\n" + . "command = $command\noutput =\n" . implode("\n", $output); + throw new \Exception($msg); + } + + return $output; + } + + +} diff --git a/classes/OpenXdmod/Migration/MigrationFactory.php b/classes/OpenXdmod/Migration/MigrationFactory.php index c51f21df0f..c9c4ad29bf 100644 --- a/classes/OpenXdmod/Migration/MigrationFactory.php +++ b/classes/OpenXdmod/Migration/MigrationFactory.php @@ -93,6 +93,7 @@ function ($class) use ($databasesMigrationName) { } $migrations[] = new AclConfigMigration($fromVersion, $toVersion); + $migrations[] = new DotEnvConfigMigration($fromVersion, $toVersion); $migration = new CompositeMigration( $fromVersion, diff --git a/config/services.yaml b/config/services.yaml index 8ea4c6d659..28d58f06e9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -75,3 +75,4 @@ services: - '@http_kernel' - 'error_controller' - '@error_renderer' + Symfony\Component\Dotenv\Command\DotenvDumpCommand: ~ From afb1e7b2008a014bc979265e8ae3bb5b8fb977e7 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 1 Dec 2025 13:15:47 -0500 Subject: [PATCH 52/83] updates per code review by @jpwhite4 --- bin/console | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/console b/bin/console index beee10dad2..fc5a44d74a 100755 --- a/bin/console +++ b/bin/console @@ -27,7 +27,7 @@ foreach($files as $potentialFile) { } if ($file === null) { - throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".'); + throw new RuntimeException('Symfony Runtime is missing. Try running "composer require symfony/runtime".'); } require_once $file; From 92773f335ef449b8fe9f08fe5b926a3a9a19a7c4 Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 1 Dec 2025 13:23:47 -0500 Subject: [PATCH 53/83] Resolving SonarQube alert for SRI SRI is subresource integrity and informs the browser to check the requested script resource against the provided cryptographic hash. Here is the page used as a reference for changes: https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity and here is the url where the error can be seen: https://sonarcloud.io/project/security_hotspots?id=ubccr_xdmod&pullRequest=2097&sinceLeakPeriod=true --- templates/twig/index.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/twig/index.html.twig b/templates/twig/index.html.twig index cca763adee..2bd7508532 100644 --- a/templates/twig/index.html.twig +++ b/templates/twig/index.html.twig @@ -375,7 +375,7 @@ {% endif %} {% if captcha_site_key|length > 0 %} - + {% endif %} From 966128dba89bb88c8834a138467a430f4f539e8b Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 1 Dec 2025 14:37:25 -0500 Subject: [PATCH 54/83] Fixing passing null to strpos Fixing issue found here: https://app.circleci.com/pipelines/github/ubccr/xdmod/4752/workflows/29b1a380-8ab1-4cb5-872d-943b90d9ca53/jobs/12063 by replacing `strpos` usage ( which does not allow null haystacks ) to `str_contains` which does. --- classes/OpenXdmod/Migration/AclConfigMigration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/OpenXdmod/Migration/AclConfigMigration.php b/classes/OpenXdmod/Migration/AclConfigMigration.php index c266e91870..73699f38a3 100644 --- a/classes/OpenXdmod/Migration/AclConfigMigration.php +++ b/classes/OpenXdmod/Migration/AclConfigMigration.php @@ -18,7 +18,7 @@ public function execute() $cmd = BIN_DIR . '/acl-config'; $output = shell_exec($cmd); - $hadError = strpos($output, 'error') !== false; + $hadError = str_contains($output, 'error'); if ($hadError) { $this->logger->error($output); From 0c76371c2a9e98ae7480252c24e7d0a704a5f8ce Mon Sep 17 00:00:00 2001 From: Ryan Rathsam Date: Mon, 1 Dec 2025 14:40:23 -0500 Subject: [PATCH 55/83] update linter php version to 8.2 --- .github/workflows/linter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5a65f30d08..5dc30714e4 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -25,7 +25,7 @@ jobs: - name: Setup php uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.2' extensions: xml tools: composer:v2 From 9c05e7a09af4a5e3bd83af07b58859338c425fbc Mon Sep 17 00:00:00 2001 From: ryanrath Date: Thu, 4 Dec 2025 15:05:28 -0500 Subject: [PATCH 56/83] Removing Doctrine as it's unused (#2131) --- composer.json | 5 - composer.lock | 1760 ++++------------------ config/bundles.php | 2 - config/packages/doctrine.yaml | 50 - config/packages/doctrine_migrations.yaml | 6 - src/Entity/.gitignore | 0 symfony.lock | 36 - 7 files changed, 333 insertions(+), 1526 deletions(-) delete mode 100644 config/packages/doctrine.yaml delete mode 100644 config/packages/doctrine_migrations.yaml delete mode 100644 src/Entity/.gitignore diff --git a/composer.json b/composer.json index 6537fbbeac..c4b83e3398 100644 --- a/composer.json +++ b/composer.json @@ -5,11 +5,6 @@ "prefer-stable": true, "require": { "php": "^8.2", - "doctrine/annotations": "^2.0", - "doctrine/dbal": "^3", - "doctrine/doctrine-bundle": "^2.14", - "doctrine/doctrine-migrations-bundle": "^3.4", - "doctrine/orm": "^3.0", "egulias/email-validator": "^4", "firebase/php-jwt": "^6.10", "geoip2/geoip2": "^2.12", diff --git a/composer.lock b/composer.lock index 42c0cd2149..9efd30571f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3c1bfd67858c59820eaeb68bceab4d71", + "content-hash": "a50a7ac9c87d0fb8292f93eaed72241c", "packages": [ { "name": "composer/ca-bundle", @@ -233,282 +233,6 @@ ], "time": "2021-09-13T08:19:44+00:00" }, - { - "name": "doctrine/annotations", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/annotations.git", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/901c2ee5d26eb64ff43c47976e114bf00843acf7", - "reference": "901c2ee5d26eb64ff43c47976e114bf00843acf7", - "shasum": "" - }, - "require": { - "doctrine/lexer": "^2 || ^3", - "ext-tokenizer": "*", - "php": "^7.2 || ^8.0", - "psr/cache": "^1 || ^2 || ^3" - }, - "require-dev": { - "doctrine/cache": "^2.0", - "doctrine/coding-standard": "^10", - "phpstan/phpstan": "^1.10.28", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^5.4 || ^6.4 || ^7", - "vimeo/psalm": "^4.30 || ^5.14" - }, - "suggest": { - "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Annotations\\": "lib/Doctrine/Common/Annotations" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Docblock Annotations Parser", - "homepage": "https://www.doctrine-project.org/projects/annotations.html", - "keywords": [ - "annotations", - "docblock", - "parser" - ], - "support": { - "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/2.0.2" - }, - "time": "2024-09-05T10:17:24+00:00" - }, - { - "name": "doctrine/collections", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/collections.git", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1", - "php": "^8.1", - "symfony/polyfill-php84": "^1.30" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "ext-json": "*", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Collections\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", - "homepage": "https://www.doctrine-project.org/projects/collections.html", - "keywords": [ - "array", - "collections", - "iterators", - "php" - ], - "support": { - "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.3.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", - "type": "tidelift" - } - ], - "time": "2025-03-22T10:17:19+00:00" - }, - { - "name": "doctrine/dbal", - "version": "3.10.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/dbal.git", - "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/1cf840d696373ea0d58ad0a8875c0fadcfc67214", - "reference": "1cf840d696373ea0d58ad0a8875c0fadcfc67214", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "doctrine/deprecations": "^0.5.3|^1", - "doctrine/event-manager": "^1|^2", - "php": "^7.4 || ^8.0", - "psr/cache": "^1|^2|^3", - "psr/log": "^1|^2|^3" - }, - "conflict": { - "doctrine/cache": "< 1.11" - }, - "require-dev": { - "doctrine/cache": "^1.11|^2.0", - "doctrine/coding-standard": "13.0.0", - "fig/log-test": "^1", - "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "2.1.17", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.23", - "slevomat/coding-standard": "8.16.2", - "squizlabs/php_codesniffer": "3.13.1", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0" - }, - "suggest": { - "symfony/console": "For helpful console commands such as SQL execution and import of files." - }, - "bin": [ - "bin/doctrine-dbal" - ], - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\DBAL\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - } - ], - "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", - "homepage": "https://www.doctrine-project.org/projects/dbal.html", - "keywords": [ - "abstraction", - "database", - "db2", - "dbal", - "mariadb", - "mssql", - "mysql", - "oci8", - "oracle", - "pdo", - "pgsql", - "postgresql", - "queryobject", - "sasql", - "sql", - "sqlite", - "sqlserver", - "sqlsrv" - ], - "support": { - "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", - "type": "tidelift" - } - ], - "time": "2025-07-10T21:11:04+00:00" - }, { "name": "doctrine/deprecations", "version": "1.1.5", @@ -558,492 +282,33 @@ "time": "2025-04-07T20:06:18+00:00" }, { - "name": "doctrine/doctrine-bundle", - "version": "2.15.0", + "name": "doctrine/lexer", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "d88294521a1bca943240adca65fa19ca8a7288c6" + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/d88294521a1bca943240adca65fa19ca8a7288c6", - "reference": "d88294521a1bca943240adca65fa19ca8a7288c6", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", "shasum": "" }, "require": { - "doctrine/dbal": "^3.7.0 || ^4.0", - "doctrine/persistence": "^3.1 || ^4", - "doctrine/sql-formatter": "^1.0.1", - "php": "^8.1", - "symfony/cache": "^6.4 || ^7.0", - "symfony/config": "^6.4 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", - "symfony/framework-bundle": "^6.4 || ^7.0", - "symfony/service-contracts": "^2.5 || ^3" - }, - "conflict": { - "doctrine/annotations": ">=3.0", - "doctrine/cache": "< 1.11", - "doctrine/orm": "<2.17 || >=4.0", - "symfony/var-exporter": "< 6.4.1 || 7.0.0", - "twig/twig": "<2.13 || >=3.0 <3.0.4" + "php": "^8.1" }, "require-dev": { - "doctrine/annotations": "^1 || ^2", - "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^13", - "doctrine/deprecations": "^1.0", - "doctrine/orm": "^2.17 || ^3.1", - "friendsofphp/proxy-manager-lts": "^1.0", - "phpstan/phpstan": "2.1.1", - "phpstan/phpstan-phpunit": "2.0.3", - "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.6.22", - "psr/log": "^1.1.4 || ^2.0 || ^3.0", - "symfony/doctrine-messenger": "^6.4 || ^7.0", - "symfony/messenger": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.2", - "symfony/property-info": "^6.4 || ^7.0", - "symfony/security-bundle": "^6.4 || ^7.0", - "symfony/stopwatch": "^6.4 || ^7.0", - "symfony/string": "^6.4 || ^7.0", - "symfony/twig-bridge": "^6.4 || ^7.0", - "symfony/validator": "^6.4 || ^7.0", - "symfony/var-exporter": "^6.4.1 || ^7.0.1", - "symfony/web-profiler-bundle": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.13 || ^3.0.4" - }, - "suggest": { - "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", - "ext-pdo": "*", - "symfony/web-profiler-bundle": "To use the data collector." + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" }, - "type": "symfony-bundle", + "type": "library", "autoload": { "psr-4": { - "Doctrine\\Bundle\\DoctrineBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, - { - "name": "Doctrine Project", - "homepage": "https://www.doctrine-project.org/" - } - ], - "description": "Symfony DoctrineBundle", - "homepage": "https://www.doctrine-project.org", - "keywords": [ - "database", - "dbal", - "orm", - "persistence" - ], - "support": { - "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.15.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", - "type": "tidelift" - } - ], - "time": "2025-06-16T19:53:58+00:00" - }, - { - "name": "doctrine/doctrine-migrations-bundle", - "version": "3.4.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/5a6ac7120c2924c4c070a869d08b11ccf9e277b9", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9", - "shasum": "" - }, - "require": { - "doctrine/doctrine-bundle": "^2.4", - "doctrine/migrations": "^3.2", - "php": "^7.2 || ^8.0", - "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" - }, - "require-dev": { - "composer/semver": "^3.0", - "doctrine/coding-standard": "^12", - "doctrine/orm": "^2.6 || ^3", - "phpstan/phpstan": "^1.4 || ^2", - "phpstan/phpstan-deprecation-rules": "^1 || ^2", - "phpstan/phpstan-phpunit": "^1 || ^2", - "phpstan/phpstan-strict-rules": "^1.1 || ^2", - "phpstan/phpstan-symfony": "^1.3 || ^2", - "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/phpunit-bridge": "^6.3 || ^7", - "symfony/var-exporter": "^5.4 || ^6 || ^7" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Doctrine\\Bundle\\MigrationsBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Doctrine Project", - "homepage": "https://www.doctrine-project.org" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony DoctrineMigrationsBundle", - "homepage": "https://www.doctrine-project.org", - "keywords": [ - "dbal", - "migrations", - "schema" - ], - "support": { - "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.2" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-migrations-bundle", - "type": "tidelift" - } - ], - "time": "2025-03-11T17:36:26+00:00" - }, - { - "name": "doctrine/event-manager", - "version": "2.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "conflict": { - "doctrine/common": "<2.9" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", - "homepage": "https://www.doctrine-project.org/projects/event-manager.html", - "keywords": [ - "event", - "event dispatcher", - "event manager", - "event system", - "events" - ], - "support": { - "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", - "type": "tidelift" - } - ], - "time": "2024-05-22T20:47:39+00:00" - }, - { - "name": "doctrine/inflector", - "version": "2.0.10", - "source": { - "type": "git", - "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", - "homepage": "https://www.doctrine-project.org/projects/inflector.html", - "keywords": [ - "inflection", - "inflector", - "lowercase", - "manipulation", - "php", - "plural", - "singular", - "strings", - "uppercase", - "words" - ], - "support": { - "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", - "type": "tidelift" - } - ], - "time": "2024-02-18T20:23:39+00:00" - }, - { - "name": "doctrine/instantiator", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" - } - ], - "time": "2022-12-30T00:23:10+00:00" - }, - { - "name": "doctrine/lexer", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Lexer\\": "src" + "Doctrine\\Common\\Lexer\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1054,387 +319,44 @@ { "name": "Guilherme Blanco", "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", - "homepage": "https://www.doctrine-project.org/projects/lexer.html", - "keywords": [ - "annotations", - "docblock", - "lexer", - "parser", - "php" - ], - "support": { - "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", - "type": "tidelift" - } - ], - "time": "2024-02-05T11:56:58+00:00" - }, - { - "name": "doctrine/migrations", - "version": "3.9.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/migrations.git", - "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/fa94c6f06b1bc6d4759481ec20b8b81d13e861be", - "reference": "fa94c6f06b1bc6d4759481ec20b8b81d13e861be", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "doctrine/dbal": "^3.6 || ^4", - "doctrine/deprecations": "^0.5.3 || ^1", - "doctrine/event-manager": "^1.2 || ^2.0", - "php": "^8.1", - "psr/log": "^1.1.3 || ^2 || ^3", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^6.2 || ^7.0" - }, - "conflict": { - "doctrine/orm": "<2.12 || >=4" - }, - "require-dev": { - "doctrine/coding-standard": "^13", - "doctrine/orm": "^2.13 || ^3", - "doctrine/persistence": "^2 || ^3 || ^4", - "doctrine/sql-formatter": "^1.0", - "ext-pdo_sqlite": "*", - "fig/log-test": "^1", - "phpstan/phpstan": "^2", - "phpstan/phpstan-deprecation-rules": "^2", - "phpstan/phpstan-phpunit": "^2", - "phpstan/phpstan-strict-rules": "^2", - "phpstan/phpstan-symfony": "^2", - "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", - "symfony/cache": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" - }, - "suggest": { - "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", - "symfony/yaml": "Allows the use of yaml for migration configuration files." - }, - "bin": [ - "bin/doctrine-migrations" - ], - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Migrations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Michael Simonson", - "email": "contact@mikesimonson.com" - } - ], - "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", - "homepage": "https://www.doctrine-project.org/projects/migrations.html", - "keywords": [ - "database", - "dbal", - "migrations" - ], - "support": { - "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.2" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", - "type": "tidelift" - } - ], - "time": "2025-07-29T11:36:14+00:00" - }, - { - "name": "doctrine/orm", - "version": "3.5.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/orm.git", - "reference": "6deec3655ba3e8f15280aac11e264225854d2369" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/6deec3655ba3e8f15280aac11e264225854d2369", - "reference": "6deec3655ba3e8f15280aac11e264225854d2369", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "doctrine/collections": "^2.2", - "doctrine/dbal": "^3.8.2 || ^4", - "doctrine/deprecations": "^0.5.3 || ^1", - "doctrine/event-manager": "^1.2 || ^2", - "doctrine/inflector": "^1.4 || ^2.0", - "doctrine/instantiator": "^1.3 || ^2", - "doctrine/lexer": "^3", - "doctrine/persistence": "^3.3.1 || ^4", - "ext-ctype": "*", - "php": "^8.1", - "psr/cache": "^1 || ^2 || ^3", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^6.3.9 || ^7.0" - }, - "require-dev": { - "doctrine/coding-standard": "^13.0", - "phpbench/phpbench": "^1.0", - "phpdocumentor/guides-cli": "^1.4", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "2.0.3", - "phpstan/phpstan-deprecation-rules": "^2", - "phpunit/phpunit": "^10.4.0", - "psr/log": "^1 || ^2 || ^3", - "squizlabs/php_codesniffer": "3.12.0", - "symfony/cache": "^5.4 || ^6.2 || ^7.0" - }, - "suggest": { - "ext-dom": "Provides support for XSD validation for XML mapping files", - "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\ORM\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "Object-Relational-Mapper for PHP", - "homepage": "https://www.doctrine-project.org/projects/orm.html", - "keywords": [ - "database", - "orm" - ], - "support": { - "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.5.0" - }, - "time": "2025-07-01T17:40:53+00:00" - }, - { - "name": "doctrine/persistence", - "version": "4.0.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/persistence.git", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa", - "shasum": "" - }, - "require": { - "doctrine/event-manager": "^1 || ^2", - "php": "^8.1", - "psr/cache": "^1.0 || ^2.0 || ^3.0" - }, - "conflict": { - "doctrine/common": "<2.10" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "1.12.7", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^9.6", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Persistence\\": "src/Persistence" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - }, - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - } - ], - "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", - "homepage": "https://www.doctrine-project.org/projects/persistence.html", - "keywords": [ - "mapper", - "object", - "odm", - "orm", - "persistence" - ], - "support": { - "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.0.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", - "type": "tidelift" - } - ], - "time": "2024-11-01T21:49:07+00:00" - }, - { - "name": "doctrine/sql-formatter", - "version": "1.5.2", - "source": { - "type": "git", - "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^12", - "ergebnis/phpunit-slow-test-detector": "^2.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" - }, - "bin": [ - "bin/sql-formatter" - ], - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\SqlFormatter\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, { - "name": "Jeremy Dorn", - "email": "jeremy@jeremydorn.com", - "homepage": "https://jeremydorn.com/" + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" } ], - "description": "a PHP SQL highlighting library", - "homepage": "https://github.com/doctrine/sql-formatter/", + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", "keywords": [ - "highlight", - "sql" + "annotations", + "docblock", + "lexer", + "parser", + "php" ], "support": { - "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" }, - "time": "2025-01-24T11:45:48+00:00" + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" }, { "name": "egulias/email-validator", @@ -4679,16 +3601,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.24", + "version": "v6.4.28", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175" + "reference": "31628f36fc97c5714d181b3a8d29efb85c6a7677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/d038cd3054aeaf1c674022a77048b2ef6376a175", - "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175", + "url": "https://api.github.com/repos/symfony/cache/zipball/31628f36fc97c5714d181b3a8d29efb85c6a7677", + "reference": "31628f36fc97c5714d181b3a8d29efb85c6a7677", "shasum": "" }, "require": { @@ -4755,7 +3677,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.24" + "source": "https://github.com/symfony/cache/tree/v6.4.28" }, "funding": [ { @@ -4775,7 +3697,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T09:32:03+00:00" + "time": "2025-10-30T08:37:02+00:00" }, { "name": "symfony/cache-contracts", @@ -4929,16 +3851,16 @@ }, { "name": "symfony/config", - "version": "v6.4.24", + "version": "v6.4.28", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e" + "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/80e2cf005cf17138c97193be0434cdcfd1b2212e", - "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e", + "url": "https://api.github.com/repos/symfony/config/zipball/15947c18ef3ddb0b2f4ec936b9e90e2520979f62", + "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62", "shasum": "" }, "require": { @@ -4984,7 +3906,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.24" + "source": "https://github.com/symfony/config/tree/v6.4.28" }, "funding": [ { @@ -5004,7 +3926,7 @@ "type": "tidelift" } ], - "time": "2025-07-26T13:50:30+00:00" + "time": "2025-11-01T19:52:02+00:00" }, { "name": "symfony/console", @@ -5106,16 +4028,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.24", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "929ab73b93247a15166ee79e807ccee4f930322d" + "reference": "5f311eaf0b321f8ec640f6bae12da43a14026898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/929ab73b93247a15166ee79e807ccee4f930322d", - "reference": "929ab73b93247a15166ee79e807ccee4f930322d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/5f311eaf0b321f8ec640f6bae12da43a14026898", + "reference": "5f311eaf0b321f8ec640f6bae12da43a14026898", "shasum": "" }, "require": { @@ -5167,7 +4089,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.24" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.26" }, "funding": [ { @@ -5187,7 +4109,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:30:48+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5256,119 +4178,6 @@ ], "time": "2024-09-25T14:21:43+00:00" }, - { - "name": "symfony/doctrine-bridge", - "version": "v7.3.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", - "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", - "shasum": "" - }, - "require": { - "doctrine/event-manager": "^2", - "doctrine/persistence": "^3.1|^4", - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "doctrine/collections": "<1.8", - "doctrine/dbal": "<3.6", - "doctrine/lexer": "<1.1", - "doctrine/orm": "<2.15", - "symfony/cache": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/form": "<6.4.6|>=7,<7.0.6", - "symfony/http-foundation": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/lock": "<6.4", - "symfony/messenger": "<6.4", - "symfony/property-info": "<6.4", - "symfony/security-bundle": "<6.4", - "symfony/security-core": "<6.4", - "symfony/validator": "<6.4" - }, - "require-dev": { - "doctrine/collections": "^1.8|^2.0", - "doctrine/data-fixtures": "^1.1|^2", - "doctrine/dbal": "^3.6|^4", - "doctrine/orm": "^2.15|^3", - "psr/log": "^1|^2|^3", - "symfony/cache": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/doctrine-messenger": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/form": "^6.4.6|^7.0.6", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/security-core": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", - "symfony/type-info": "^7.1.8", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" - }, - "type": "symfony-bridge", - "autoload": { - "psr-4": { - "Symfony\\Bridge\\Doctrine\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides integration for Doctrine with various Symfony components", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-15T11:36:08+00:00" - }, { "name": "symfony/dotenv", "version": "v6.4.24", @@ -6901,7 +5710,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -6962,7 +5771,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -6973,6 +5782,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -7220,82 +6033,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php84", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "000df7860439609837bbe28670b0be15783b7fbf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", - "reference": "000df7860439609837bbe28670b0be15783b7fbf", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-02-20T12:04:08+00:00" - }, { "name": "symfony/property-access", "version": "v6.4.24", @@ -8180,16 +6917,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -8208,84 +6945,14 @@ }, "branch-alias": { "dev-main": "3.6-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-04-25T09:37:31+00:00" - }, - { - "name": "symfony/stopwatch", - "version": "v6.4.24", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", - "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/service-contracts": "^2.5|^3" + } }, - "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" + "Symfony\\Contracts\\Service\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -8294,18 +6961,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a way to profile code", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -8325,7 +7000,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", @@ -8786,16 +7461,16 @@ }, { "name": "symfony/var-exporter", - "version": "v6.4.24", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35" + "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35", - "reference": "1e742d559fe5b19d0cdc281b1bf0b1fcc243bd35", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/466fcac5fa2e871f83d31173f80e9c2684743bfc", + "reference": "466fcac5fa2e871f83d31173f80e9c2684743bfc", "shasum": "" }, "require": { @@ -8843,7 +7518,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.24" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.26" }, "funding": [ { @@ -8863,7 +7538,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/yaml", @@ -9368,6 +8043,167 @@ }, "time": "2022-02-13T15:00:28+00:00" }, + { + "name": "doctrine/inflector", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.0.10" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2024-02-18T20:23:39+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.3", @@ -11092,16 +9928,16 @@ }, { "name": "symfony/process", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", "shasum": "" }, "require": { @@ -11133,7 +9969,73 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.0" + "source": "https://github.com/symfony/process/tree/v7.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-16T11:21:06+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "reference": "b67e94e06a05d9572c2fa354483b3e13e3cb1898", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v6.4.24" }, "funding": [ { @@ -11144,12 +10046,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-17T09:11:12+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "symfony/web-profiler-bundle", diff --git a/config/bundles.php b/config/bundles.php index 35579e2b4d..c6e7d818ab 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -7,6 +7,4 @@ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml deleted file mode 100644 index d42c52d6d2..0000000000 --- a/config/packages/doctrine.yaml +++ /dev/null @@ -1,50 +0,0 @@ -doctrine: - dbal: - url: '%env(resolve:DATABASE_URL)%' - - # 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%' - use_savepoints: true - orm: - auto_generate_proxy_classes: true - enable_lazy_ghost_objects: true - report_fields_where_declared: true - validate_xml_mapping: true - naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - auto_mapping: true - mappings: - App: - type: attribute - is_bundle: false - dir: '%kernel.project_dir%/src/Entity' - prefix: 'App\Entity' - alias: App - -when@test: - doctrine: - dbal: - # "TEST_TOKEN" is typically set by ParaTest - dbname_suffix: '_test%env(default::TEST_TOKEN)%' - -when@prod: - doctrine: - orm: - auto_generate_proxy_classes: false - proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' - 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 diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml deleted file mode 100644 index 29231d94bd..0000000000 --- a/config/packages/doctrine_migrations.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/symfony.lock b/symfony.lock index 97e718351b..afaec77ff8 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,13 +1,4 @@ { - "doctrine/annotations": { - "version": "2.0", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "1.10", - "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05" - } - }, "doctrine/deprecations": { "version": "1.1", "recipe": { @@ -17,33 +8,6 @@ "ref": "87424683adc81d7dc305eefec1fced883084aab9" } }, - "doctrine/doctrine-bundle": { - "version": "2.15", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "2.10", - "ref": "d1778a69711a9b06bb4e202977ca6c4a0d16933d" - }, - "files": [ - "config/packages/doctrine.yaml", - "src/Entity/.gitignore", - "src/Repository/.gitignore" - ] - }, - "doctrine/doctrine-migrations-bundle": { - "version": "3.4", - "recipe": { - "repo": "github.com/symfony/recipes", - "branch": "main", - "version": "3.1", - "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" - }, - "files": [ - "config/packages/doctrine_migrations.yaml", - "migrations/.gitignore" - ] - }, "google/recaptcha": { "version": "1.3", "recipe": { From a3d522cd35d169475e0e15bd1be9db9656a15080 Mon Sep 17 00:00:00 2001 From: Joe White Date: Mon, 8 Dec 2025 14:14:47 -0500 Subject: [PATCH 57/83] Sync changes with upstream/main (#2132) --- .../Migration/AclConfigMigration.php | 5 +- .../InternalDashboardController.php | 10 +-- .../InternalDashboard/UserAdminController.php | 2 +- .../user_admin/input/get_user_visits.json | 62 +++++++++---------- tests/component/lib/BaseTest.php | 35 ----------- tests/component/lib/ETL/IngestorTest.php | 3 - .../component/lib/Export/RealmManagerTest.php | 12 +++- .../lib/Controllers/BaseUserAdminTest.php | 32 +++++----- .../lib/Controllers/ControllerTest.php | 52 +++++++--------- .../lib/Controllers/ReportBuilderTest.php | 12 +--- .../lib/Controllers/UsageExplorerTest.php | 22 +------ .../lib/Controllers/UserAdminTest.php | 27 ++++---- tests/integration/lib/Rest/JobViewerTest.php | 6 -- .../lib/TestHarness/XdmodTestHelper.php | 47 -------------- tests/playwright/Docker/docker-compose.yml | 1 - .../lib/internal_dashboard.selectors.ts | 17 ++--- tests/playwright/lib/usageTab.page.ts | 13 ++-- .../internal_dashboard.spec.ts | 28 ++------- .../Controllers/MetricExplorerChartsTest.php | 4 +- .../ETL/DataEndpoint/WebServerLogFileTest.php | 26 +------- 20 files changed, 124 insertions(+), 292 deletions(-) diff --git a/classes/OpenXdmod/Migration/AclConfigMigration.php b/classes/OpenXdmod/Migration/AclConfigMigration.php index 73699f38a3..ffbc2ff106 100644 --- a/classes/OpenXdmod/Migration/AclConfigMigration.php +++ b/classes/OpenXdmod/Migration/AclConfigMigration.php @@ -18,9 +18,10 @@ public function execute() $cmd = BIN_DIR . '/acl-config'; $output = shell_exec($cmd); - $hadError = str_contains($output, 'error'); - if ($hadError) { + if ($output === false) { + $this->logger->error("Error executing acl-config"); + } else if ($output !== null) { $this->logger->error($output); } } diff --git a/src/Controller/InternalDashboard/InternalDashboardController.php b/src/Controller/InternalDashboard/InternalDashboardController.php index 7df6ceb2cc..0602999346 100644 --- a/src/Controller/InternalDashboard/InternalDashboardController.php +++ b/src/Controller/InternalDashboard/InternalDashboardController.php @@ -334,12 +334,6 @@ private function enumExistingUsers(Request $request): Response 'count' => count($filtered), 'response' => $filtered ]; - /*return $this->json([ - 'success' => true, - 'count' => count($filtered), - 'response' => $filtered - - ]);*/ return new Response(json_encode($data)); } @@ -381,10 +375,10 @@ private function enumUserVisits(Request $request, string $operation): Response $userTypes = explode(',', $this->getStringParam($request, 'user_types')); $logger = $this->logger; if (!in_array($timeframe, ['year', 'month'])) { - return $this->json([ + return new Response(json_encode([ 'success' => false, 'message' => 'invalid value specified for the timeframe' - ]); + ])); } $data = [ diff --git a/src/Controller/InternalDashboard/UserAdminController.php b/src/Controller/InternalDashboard/UserAdminController.php index 11c5e30aaa..b337dd411d 100644 --- a/src/Controller/InternalDashboard/UserAdminController.php +++ b/src/Controller/InternalDashboard/UserAdminController.php @@ -481,7 +481,7 @@ function ($value) { $userName ), 'username' => $userName, - 'user_type' => $userToUpdate->getUserType() + 'user_type' => (string) $userToUpdate->getUserType() # JS code expects a string encoded value ]); } diff --git a/tests/artifacts/xdmod/user_admin/input/get_user_visits.json b/tests/artifacts/xdmod/user_admin/input/get_user_visits.json index 080c31b1fa..cb1c84da86 100644 --- a/tests/artifacts/xdmod/user_admin/input/get_user_visits.json +++ b/tests/artifacts/xdmod/user_admin/input/get_user_visits.json @@ -29,7 +29,7 @@ }, "success": false, "output": "get_user_visits_quarter_1", - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -62,7 +62,7 @@ }, "output": "get_user_visits_quarter_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -95,7 +95,7 @@ }, "output": "get_user_visits_quarter_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -128,7 +128,7 @@ }, "output": "get_user_visits_quarter_3", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -161,7 +161,7 @@ }, "output": "get_user_visits_quarter_3_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -194,7 +194,7 @@ }, "output": "get_user_visits_quarter_3_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -227,7 +227,7 @@ }, "output": "get_user_visits_quarter_3_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -260,7 +260,7 @@ }, "output": "get_user_visits_quarter_5", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -293,7 +293,7 @@ }, "output": "get_user_visits_quarter_5_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -326,7 +326,7 @@ }, "output": "get_user_visits_quarter_5_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -359,7 +359,7 @@ }, "output": "get_user_visits_quarter_5_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -392,7 +392,7 @@ }, "output": "get_user_visits_quarter_5_3", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -425,7 +425,7 @@ }, "output": "get_user_visits_quarter_5_3_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -458,7 +458,7 @@ }, "output": "get_user_visits_quarter_5_3_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -491,7 +491,7 @@ }, "output": "get_user_visits_quarter_5_3_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -524,7 +524,7 @@ }, "output": "get_user_visits_quarter_700", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -557,7 +557,7 @@ }, "output": "get_user_visits_quarter_700_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -590,7 +590,7 @@ }, "output": "get_user_visits_quarter_700_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -623,7 +623,7 @@ }, "output": "get_user_visits_quarter_700_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -656,7 +656,7 @@ }, "output": "get_user_visits_quarter_700_3", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -689,7 +689,7 @@ }, "output": "get_user_visits_quarter_700_3_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -722,7 +722,7 @@ }, "output": "get_user_visits_quarter_700_3_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -755,7 +755,7 @@ }, "output": "get_user_visits_quarter_700_3_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -788,7 +788,7 @@ }, "output": "get_user_visits_quarter_700_5", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -821,7 +821,7 @@ }, "output": "get_user_visits_quarter_700_5_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -854,7 +854,7 @@ }, "output": "get_user_visits_quarter_700_5_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -887,7 +887,7 @@ }, "output": "get_user_visits_quarter_700_5_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -920,7 +920,7 @@ }, "output": "get_user_visits_quarter_700_5_3", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -953,7 +953,7 @@ }, "output": "get_user_visits_quarter_700_5_3_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -986,7 +986,7 @@ }, "output": "get_user_visits_quarter_700_5_3_2", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ], [ @@ -1019,7 +1019,7 @@ }, "output": "get_user_visits_quarter_700_5_3_2_1", "success": false, - "content_type": "application/json" + "content_type": "text/html; charset=UTF-8" } ] ] diff --git a/tests/component/lib/BaseTest.php b/tests/component/lib/BaseTest.php index c3dbb28113..0c99df1bea 100644 --- a/tests/component/lib/BaseTest.php +++ b/tests/component/lib/BaseTest.php @@ -154,39 +154,4 @@ protected function arrayFilterKeysRecursive(array $keyList, array $input) } return $tmpArray; } - - /** - * This function provides away to determine if two arrays contain the same data. Ordering of keys will only affect - * the results if the arrays are numerically indexed. - * - * @param array $left - * @param array $right - * @param bool $exact if true, then the values will also be strictly compared. - * @return void this function does not return a value. If the arrays are not the same then it will fail the test, - * w/ the differences between the arrays that were found. - */ - protected function arraysAreSame( array $left, array $right, bool $exact = true) - { - if (count(array_diff(array_keys($left), array_keys($right))) > 0) { - $this->fail('Keys are different'); - } - $differences = []; - foreach($left as $lkey => $lvalue) { - $ltype = gettype($lvalue); - $rtype = gettype($right[$lkey]); - if ($ltype !== $rtype) { - $differences []= sprintf("Expected $lkey to be %s got %s", $ltype, $rtype); - if ($exact && $lvalue !== $right[$lkey]) { - $differences []= sprintf("Expected $lkey value to be %s got %s", $lvalue, $right[$lkey]); - } - } - } - if (count($differences) > 0) { - $this->fail(sprintf( - "Differences Found:\n%s", - implode("\n", $differences) - )); - } - $this->assertTrue(true); - } } diff --git a/tests/component/lib/ETL/IngestorTest.php b/tests/component/lib/ETL/IngestorTest.php index 6f2a65dae2..c27f3ab22d 100644 --- a/tests/component/lib/ETL/IngestorTest.php +++ b/tests/component/lib/ETL/IngestorTest.php @@ -48,9 +48,6 @@ public function testLoadDataInfileWarnings() { if ( ! empty($result['stdout']) ) { foreach ( explode(PHP_EOL, trim($result['stdout'])) as $line ) { - if (empty(trim($line))) { - continue; - } $this->assertMatchesRegularExpression('/[Ww][Aa][Rr][Nn][Ii][Nn][Gg]/', $line); $numWarnings++; } diff --git a/tests/component/lib/Export/RealmManagerTest.php b/tests/component/lib/Export/RealmManagerTest.php index f0d2fa5dcb..aaef07d317 100644 --- a/tests/component/lib/Export/RealmManagerTest.php +++ b/tests/component/lib/Export/RealmManagerTest.php @@ -88,7 +88,11 @@ public function testGetRealms($realms) fn($realm) => ['name' => $realm->getName(), 'display' => $realm->getDisplay()], self::$realmManager->getRealms() ); - $this->arraysAreSame($realms, $actual); + $this->assertEqualsCanonicalizing( + $realms, + $actual, + sprintf('Expected: %s, Received: %s', json_encode($realms), json_encode($actual)) + ); } /** @@ -103,7 +107,11 @@ public function testGetRealmsForUser($role, $realms) fn($realm) => ['name' => $realm->getName(), 'display' => $realm->getDisplay()], self::$realmManager->getRealmsForUser(self::$users[$role]) ); - $this->arraysAreSame($realms, $actual); + $this->assertEqualsCanonicalizing( + $realms, + $actual, + sprintf('Expected: %s, Received: %s', json_encode($realms), json_encode($actual)) + ); } /** diff --git a/tests/integration/lib/Controllers/BaseUserAdminTest.php b/tests/integration/lib/Controllers/BaseUserAdminTest.php index 8d14bac427..cdb6b86f70 100644 --- a/tests/integration/lib/Controllers/BaseUserAdminTest.php +++ b/tests/integration/lib/Controllers/BaseUserAdminTest.php @@ -158,7 +158,7 @@ protected static function removeUser($userId, $username) } } - $helper->logoutDashboard(); + $helper->logout(); } /** @@ -305,15 +305,16 @@ protected function updateCurrentUser($username, $password, $firstName = null, $l $updateUserData['email_address'] = $emailAddress; } - $this->log("Attempting to Update User"); - $this->log(var_export($updateUserData, true)); - $updateUserResponse = $helper->patch('rest/users/current', null, $updateUserData); + $updateUserResponse = $helper->patch( + 'rest/v0.1/users/current', + null, + $updateUserData + ); $expected = JSON::loadFile( parent::getTestFiles()->getFile('user_admin', 'test.update_user') ); - $this->log("Validating Update User Response"); $this->validateResponse($updateUserResponse); $this->assertEquals( @@ -322,13 +323,12 @@ protected function updateCurrentUser($username, $password, $firstName = null, $l "Unable to validate update user response. Expected: " . json_encode($expected) . " Received: " . json_encode($updateUserResponse[0]) ); - $this->log("Switching back"); $switchBackResult = $helper->get('', ['_switch_user' => '_exit']); - if ($switchBackResult[1]['http_code'] !== 200) { - echo "Switch Back Request unexpectedly failed\n"; - print_r($switchBackResult); - } - $this->log("Logging Out!"); + $this->assertEquals( + 200, + $switchBackResult[1]['http_code'], + 'Switch Back Request unexpectedly failed' + ); $helper->logout(); } @@ -377,7 +377,7 @@ protected function updateUser($userId, $emailAddress, $acls, $assignedPerson, $i $this->assertTrue($data['success'], "Expected the 'success' property to be: true Received: " . $data['success']); - $this->helper->logoutDashboard(); + $this->helper->logout(); } /** @@ -416,7 +416,7 @@ protected function retrieveUserId($userName, $userGroup = 3) $this->assertNotNull($userId, "Unable to find user: $userName in user group: $userGroup"); - $this->helper->logoutDashboard(); + $this->helper->logout(); return $userId; } @@ -448,7 +448,7 @@ protected function retrieveUserProperties($userId, array $properties) $user = $response[0]['user_information']; $keys = array_intersect($properties, array_keys($user)); $results = array_intersect_key($user, array_flip($keys)); - $this->helper->logoutDashboard(); + $this->helper->logout(); return count($results) === 1 && count($properties) === 1 ? array_pop($results) : $results; } @@ -468,10 +468,6 @@ protected function validateResponse($response, $expectedHttpCode = 200, $expecte { $actualContentType = $response[1]['content_type']; $actualHttpCode = $response[1]['http_code']; - if ($actualHttpCode !== $expectedHttpCode || $actualContentType !== $expectedContentType) { - print_r($response); - } - $this->assertTrue( strpos($actualContentType, $expectedContentType) !== false, "Expected content-type: $expectedContentType. Received: $actualContentType" diff --git a/tests/integration/lib/Controllers/ControllerTest.php b/tests/integration/lib/Controllers/ControllerTest.php index 4cd8c67aff..04be089426 100644 --- a/tests/integration/lib/Controllers/ControllerTest.php +++ b/tests/integration/lib/Controllers/ControllerTest.php @@ -22,7 +22,7 @@ protected function setup(): void public function testEnumExistingUsers() { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $params = array( 'operation' => 'enum_existing_users', @@ -65,7 +65,7 @@ public function testEnumExistingUsers() $this->assertTrue($allFound, "There were other differences besides the expected 'last_logged_in' | " . json_encode($actualUsers)); - $this->helper->logoutDashboard(); + $this->helper->logout(); } public function testEnumUserTypes() @@ -74,7 +74,7 @@ public function testEnumUserTypes() parent::getTestFiles()->getFile('controllers', 'enum_user_types-8.0.0') ); - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'operation' => 'enum_user_types' @@ -97,7 +97,7 @@ public function testEnumUserTypes() $this->assertEquals($expected, $actual, "Expected the actual results to match the expected results"); - $this->helper->logoutDashboard(); + $this->helper->logout(); } public function testEnumRoles() @@ -106,7 +106,7 @@ public function testEnumRoles() parent::getTestFiles()->getFile('controllers', 'enum_roles-add_default_center') ); - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'operation' => 'enum_roles' @@ -158,7 +158,7 @@ function ($value, $index, $properties) use (&$success) { $this->assertTrue($allFound, "There were other differences besides the expected 'last_logged_in'"); - $this->helper->logoutDashboard(); + $this->helper->logout(); } @@ -174,7 +174,7 @@ public function testListUsers(array $options) $group = $options['user_group']; $outputFile = $options['output']; - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'operation' => 'list_users', @@ -198,7 +198,7 @@ public function testListUsers(array $options) $this->assertTrue($allFound, "There were other differences besides the expected 'last_logged_in'"); - $this->helper->logoutDashboard(); + $this->helper->logout(); } public function listUsersGroupProvider() @@ -214,7 +214,7 @@ public function testEnumUserTypesAndRoles() parent::getTestFiles()->getFile('controllers', 'enum_user_types_and_roles-update_enum_user_types_and_roles') ); - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'operation' => 'enum_user_types_and_roles' @@ -237,14 +237,14 @@ public function testEnumUserTypesAndRoles() $this->assertEquals($expected, $actual, "Expected the actual results to equal the expected."); - $this->helper->logoutDashboard(); + $this->helper->logout(); } public function testSabUserEnumTgUsers() { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'start' => 0, @@ -300,12 +300,12 @@ function ($value) use ($expectedUser) { } } $this->assertEmpty($notFound, "There were expected users missing in actual (person_id is not actually checked and may be different).\nExpected: " . json_encode($notFound) . "\nActual: " . json_encode($actualUsers)); - $this->helper->logoutDashboard(); + $this->helper->logout(); } public function testCreateUser() { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $data = array( 'operation' => 'create_user', @@ -355,7 +355,7 @@ public function testCreateUser() $this->assertEquals($expectedMessage, $data['message'], "Expected the 'message' property to be: $expectedMessage Received: " . $data['message']); } - $this->helper->logoutDashboard(); + $this->helper->logout(); } /** @@ -363,7 +363,7 @@ public function testCreateUser() */ public function testModifyUser() { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $users = $this->listUsers(); @@ -413,7 +413,7 @@ function ($item) { $this->assertEquals($expectedStatus, $data['status'], "Expected the 'status' property to be: $expectedStatus Received: " . $data['status']); $this->assertEquals('bsmith', $data['username'], "Expected the 'username' property to be: $expectedUsername Received: " . $data['username']); $this->assertEquals($expectedUserType, $data['user_type'], "Expected the 'user_type' property to be $expectedUserType Received: " . $data['user_type']); - $this->helper->logoutDashboard(); + $this->helper->logout(); } /** @@ -421,7 +421,7 @@ function ($item) { */ public function testDeleteUser() { - $this->helper->authenticateDashboard('mgr'); + $this->helper->authenticate('mgr'); $users = $this->listUsers(); $user = array_values( @@ -455,7 +455,7 @@ function ($item) { $this->assertTrue($data['success'], "Expected the 'success' property to be: true Received: " . $data['success']); $this->assertEquals($expectedMessage, $data['message'], "Expected the 'message' property to be: $expectedMessage received: " . $data['message']); - $this->helper->logoutDashboard(); + $this->helper->logout(); } /** @@ -484,11 +484,7 @@ public function testEnumTargetAddresses(array $options) $helper = $options['helper']; $response = $helper->post("internal_dashboard/controllers/mailer.php", null, $data); - if (($expectedContentType !== $response[1]['content_type']) || ($expectedHttpCode !== $response[1]['http_code'])) { - echo var_export($response, true) . "\n"; - echo var_export($data, true) . "\n"; - echo "$expectedFileName\n"; - } + $this->assertEquals($expectedContentType, $response[1]['content_type']); $this->assertEquals($expectedHttpCode, $response[1]['http_code']); @@ -507,15 +503,11 @@ public function testEnumTargetAddresses(array $options) } $expected = JSON::loadFile($expectedFileName); - if ($expected !== $actual) { - echo var_export($response, true) . "\n"; - echo var_export($data, true) . "\n"; - echo "$expectedFileName\n"; - } + $this->assertEquals($expected, $actual); if (isset($options['last'])) { - $helper->logoutDashboard(); + $helper->logout(); } } @@ -528,7 +520,7 @@ public function provideEnumTargetAddresses() $data = JSON::loadFile(parent::getTestFiles()->getFile('controllers', 'enum_target_addresses-update_enum_user_types_and_roles', 'input')); $helper = new XdmodTestHelper(); - $helper->authenticateDashboard('mgr'); + $helper->authenticate('mgr'); foreach($data as $key => $test) { foreach($test[0]['data'] as $dataKey => $value) { diff --git a/tests/integration/lib/Controllers/ReportBuilderTest.php b/tests/integration/lib/Controllers/ReportBuilderTest.php index 7bf176f5f7..5b6fcab07a 100644 --- a/tests/integration/lib/Controllers/ReportBuilderTest.php +++ b/tests/integration/lib/Controllers/ReportBuilderTest.php @@ -135,9 +135,6 @@ public function testDownloadReportInputValidation($params, $expected) } } else { // expect text data back - if ('text/html; charset=UTF-8' !== $curlinfo['content_type']) { - echo var_export($response, true) . "\n"; - } $this->assertEquals('text/html; charset=UTF-8', $curlinfo['content_type']); $this->assertEquals($expected, $response[0]); } @@ -246,9 +243,7 @@ public function testEnumReports(array $options) } $actual = $response[0]; - if ($actual !== $expected) { - echo var_export($response, true) . "\n"; - } + $this->assertEquals($actual, $expected); if ($user !== 'pub') { @@ -758,11 +753,10 @@ private function reportImageRenderer(array $params) { $response = $this->helper->get('reports/builder/image', $params); + print_r($response); $this->log("Response Content-Type: [" . $response[1]['content_type'] . "]"); $this->log("Response HTTP-Code : [" . $response[1]['http_code'] . "]"); - if (('image/png' !== $response[1]['content_type']) || (200 !== $response[1]['http_code']) ) { - echo var_export($response, true) . "\n"; - } + $this->assertEquals('image/png', $response[1]['content_type']); $this->assertEquals(200, $response[1]['http_code']); } diff --git a/tests/integration/lib/Controllers/UsageExplorerTest.php b/tests/integration/lib/Controllers/UsageExplorerTest.php index cfd2e09ea5..1703f493f5 100644 --- a/tests/integration/lib/Controllers/UsageExplorerTest.php +++ b/tests/integration/lib/Controllers/UsageExplorerTest.php @@ -159,11 +159,7 @@ public function testSystemUsernameAccess() believe that you should be able to see this information, then please select "Submit Support Request" in the "Contact Us" menu to request access. EOF; - if (($response[1]['content_type'] !== 'application/json') || - ($response[1]['http_code'] !== 403) || - ($response[0]['message'] !== $expectedErrorMessage)) { - echo "\n" . var_export($response, true). "\n"; - } + $this->assertEquals($response[1]['content_type'], 'application/json'); $this->assertEquals($response[1]['http_code'], 403); $this->assertEquals($response[0]['message'], $expectedErrorMessage); @@ -388,10 +384,7 @@ public function testAggregateViewValidData($view, $expected) } $response = $this->helper->post('controllers/user_interface.php', null, $view); - if ((strpos($response[1]['content_type'], 'text/html; charset=UTF-8') === false) || - ($response[1]['http_code'] !== 200)) { - echo "\n" . var_export($response, true) . "\n"; - } + $this->assertNotFalse(strpos($response[1]['content_type'], 'text/html; charset=UTF-8')); $this->assertEquals($response[1]['http_code'], 200); @@ -422,10 +415,6 @@ public function testErrorBars($input, $expected) $this->markTestSkipped('Needs realm integration.'); } $response = $this->helper->post('controllers/user_interface.php', null, $input); - if ((strpos($response[1]['content_type'], 'text/html; charset=UTF-8') === false) || - ($response[1]['http_code'] !== 200)) { - echo "\n" . var_export($response, true) . "\n"; - } $this->assertNotFalse(strpos($response[1]['content_type'], 'text/html; charset=UTF-8')); $this->assertEquals($response[1]['http_code'], 200); @@ -505,14 +494,9 @@ public function testExport($chartConfig, $expectedMimeType, $expectedFinfo) } $response = $this->helper->post('controllers/user_interface.php', null, $chartConfig); - $actualContentType = $response[1]['content_type']; - - if (($response[1]['http_code'] !== 200) || - ($expectedMimeType !== $actualContentType) ) { - echo "\n" . var_export($response, true) . "\n"; - } $this->assertEquals($response[1]['http_code'], 200); + $actualContentType = $response[1]['content_type']; $this->assertEquals($expectedMimeType, $actualContentType); $actualFinfo = finfo_buffer(finfo_open(FILEINFO_MIME), $response[0]); diff --git a/tests/integration/lib/Controllers/UserAdminTest.php b/tests/integration/lib/Controllers/UserAdminTest.php index 0dc4751ba0..2e26bc2b87 100644 --- a/tests/integration/lib/Controllers/UserAdminTest.php +++ b/tests/integration/lib/Controllers/UserAdminTest.php @@ -30,7 +30,7 @@ public function testCreateUserFails(array $params, array $expected) $this->assertEquals($expected, $actual); - $this->helper->logoutDashboard(); + $this->helper->logout(); } /** @@ -159,7 +159,6 @@ public function provideCreateUserFails() public function testCreateUsersSuccess(array $user) { $userId = $this->createUser($user); - $expectedSuccess = isset($user['expected_success']) ? $user['expected_success'] : true; // if we received a userId back then let's go ahead and update the // users password so that it can be used to login in future tests. @@ -244,9 +243,10 @@ function ($filter) use ($personId) { } } } + $this->helper->authenticateDirect($username, $username); - $response = $this->helper->get('warehouse/quick_filters'); + $response = $this->helper->get('rest/v0.1/warehouse/quick_filters'); $this->validateResponse($response); $this->assertArrayHasKey('results', $response[0]); @@ -464,7 +464,7 @@ public function testGetDwDescripters($username) [ 'status_code' => 200, 'body_validator' => ( - MetricExplorerTest::getDwDescriptersBodyValidator($this) + MetricExplorerTest::getDwDescriptersBodyValidator($this) ) ] ); @@ -526,10 +526,7 @@ public function testGetUserVisits(array $options) $this->validateResponse($response, 200, $expectedContentType); - $actual = $response[0]; - if (is_string($response[0])) { - $actual = json_decode($response[0], true); - } + $actual = json_decode($response[0], true); $expected = JSON::loadFile( parent::getTestFiles()->getFile('user_admin', $expectedOutput, 'output') @@ -579,7 +576,7 @@ function ($key, $value) use ($expectedStat, $expectedDifferences, $actualDiffere } if (isset($options['last'])) { - $helper->logoutDashboard(); + $helper->logout(); } } @@ -612,7 +609,7 @@ public function testGetUserVisitsExport(array $options) ); $response = $helper->post("internal_dashboard/controllers/controller.php", null, $data); - $expectedContentType = $expectedSuccess ? 'application/xls' : 'application/json'; + $expectedContentType = $expectedSuccess ? 'application/xls' : 'text/html; charset=UTF-8'; $this->validateResponse($response, 200, $expectedContentType); @@ -638,14 +635,12 @@ public function testGetUserVisitsExport(array $options) } } else { // we expect the incoming data to be json formatted. - $actualLines = $response[0]; - if (is_string($response[0])) { - $actualLines = json_decode($response[0], true); - } + $actualLines = json_decode($response[0], true); foreach($actualLines as $key => $value) { $actual[] = array($key, $value); } } + $fileType = $expectedSuccess ? '.csv' : '.json'; $expectedFileName = parent::getTestFiles()->getFile('user_admin', $expectedOutput, 'output', $fileType); @@ -742,7 +737,7 @@ function ($key, $expectedRow) use ($actualRow, $ignoredColumns) { } if (isset($options['last'])) { - $helper->logoutDashboard(); + $helper->logout(); } } @@ -867,7 +862,7 @@ protected function getUserVisits(array $options) } } - $this->helper->logoutDashboard(); + $this->helper->logout(); return (int)$results; } diff --git a/tests/integration/lib/Rest/JobViewerTest.php b/tests/integration/lib/Rest/JobViewerTest.php index 2732ac0a65..fef7de5c51 100644 --- a/tests/integration/lib/Rest/JobViewerTest.php +++ b/tests/integration/lib/Rest/JobViewerTest.php @@ -110,9 +110,6 @@ public function testDimensionValues($xdmodhelper, $dimension) ); $response = $xdmodhelper->get(self::ENDPOINT . 'dimensions/' . $dimension, $queryparams); - if (200 !== $response[1]['http_code']) { - echo var_export($response, true); - } $this->assertEquals(200, $response[1]['http_code']); $resdata = $response[0]; @@ -182,9 +179,6 @@ private function validateSingleJobSearch($searchparams, $doAuth = true) } $result = $this->xdmodhelper->get(self::ENDPOINT . 'search/jobs', $searchparams); - if (false === $result[0]['success'] ){ - echo var_export($result, true) . "\n"; - } $this->assertArrayHasKey('success', $result[0]); $this->assertTrue($result[0]['success']); $this->assertArrayHasKey('results', $result[0]); diff --git a/tests/integration/lib/TestHarness/XdmodTestHelper.php b/tests/integration/lib/TestHarness/XdmodTestHelper.php index 4a669d37a4..1b71160f39 100644 --- a/tests/integration/lib/TestHarness/XdmodTestHelper.php +++ b/tests/integration/lib/TestHarness/XdmodTestHelper.php @@ -145,12 +145,6 @@ public function authenticate($userrole) $this->userrole = $userrole; $this->setauthvariables(null); $authresult = $this->post("rest/auth/login", null, $this->config['role'][$userrole]); - if (!is_array($authresult[0]) || !array_key_exists('results', $authresult[0])) { - echo "Invalid Authentication for $userrole\n"; - echo var_export($authresult, true) . "\n"; - echo "*****\n"; - echo var_export(array_keys($authresult[0]), true) . "\n"; - } $authtokens = $authresult[0]['results']; $this->setauthvariables($authtokens['token']); } @@ -232,53 +226,12 @@ public function authenticateSSO($parameters, $includeDefault = true) } } - /** - * Attempt to authenticate using the provided $userrole against XDMoD's - * internal dashboard. - * - * @param string $userrole the role you wish to authenticate as with the - * internal dashboard. - * @throws \Exception if the specified $userrole is not present in testing.json - */ - public function authenticateDashboard($userrole) - { - $this->authenticate($userrole); - /*if (! isset($this->config['role'][$userrole])) { - throw new \Exception("User role $userrole not defined in testing.json file"); - } - $this->userrole = $userrole; - $this->setauthvariables(null); - $data = array( - 'xdmod_username' => $this->config['role'][$userrole]['username'], - 'xdmod_password' => $this->config['role'][$userrole]['password'] - ); - $authresult = $this->post("internal_dashboard/user_check.php", null, $data); - $cookie = isset($authresult[2]['Set-Cookie']) ? $authresult[2]['Set-Cookie'] : null; - $this->setauthvariables('', $cookie);*/ - } - public function logout() { $this->post("rest/auth/logout", null, null); $this->setauthvariables(null); } - /** - * Attempt to execute the internal dashboard's logout action for the current - * session. - */ - public function logoutDashboard() - { - $this->post( - 'internal_dashboard/controllers/controller.php', - null, - array( - 'operation' => 'logout' - ) - ); - $this->setauthvariables(null); - } - private function docurl() { $this->responseHeaders = array(); diff --git a/tests/playwright/Docker/docker-compose.yml b/tests/playwright/Docker/docker-compose.yml index 3617c93cc1..eb7970ddf6 100644 --- a/tests/playwright/Docker/docker-compose.yml +++ b/tests/playwright/Docker/docker-compose.yml @@ -23,7 +23,6 @@ services: stdin_open: true tty: true ipc: host - init: true links: - xdmod depends_on: diff --git a/tests/playwright/lib/internal_dashboard.selectors.ts b/tests/playwright/lib/internal_dashboard.selectors.ts index 58c5986947..4c49ca3742 100644 --- a/tests/playwright/lib/internal_dashboard.selectors.ts +++ b/tests/playwright/lib/internal_dashboard.selectors.ts @@ -61,7 +61,16 @@ const selectors = { * @returns {string} */ col_for_user: function (username, column_name) { - return `(//div[contains(@class, "existing_user_grid")]//div[contains(@class,"x-grid3-body")]//table//td[count(preceding-sibling::td) + 1 = count(//div[contains(@class,"existing_user_grid")]//div[contains(@class,"x-grid3-header")]//table//td[.="${column_name}"]/preceding-sibling::td) + 1 ])[count(//div[contains(@class,"existing_user_grid")]//div[contains(@class,"x-grid3-body")]//table//td[.="${username}"]/preceding::div[contains(@class,"x-grid3-row")]) + 1]` + return `( + //div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-body")]//table//td[ + count(preceding-sibling::td) + 1 = + count(//div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-header")]//table//td[.="${column_name}"]/preceding-sibling::td) + 1 + ] + ) [ + count( + //div[contains(@class, "existing_user_grid")]//div[contains(@class, "x-grid3-body")]//table//td[.="${username}"]/preceding::div[contains(@class, 'x-grid3-row')] + ) + 1 + ]`; } } }, @@ -111,9 +120,6 @@ const selectors = { itemWithText: function (text) { return `${selectors.create_manage_users.current_users.settings.toolbar.actions.container}//span[.="${text}"]`; } - }, - details_header: function(user) { - return `${selectors.create_manage_users.current_users.settings.container}//span[contains(@class, "x-panel-header-text") and contains(text(), "${user}")]`; } }, inputByLabel: function (labelText, inputType) { @@ -188,9 +194,6 @@ const selectors = { userType: function () { return `${selectors.create_manage_users.new_user.container()}//input[contains(@class, "new_user_user_type")]`; }, - userTypeTrigger: function() { - return `${selectors.create_manage_users.new_user.userType()}/following-sibling::img[contains(@class, "x-form-trigger")]` - }, aclByName: function (name) { return `${selectors.create_manage_users.new_user.container()}//div[contains(@class, "admin_panel_section_role_assignment_n")]//table[contains(@class, "x-grid3-row-table")]//td[div="${name}"]/following-sibling::td//div[contains(@class, "x-grid3-cell-inner")]/div`; }, diff --git a/tests/playwright/lib/usageTab.page.ts b/tests/playwright/lib/usageTab.page.ts index c400fa68a1..e3b5ba1569 100644 --- a/tests/playwright/lib/usageTab.page.ts +++ b/tests/playwright/lib/usageTab.page.ts @@ -27,8 +27,8 @@ class Usage extends BasePage{ async selectTab(){ const xdmod = new XDMoD(this.page, this.page.baseUrl); await xdmod.selectTab('tg_usage'); - await expect(await this.chartLocator.count()).toBeGreaterThan(0); - await expect(this.maskLocator).toHaveCount(0); + await expect(this.chartLocator).toBeVisible(); + await expect(this.maskLocator).toBeHidden(); } /** @@ -120,9 +120,8 @@ class Usage extends BasePage{ * @return {Boolean} True if the node is expanded. */ async isTreeNodeExpanded(name){ - const unfoldTreeSelector = this.page.locator(selectors.unfoldTreeNodeByName(name)); - await expect(unfoldTreeSelector).toBeVisible(); - const unfoldTreeClass = await unfoldTreeSelector.getAttribute('class'); + const unfoldTreeSelector = selectors.unfoldTreeNodeByName(name); + const unfoldTreeClass = await this.page.getAttribute(unfoldTreeSelector, 'class'); return unfoldTreeClass.match(/[$ ]x-tree-node-plus[^ ]/) === null; } @@ -169,9 +168,7 @@ class Usage extends BasePage{ if (!check){ await this.expandTreeNode(topName); } - const treeNodeLocator = this.page.locator(selectors.treeNodeByPath(topName, childName)) - await expect(treeNodeLocator).toBeVisible(); - await treeNodeLocator.click(); + await this.page.locator(selectors.treeNodeByPath(topName, childName)).click(); } /** diff --git a/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts b/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts index e08ad40a65..051436a50b 100644 --- a/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts +++ b/tests/playwright/tests/internal_dashboard/internal_dashboard.spec.ts @@ -56,7 +56,7 @@ test.describe('Internal Dashboard Tests', async () => { await expect(page.locator(InternalDashboard.selectors.combo.container)).toBeVisible(); await expect(page.locator(InternalDashboard.selectors.combo.itemByText('Unknown'))).toBeVisible(); - await page.locator(InternalDashboard.selectors.combo.itemByText('Unknown')).click(); + await page.click(InternalDashboard.selectors.combo.itemByText('Unknown')); await expect(page.locator(InternalDashboard.selectors.combo.container)).toBeHidden(); const mapTo = await page.inputValue(InternalDashboard.selectors.create_manage_users.new_user.mapTo()); @@ -91,19 +91,8 @@ test.describe('Internal Dashboard Tests', async () => { await expect(page.locator(InternalDashboard.selectors.create_manage_users.new_user.aclByName('User'))).toHaveClass(/x-grid3-check-col-on/); }); - await test.step('Select User Type', async () => { - const userTypeTrigger = page.locator(InternalDashboard.selectors.create_manage_users.new_user.userTypeTrigger()); - await expect(userTypeTrigger).toBeVisible(); - await userTypeTrigger.click(); - - const externalComboOptionLocator = page.locator(InternalDashboard.selectors.combo.itemByText('External')); - await expect(externalComboOptionLocator).toBeVisible(); - await externalComboOptionLocator.click(); - await expect(externalComboOptionLocator).toBeHidden(); - }); - await test.step('Save User', async () => { - await page.locator(InternalDashboard.selectors.create_manage_users.buttons.create_user()).click(); + await page.click(InternalDashboard.selectors.create_manage_users.buttons.create_user()); await expect(page.locator(InternalDashboard.selectors.createSuccessNotification('btest'))).toBeVisible(); await expect(page.locator(InternalDashboard.selectors.createSuccessNotification('btest'))).toBeHidden(); @@ -195,7 +184,8 @@ test.describe('Internal Dashboard Tests', async () => { let dropDownValueSelector = InternalDashboard.selectors.combo.itemByText(setting.updated); let dropDownValue = page.locator(dropDownValueSelector); await expect(dropDownValue).toBeVisible(); - await dropDownValue.click(); + await page.click(dropDownValueSelector); + await expect(inputDropDown).toBeHidden(); } else if ('text' === setting.type) { const inputSelector = InternalDashboard.selectors.create_manage_users.current_users.settings.inputByLabel(setting.label, setting.type); @@ -235,24 +225,18 @@ test.describe('Internal Dashboard Tests', async () => { const displayedUserTypeSelector = InternalDashboard.selectors.create_manage_users.current_users.user_list.toolbar.buttonByLabel('Displaying', setting.original); const displayedUserType = page.locator(displayedUserTypeSelector); await expect(displayedUserType).toBeVisible(); - await displayedUserType.click(); + await page.click(displayedUserTypeSelector); const newUserTypeItemSelector = InternalDashboard.selectors.create_manage_users.current_users.user_list.dropDownItemByText(setting.updated); const newUserTypeItem = page.locator(newUserTypeItemSelector); await expect(newUserTypeItem).toBeVisible(); - await newUserTypeItem.click(); + await page.click(newUserTypeItemSelector); }); await test.step(`${setting.label}: Check that the user is listed in the Existing Users table`, async () => { await expect(page.locator(InternalDashboard.selectors.create_manage_users.current_users.user_list.col_for_user('btest'))).toBeVisible(); }); } else { - await test.step(`${setting.label}: Make sure that the user is selected`, async() => { - const userSelector = page.locator(InternalDashboard.selectors.create_manage_users.current_users.user_list.col_for_user('btest')); - await userSelector.click(); - const userDetailsLocator = page.locator(InternalDashboard.selectors.create_manage_users.current_users.settings.toolbar.details_header('Bob Test')); - await expect(userDetailsLocator).toBeVisible(); - }); await test.step(`${setting.label}: Check that ${setting.label} has been updated successfully to "${setting.updated}"`, async () => { const inputType = 'dropdown' === setting.type ? 'text' : setting.type; const selector = InternalDashboard.selectors.create_manage_users.current_users.settings.inputByLabel(setting.label, inputType); diff --git a/tests/regression/lib/Controllers/MetricExplorerChartsTest.php b/tests/regression/lib/Controllers/MetricExplorerChartsTest.php index 86990a871d..d3f01b76fb 100644 --- a/tests/regression/lib/Controllers/MetricExplorerChartsTest.php +++ b/tests/regression/lib/Controllers/MetricExplorerChartsTest.php @@ -26,7 +26,7 @@ public static function tearDownAfterClass(): void * use this function to print out the results from the api call. This * can be used to generate new expected test results. */ - /*private function output($chartData) + private function output($chartData) { $result = array( 'total' => $chartData['totalCount'], @@ -40,7 +40,7 @@ public static function tearDownAfterClass(): void ); } var_export($result); - }*/ + } /** * See the filterTestsProvider for instructions on how to generate diff --git a/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php b/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php index e266a67f52..ffa2d5ea8d 100644 --- a/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php +++ b/tests/unit/lib/ETL/DataEndpoint/WebServerLogFileTest.php @@ -53,7 +53,7 @@ public function testWebServerLogFile($filename, $logFormat, $expected) $endpoint->connect(); $numIterations = 0; foreach ($endpoint as $record) { - $this->arrays_are_same($expected[$numIterations], $record); + $this->assertEqualsCanonicalizing($expected[$numIterations], $record); $numIterations++; } $this->assertSame( @@ -111,28 +111,4 @@ public function provideWebServerLogFile() } return $tests; } - - private function arrays_are_same( array $left, array $right, bool $exact = true) - { - if (count(array_diff(array_keys($left), array_keys($right))) > 0) { - $this->fail('Keys are different'); - } - $differences = []; - foreach($left as $lkey => $lvalue) { - $ltype = gettype($lvalue); - $rtype = gettype($right[$lkey]); - if ($ltype !== $rtype) { - $differences []= sprintf("Expected $lkey to be %s got %s", $ltype, $rtype); - if ($exact && $lvalue !== $right[$lkey]) { - $differences []= sprintf("Expected $lkey value to be %s got %s", $lvalue, $right[$lkey]); - } - } - } - if (count($differences) > 0) { - $this->fail(sprintf( - "Differences Found:\n%s", - implode("\n", $differences) - )); - } - } } From 7ae5ef4fab716748bb1476b4942ba0c02adf4efb Mon Sep 17 00:00:00 2001 From: ryanrath Date: Mon, 12 Jan 2026 15:30:38 -0500 Subject: [PATCH 58/83] Sync efforts (#2139) * This is handle a deprecation * removing unneeded security config * not having this set is a deprecation * Cleaning up the authentication Controller * Updating doc blocks and removing unneeded logging * just adding comments on why the line was changed * Changes to make review less insanity inducing --- classes/Realm/Realm.php | 1 + config/packages/framework.yaml | 1 + config/packages/security.yaml | 22 - config/services.yaml | 6 - src/Controller/AuthenticationController.php | 172 ++----- src/Controller/BaseController.php | 14 +- src/Controller/MetricExplorerController.php | 289 +++++------ src/Controller/UserController.php | 287 ++++++----- src/Controller/WarehouseController.php | 513 +++++++++----------- 9 files changed, 577 insertions(+), 728 deletions(-) diff --git a/classes/Realm/Realm.php b/classes/Realm/Realm.php index aba378d976..583be7807d 100644 --- a/classes/Realm/Realm.php +++ b/classes/Realm/Realm.php @@ -388,6 +388,7 @@ private static function getSortedObjectList( $factoryClassName = sprintf('\\%s\\%s', __NAMESPACE__, $factoryClassName); } + // We are using the array format for a callable instead of a string due to the use of `static::` being deprecated w/ the string version. $factoryCallable = [$factoryClassName, 'factory']; if ('Realm' == $className) { // The Realm class already has the configuration and does not need it to be passed diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 5cdcf6bd63..8fd3e1ef19 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -3,6 +3,7 @@ framework: annotations: enabled: false error_controller: CCR\Errors\ErrorController + handle_all_throwables: true secret: '%env(APP_SECRET)%' #csrf_protection: true http_method_override: false diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 2d9f1d1528..c670b738af 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -26,15 +26,6 @@ security: invalidate_session: true access_denied_handler: CCR\Security\AccessDeniedHandler entry_point: CCR\Security\Authenticators\FormLoginAuthenticator - api: - lazy: true - provider: all_users - json_login: - check_path: /api/login - login_path: /api/login - logout: - path: api_logout - target: / # Easy way to control access for large sections of your site @@ -45,16 +36,3 @@ security: # - { path: ^/, roles: PUBLIC_ACCESS} # - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_US1ER } - -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 diff --git a/config/services.yaml b/config/services.yaml index 28d58f06e9..14f7914d99 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -63,12 +63,6 @@ services: $options: username_parameter: 'username' password_parameter: 'password' - CCR\EventListeners\LogoutListener: - tags: - - name: 'kernel.event_listener' - event: 'Symfony\Component\Security\Http\Event\LogoutEvent' - dispatcher: security.event_dispatcher.main - method: onLogout CCR\Errors\ErrorController: tags: [ 'controller.service_arguments' ] arguments: diff --git a/src/Controller/AuthenticationController.php b/src/Controller/AuthenticationController.php index c5e0d8399a..fa37db403e 100644 --- a/src/Controller/AuthenticationController.php +++ b/src/Controller/AuthenticationController.php @@ -7,6 +7,8 @@ use CCR\Security\Helpers\Tokens; use Exception; use Models\Services\JsonWebToken; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; use Symfony\Component\HttpFoundation\Cookie; @@ -14,6 +16,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; use Twig\Environment; @@ -22,100 +25,61 @@ /** * This controller handles the authentication routes for XDMoD. Please note that it works in conjunction with Symfony's * security framework, our customizations of which reside in `src/Security`. These customizations consist of - * UserProviders and Authenticators. Authenticators do what they say on the tin and are responsible for the actual - * authentication of users. UserProviders are responsible for identifying which user is logged in after they have been - * logged in. + * UserProviders and Authenticators. Authenticators are responsible for retrieving the credentials ( username / pasword, + * tokens, etc. ) from the request. UserProviders are responsible for identifying which user is logged in after they have been + * logged in. Symfony then compares these credentials against those retrieved by the UserProvider and if verified the + * user is logged in. */ class AuthenticationController extends BaseController { /** - * @var ContainerBagInterface + * If SSO is setup, this is the url that will be used to log into the configured Identity Provider. */ - private $parameters; - - private $ssoUrl; + private string $ssoUrl; /** * @param LoggerInterface $logger - * @param ContainerBagInterfaces $parameters + * @param ContainerBagInterface $parameters * @param Environment $twig * @param Tokens $tokenHelper + * @throws NotFoundExceptionInterface|ContainerExceptionInterface this is thrown if the `sso` section of `services.yaml` is not present. */ public function __construct(LoggerInterface $logger, ContainerBagInterface $parameters, Environment $twig, Tokens $tokenHelper) { $this->logger = $logger; - $this->parameters = $parameters; - $this->ssoUrl = $this->parameters->get('sso')['login_link']; + $this->ssoUrl = $parameters->get('sso')['login_link']; parent::__construct($logger, $twig, $tokenHelper); } /** - * This route is here so that we make sure the XDUser::postLogin function is called and that the users token is set - * in the appropriate location for use throughout the users session. The actual "login" process is handled by - * `src/Authenticators/FormLoginAuthenticator` with configuration located in `config/packages/security.yaml`. - * @return Response + * This route is here just to provide a route, no actual logging in is done here as evidenced by the only code + * present is the throwing of an Exception. The actual "login" process is handled by the Symfony Authentication + * process in conjunction with our custom Authenticators that are responsible for pulling / providing creds from a + * Request: + * - `src/Authenticators/FormLoginAuthenticator` + * - `src/Authenticators/SimpleSamlPhpAuthenticator` + * + * and our UserProviders that know how to lookup a user in our database: + * - `src/Security/UsernameUserProvider.php` + * + * This information is then combined and Symfony handles the validation of username / password hash etc. + * + * The configuration can be found in `config/packages/security.yaml`. + * + * @return NotFoundHttpException */ #[Route('{prefix}/login', name: 'xdmod_login', requirements: ['prefix' => '.*'], methods: ['POST'])] #[Route('/login', name: 'xdmod_new_login', methods: ['POST'])] - public function formLogin(): Response + public function login(): NotFoundHttpException { - $user = $this->getUser(); - - if (null === $user) { - $this->logger->error('No user found during login.'); - return $this->json([ - 'success' => false, - ], Response::HTTP_UNAUTHORIZED); - } - - // If for some reason we didn't get an \XDUser then fail fast. - // ( Honestly this is really here to make sure auto-complete works for $user ) - if (!($user instanceof \XDUser)) { - $this->logger->error('User instance type mismatched.'); - return $this->json([ - 'success' => false, - 'message' => 'User type mismatch' - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } - - try { - $user->postLogin(); - } catch (Exception $e) { - $this->logger->error( - sprintf( - 'An error has occurred during the post-login process for %s', - $user->getUsername() - ) - ); - return $this->json([ - 'success' => false, - 'message' => 'Error occurred during post login process.' - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } - - $token = $user->getToken(); - $response = $this->json([ - 'success' => true, - 'results' => [ - 'token' => $token, - 'name' => $user->getFormalName() - ] - ]); - - // This cookie will tell the HomeController that we have a currently logged in user. - $response->headers->setCookie(new Cookie('xdmod_token', $token)); - - $this->logger->info(sprintf('Successful login by %s', $user->getUsername())); - - return $response; + throw new NotFoundHttpException(); } /** * This route is responsible for any logic that may need to be executed when a user is logged out. Currently, the - * actual heavy lifting of logging out is done by the configuration in `config/packages/security.yaml`. - * - * + * actual heavy lifting of logging out is done by the configuration in `config/packages/security.yaml`. This function + * just ensures that we clean up our custom Session. * * @param Request $request * @return Response @@ -123,9 +87,8 @@ public function formLogin(): Response #[Route('/rest/logout', name: 'xdmod_logout', methods: ['POST', 'GET'])] #[Route('/logout', name: 'xdmod_new_logout', methods: ['POST'])] #[Route('/rest/auth/logout', name: 'xdmod_rest_auth_logout', methods: ['POST'])] - public function formLogout(Request $request): Response + public function logout(Request $request): Response { - $this->logger->error('*** FormLogout ***'); $token = $request->getSession()->get('xdmod_token'); \XDSessionManager::logoutUser($token); $request->getSession()->invalidate(); @@ -136,67 +99,8 @@ public function formLogout(Request $request): Response } /** - * This route is responsible for logging API users in. The configuration for this route is located in - * `config/packages/security.yaml`. - * - * @param Request $request - * - * @return Response - * @throws Exception - */ - #[Route('{prefix}/api/login', name: 'api_login', requirements: ['prefix' => '.*'], methods: ['POST'])] - public function login(Request $request): Response - { - $user = $this->getUser(); - - if (null === $user) { - return $this->json([ - 'message' => 'missing credentials' - ], Response::HTTP_UNAUTHORIZED); - } - - $xdUser = \XDUser::getUserByUserName($user->getUserIdentifier()); - - $xdUser->postLogin(); - - $request->getSession()->set('xdUser', $xdUser->getUserID()); - - - $response = $this->json([ - 'user' => $user->getUserIdentifier(), - 'token' => $xdUser->getToken() - ]); - - - // Make sure that we remove any xdmod_token cookie that already exists so that it can be set with the correct - // token. - $cookies = $response->headers->getCookies(); - foreach ($cookies as $cookie) { - if ($cookie->getName() === 'xdmod_token') { - $response->headers->removeCookie('xdmod_token'); - } - } - - $response->headers->setCookie(Cookie::create('xdmod_token', $xdUser->getToken(), 0, '/', '', true)); - - return $response; - } - - /** - * This Route is responsible for logging API Users out. - * - * @return Response + * Return an IDP redirect URL for SSO login * - * @throws Exception since this should never be called. - */ - #[Route('{prefix}/api/logout', name: 'api_logout', requirements: ['prefix' => '.*'], methods: ['POST'])] - public function logout(): Response - { - session_destroy(); - throw new Exception("Don't forget to activate logout."); - } - - /** * @param Request $request * * @return Response @@ -212,11 +116,19 @@ public function idpRedirect(Request $request): Response $value = "{$ssoUrl}?ReturnTo=$returnTo"; $request->getSession()->set('_security.main.target_path', $returnTo); } - $this->logger->debug('IDP Redirect', [$value]); return new Response($value, Response::HTTP_OK, ['Content-Type' => 'text/plain']); } + /** + * If a JupyterHub is configured, redirect to it with a new JSON Web Token in a cookie. + * + * @param Request $request + * @return RedirectResponse to the configured JupyterHub root if the user is + * authenticated, otherwise to the sign-in + * screen. + * @throws Exception if a JupyterHub is not configured. + */ #[Route('/jwt-redirect', methods: ['GET'])] public function redirectWithJwt(Request $request): Response { diff --git a/src/Controller/BaseController.php b/src/Controller/BaseController.php index 0922f4453b..f1fa887704 100644 --- a/src/Controller/BaseController.php +++ b/src/Controller/BaseController.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; @@ -84,14 +85,7 @@ public function authorize(Request $request, array $requiredAcls = [], bool $anyA { $user = $this->getXDUser($request->getSession()); - $this->logger->debug( - sprintf( - 'Attempting to authorize user: %s (%s) with requirements: %s', - $user->getUsername(), - var_export($user->getAclNames(), true), - var_export($requiredAcls, true) - ) - ); + // If role requirements were not given, then the only check to perform // is that the user is not a public user. $isPublicUser = $user->isPublicUser(); @@ -127,11 +121,11 @@ protected function getUserFromRequest(Request $request) } /** - * @param Session $session + * @param SessionInterface $session * @return XDUser * @throws Exception */ - protected function getXDUser(Session $session): XDUser + protected function getXDUser(SessionInterface $session): XDUser { $symfonyUser = $this->getUser(); if (!isset($symfonyUser)) { diff --git a/src/Controller/MetricExplorerController.php b/src/Controller/MetricExplorerController.php index c60b390550..696e214b9b 100644 --- a/src/Controller/MetricExplorerController.php +++ b/src/Controller/MetricExplorerController.php @@ -34,40 +34,10 @@ class MetricExplorerController extends BaseController private const DEFAULT_ERROR_MESSAGE = 'An error was encountered while attempting to process the requested authorization procedure.'; - /** - * - * @param Request $request - * @return Response - * @throws AccessDeniedException - * @throws SessionExpiredException - * @throws UnknownGroupByException - * @throws Exception - */ - #[Route('/controllers/metric_explorer.php', methods: ['POST', 'GET'])] - public function index(Request $request): Response - { - $operation = $this->getStringParam($request, 'operation', true); - switch ($operation) { - case 'get_data': - return $this->getData($request); - case 'get_dimension': - return $this->getDimensionValues($request); - case 'get_dw_descripter': - return $this->getDwDescriptors($request); - case 'get_filters': - return $this->getFilters($request); - case 'get_rawdata': - return $this->getRawData($request); - } - - return $this->json([ - 'success' => false, - 'message' => 'Unknown Operation provided.' - ]); - } /** + * Retrieve all of the queries that the requesting user has currently saved. * * @param Request $request * @return Response @@ -76,7 +46,6 @@ public function index(Request $request): Response #[Route('{prefix}/metrics/explorer/queries', requirements: ['prefix' => '.*'], methods: ['GET'])] public function getQueries(Request $request): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $action = 'getQueries'; $payload = [ 'success' => false, @@ -85,8 +54,8 @@ public function getQueries(Request $request): Response $statusCode = 401; try { - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if (isset($user) && $user instanceof XDUser) { + $user = $this->authorize($request); + if (isset($user)) { $queries = new \UserStorage($user, self::QUERIES_STORE); $data = $queries->get(); @@ -101,15 +70,19 @@ public function getQueries(Request $request): Response } else { $payload['message'] = self::DEFAULT_ERROR_MESSAGE; } - } catch (BadRequestException|HttpException|Exception $exception) { - $payload['message'] = $exception->getMessage(); - $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } catch (BadRequestHttpException|HttpException $e) { + $payload['message'] = $e->getMessage(); + $statusCode = $e->getStatusCode(); + } catch(Exception $e) { + $payload['message'] = $e->getMessage(); + $statusCode = 500; } return $this->json($payload, $statusCode); } /** + * Retrieve a query's information by unique id for the requesting user. * * @param Request $request * @param string $queryId @@ -118,7 +91,6 @@ public function getQueries(Request $request): Response #[Route('{prefix}/metrics/explorer/queries/{queryId}', requirements: ["queryId"=>"\w+", 'prefix' => '.*'], methods: ['GET'])] public function getQueryByid(Request $request, string $queryId): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $action = 'getQueryById'; $payload = array( 'success' => false, @@ -127,8 +99,8 @@ public function getQueryByid(Request $request, string $queryId): Response $statusCode = 401; try { - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if (isset($user) && $user instanceof XDUser) { + $user = $this->authorize($request); + if (isset($user)) { $queries = new \UserStorage($user, self::QUERIES_STORE); $query = $queries->getById($queryId); @@ -145,15 +117,19 @@ public function getQueryByid(Request $request, string $queryId): Response } else { $payload['message'] = self::DEFAULT_ERROR_MESSAGE; } - } catch (BadRequestException|HttpException|Exception $exception) { - $payload['message'] = $exception->getMessage(); - $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } catch (BadRequestHttpException|HttpException $e) { + $payload['message'] = $e->getMessage(); + $statusCode = $e->getStatusCode(); + } catch(Exception $e) { + $payload['message'] = $e->getMessage(); + $statusCode = 500; } return $this->json($payload, $statusCode); } /** + * Create a new query to be stored in the requesting users User Profile. * * @param Request $request * @return Response @@ -168,45 +144,47 @@ public function createQuery(Request $request): Response ); $statusCode = 401; try { - $data = $request->get('data', null); - if ($data === null) { - throw new BadRequestHttpException('data is a required parameter.'); - } - if ($this->getUser() !== null) { - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if (isset($user) && $user instanceof XDUser) { - $queries = new \UserStorage($user, self::QUERIES_STORE); - if (!is_string($data)) { - throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); - } - $data = is_string($data) ? json_decode($data, true) : $data; - $success = $queries->insert($data) != null; - $payload['success'] = $success; - if ($success) { - $payload['success'] = true; - $payload['data'] = $data; - $statusCode = 200; - } else { - $payload['message'] = 'Error creating chart. User is over the chart limit.'; - $statusCode = 500; - } + $user = $this->authorize($request); + if (isset($user)) { + $queries = new \UserStorage($user, self::QUERIES_STORE); + $data = $request->get('data'); + if ($data === null) { + throw new BadRequestHttpException('data is a required parameter.'); + } + if (!is_string($data)) { + throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); + } + $data = json_decode($data, true); + $success = $queries->insert($data) != null; + $payload['success'] = $success; + if ($success) { + $payload['success'] = true; + $payload['data'] = $data; + $statusCode = 200; + } else { + $payload['message'] = 'Error creating chart. User is over the chart limit.'; + $statusCode = 500; } } else { $payload['message'] = self::DEFAULT_ERROR_MESSAGE; } - } catch (BadRequestException|HttpException|Exception $exception) { - $payload['message'] = $exception->getMessage(); - if (get_class($exception) === 'Exception') { - $statusCode = 500; - } elseif (method_exists($exception, 'getStatusCode')) { - $statusCode = $exception->getStatusCode(); - } + } catch (BadRequestHttpException|HttpException $e) { + $payload['message'] = $e->getMessage(); + $statusCode = $e->getStatusCode(); + } catch (\Exception $e) { + $payload['message'] = $e->getMessage(); + $statusCode = 500; } return $this->json($payload, $statusCode); } /** + * Update the query identified by the provided 'id' parameter with the + * values of the following form params ( if provided ): + * - name + * - config + * - timestamp * * @param Request $request * @param string $queryId @@ -224,69 +202,60 @@ public function updateQueryById(Request $request, string $queryId): Response $statusCode = 401; try { - if ($this->getUser() === null) { - $payload['message'] = self::DEFAULT_ERROR_MESSAGE; - } else { - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if (isset($user) && $user instanceof XDUser) { - $this->logger->error(sprintf("Updating Query for: %s",$user->getUsername())); - $queries = new \UserStorage($user, self::QUERIES_STORE); - - $query = $queries->getById($queryId); - if (isset($query)) { - - $data = $request->get('data'); - - if (isset($data)) { - if (!is_string($data)) { - throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); - } - $jsonData = json_decode($data, true); - $name = isset($jsonData['name']) ? $jsonData['name'] : null; - $config = isset($jsonData['config']) ? $jsonData['config'] : null; - $ts = isset($jsonData['ts']) ? $jsonData['ts'] : microtime(true); - } else { - $name = $this->getStringParam($request, 'name'); - $config = $this->getStringParam($request, 'config'); - $ts = $this->getDateTimeFromUnixParam($request, 'ts'); - } + $user = $this->authorize($request); + if (isset($user)) { + $queries = new \UserStorage($user, self::QUERIES_STORE); - if (isset($name)) { - $query['name'] = $name; + $query = $queries->getById($queryId); + if (isset($query)) { + $data = $request->get('data'); + if (isset($data)) { + if (!is_string($data)) { + throw new BadRequestHttpException('Invalid value for data. Must be a(n) string.'); } + $jsonData = json_decode($data, true); + $name = isset($jsonData['name']) ? $jsonData['name'] : null; + $config = isset($jsonData['config']) ? $jsonData['config'] : null; + $ts = isset($jsonData['ts']) ? $jsonData['ts'] : microtime(true); + } else { + $name = $this->getStringParam($request, 'name'); + $config = $this->getStringParam($request, 'config'); + $ts = $this->getDateTimeFromUnixParam($request, 'ts'); + } - if (isset($config)) { - $query['config'] = $config; - } - if (isset($ts)) { - $query['ts'] = $ts; - } + if (isset($name)) { + $query['name'] = $name; + } + if (isset($config)) { + $query['config'] = $config; + } + if (isset($ts)) { + $query['ts'] = $ts; + } - $queries->upsert($queryId, $query); + $queries->upsert($queryId, $query); - // required for the UI to do it's thing. - $total = count($queries->get()); + // required for the UI to do it's thing. + $total = count($queries->get()); - // make sure everything is in place for returning to the - // front end. - $payload['total'] = $total; - $payload['success'] = true; - $statusCode = 200; - } else { - $payload['message'] = 'There was no query found for the given id'; - $statusCode = 404; - } + // make sure everything is in place for returning to the + // front end. + $payload['total'] = $total; + $payload['success'] = true; + $statusCode = 200; } else { - $payload['message'] = self::DEFAULT_ERROR_MESSAGE; + $payload['message'] = 'There was no query found for the given id'; + $statusCode = 404; } + } else { + $payload['message'] = self::DEFAULT_ERROR_MESSAGE; } - } catch (BadRequestException|HttpException|Exception $exception) { - $payload['message'] = $exception->getMessage(); - if (get_class($exception) === 'Exception') { - $statusCode = 500; - } elseif (method_exists($exception, 'getStatusCode')) { - $statusCode = $exception->getStatusCode(); - } + } catch (BadRequestHttpException|HttpException $e) { + $payload['message'] = $e->getMessage(); + $statusCode = $e->getStatusCode(); + } catch(\Exception $e) { + $payload['message'] = $e->getMessage(); + $statusCode = 500; } return $this->json($payload, $statusCode); @@ -301,8 +270,6 @@ public function updateQueryById(Request $request, string $queryId): Response #[Route('{prefix}/metrics/explorer/queries/{queryId}', requirements: ["queryId"=> "\w+", 'prefix' => '.*'], methods: ['DELETE'])] public function deleteQueryById(Request $request, string $queryId): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $action = 'deleteQueryById'; $payload = array( 'success' => false, @@ -312,15 +279,19 @@ public function deleteQueryById(Request $request, string $queryId): Response $statusCode = 401; try { - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if (isset($user) and $user instanceof XDUser) { + $user = $this->authorize($request); + if (isset($user)) { $queries = new \UserStorage($user, self::QUERIES_STORE); $query = $queries->getById($queryId); if (isset($query)) { + $before = count($queries->get()); $after = $queries->delById($queryId); $success = $before > $after; + + // make sure everything is in place for returning to the + // front end. $payload['success'] = $success; $payload['message'] = $success ? $payload['message'] : 'There was an error removing the query identified by: ' . $queryId; @@ -332,21 +303,17 @@ public function deleteQueryById(Request $request, string $queryId): Response } else { $payload['message'] = self::DEFAULT_ERROR_MESSAGE; } - } catch (BadRequestException|HttpException|Exception $exception) { - $payload['message'] = $exception->getMessage(); - $statusCode = (get_class($exception) === 'Exception') ? 500 : $exception->getStatusCode(); + } catch (BadRequestHttpException|HttpException $e) { + $payload['message'] = $e->getMessage(); + $statusCode = $e->getStatusCode(); + } catch (Exception $e) { + $payload['message'] = $e->getMessage(); + $statusCode = 500; } return $this->json($payload, $statusCode); } - - /** - * @param XDUser $user - * @param array $query - * @return void - * @throws Exception - */ private function removeRoleFromQuery(XDUser $user, array &$query) { // If the query doesn't have a config, stop. @@ -380,6 +347,48 @@ private function removeRoleFromQuery(XDUser $user, array &$query) $query['config'] = json_encode($queryConfig); } + /** + * This function is just here to allow us to support the original metric explorer html controller urls. Which + * functioned by referencing the same url `/controllers/metric_explorer.php` and including an `operation` parameter + * to differentiate which file to load. Specifically, this function replicates the following controller operations + * ( including the file that this endpoint was ported from ): + * - `get_data`: `html/controllers/metric_explorer/get_data.php` + * - `get_dimension`: `html/controllers/metric_explorer/get_dimension.php` + * - `get_dw_descripter`: `html/controllers/metric_explorer/get_dw_descripter.php` + * - `get_filters`: `html/controllers/metric_explorer/get_filters.php` + * + * @param Request $request + * @return Response + * @throws AccessDeniedException + * @throws SessionExpiredException + * @throws UnknownGroupByException + * @throws Exception + */ + #[Route('/controllers/metric_explorer.php', methods: ['POST', 'GET'])] + public function index(Request $request): Response + { + $operation = $this->getStringParam($request, 'operation', true); + + switch ($operation) { + case 'get_data': + return $this->getData($request); + case 'get_dimension': + return $this->getDimensionValues($request); + case 'get_dw_descripter': + return $this->getDwDescriptors($request); + case 'get_filters': + return $this->getFilters($request); + case 'get_rawdata': + return $this->getRawData($request); + } + + return $this->json([ + 'success' => false, + 'message' => 'Unknown Operation provided.' + ]); + } + + /** * * @param Request $request diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 1a18c37c85..2e3d9cfd3f 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -4,7 +4,6 @@ namespace CCR\Controller; use CCR\DB; -use Exception; use Models\Services\Acls; use Models\Services\Organizations; use Symfony\Component\HttpFoundation\Request; @@ -20,7 +19,6 @@ /** * */ -#[Route("{prefix}/users", requirements: ['prefix' => '.*'])] class UserController extends BaseController { @@ -57,136 +55,51 @@ class UserController extends BaseController ], ]; - /** - * - * - * @param Request $request - * @return Response - * @throws Exception - */ - #[Route('', methods: ['POST'])] - #[Route('/controllers/sab_user.php', name: 'list_users_legacy', methods: ['GET'])] - public function listUsers(Request $request): Response - { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - - $start = $this->getIntParam($request, 'start', true); - $limit = $this->getIntParam($request, 'limit', true); - $searchMode = $this->getStringParam($request, 'search_mode', true); - $piOnly = $this->getBooleanParam($request, 'pi_only', true); - $nameFilter = $this->getStringParam($request, 'query'); - $userManagement = $this->getBooleanParam($request, 'userManagement'); - $universityId = null; - $searchMethod = null; - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - if ($user->hasAcl(ROLE_ID_CAMPUS_CHAMPION) && !isset($userManagement)) { - $universityId = Acls::getDescriptorParamValue($user, ROLE_ID_CAMPUS_CHAMPION, 'provider'); - } - - switch ($searchMode) { - case 'formal_name': - $searchMethod = FORMAL_NAME_SEARCH; - break; - case 'username': - $searchMethod = USERNAME_SEARCH; - } - - $dataWarehouse = new XDWarehouse(); - - list($userCount, $users) = $dataWarehouse->enumerateGridUsers( - $searchMethod, - $start, - $limit, - $nameFilter, - $piOnly, - $universityId - ); - - $entryId = 0; - $userEntries = []; - foreach ($users as $currentUser) { - $entryId++; - - $personName = 'Invalid'; - $personId = -666; - switch ($searchMode) { - case 'formal_name': - $personName = $currentUser['long_name']; - $personId = $currentUser['id']; - break; - case 'username': - $personName = $currentUser['abusername']; - $personId = sprintf('%s;%s', $currentUser['id'], $currentUser['abusername']); - break; - } - $userEntries[] = [ - 'id' => $entryId, - 'person_id' => $personId, - 'person_name' => $personName - ]; - } - - return $this->json([ - 'success' => true, - 'status' => 'success', - 'message' => 'success', - 'total_user_count' => $userCount, - 'users' => $userEntries - ]); - } /** + * Get details for the current user. * * @param Request $request * @return Response - * @throws Exception + * @throws \Exception */ - #[Route("/current", name: "get_current_user", methods: ["GET"])] + #[Route("{prefix}/users/current", name: "get_current_user", requirements: ['prefix' => '.*'], methods: ["GET"])] public function getCurrentUser(Request $request) { $this->authorize($request); - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - - if ((!$user instanceof XDUser)) { - return $this->json([ - 'success' => false, - 'message' => 'Internal Error validating User' - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } - return $this->json([ 'success' => true, - 'results' => $this->extractUserData($user) + 'results' => $this->extractUserData(XDUser::getUserByUserName($this->getUser()->getUserIdentifier())) ]); } /** + * Update details about the current user. * * @param Request $request * @return Response - * @throws Exception if unable to look up an XDUser by the currently logged in user's id. + * @throws \Exception if unable to look up an XDUser by the currently logged in user's id. */ - #[Route("/current", name: "update_current_user", methods: ["PATCH"])] + #[Route("{prefix}/users/current", name: "update_current_user", requirements: ['prefix' => '.*'], methods: ["PATCH"])] public function updateCurrentUser(Request $request) { + // Ensure that the user is logged in. $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - - if ((!$user instanceof XDUser)) { - return $this->json([ - 'success' => false, - 'message' => 'Internal Error validating User' - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } + $this->authorize($request); + // Attempt to update the user's profile with the given information. $this->updateUser( - $user, + XDUser::getUserByUserName($this->getUser()->getUserIdentifier()), $this->extractUserSettableProperties($request) ); + // If the last step completed successfully, hide the welcome message + // for first-time XSEDE users and return a success message. + SessionSingleton::getSession()->set('suppress_profile_autoload', true); + return $this->json([ 'success' => true, 'message' => 'User profile updated successfully' @@ -203,9 +116,9 @@ public function updateCurrentUser(Request $request) * * @param Request $request * @return Response - * @throws Exception + * @throws \Exception */ - #[Route('/current/api/token', methods: ['GET'])] + #[Route('{prefix}/users/current/api/token', requirements: ['prefix' => '.*'], methods: ['GET'])] public function getCurrentAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -228,12 +141,11 @@ public function getCurrentAPIToken(Request $request): Response * - They just have authenticated to XDMoD via one of the supported methods. * - They must not have an existing API Token. * - * * @param Request $request * @return Response - * @throws Exception if there is a problem retrieving a database connection. + * @throws \Exception if there is a problem retrieving a database connection. */ - #[Route('/current/api/token', methods: ['POST'])] + #[Route('{prefix}/users/current/api/token', requirements: ['prefix' => '.*'], methods: ['POST'])] public function createAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -258,9 +170,9 @@ public function createAPIToken(Request $request): Response * * @param Request $request * @return Response - * @throws Exception + * @throws \Exception */ - #[Route('/current/api/token', methods: ['DELETE'])] + #[Route('{prefix}/users/current/api/token', requirements: ['prefix' => '.*'], methods: ['DELETE'])] public function revokeAPIToken(Request $request): Response { $user = $this->authorize($request); @@ -279,7 +191,125 @@ public function revokeAPIToken(Request $request): Response } // If the `revokeToken` failed for some reason then we let the user know. - throw new Exception('Unable to revoke API token.'); + throw new \Exception('Unable to revoke API token.'); + } + + /** + * This function is just here to allow us to support the original html controller urls. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + #[Route('/controllers/sab_user.php', name: 'list_users_legacy', methods: ["GET", "POST"])] + public function sabUser(Request $request): Response + { + $operation = $this->getStringParam($request, 'operation'); + + return match ($operation) { + 'enum_tg_users' => $this->listUsers($request), + + default => $this->json([ + 'status' => 'invalid_operation_specified', + 'success' => false, + 'totalCount' => 0, + 'message' => 'invalid_operation_specified', + 'data' => [] + ]), + }; + } + + /** + * This function is a port of `html/controllers/sab_users/enum_tg_users.php`. + * + * It's here as opposed to a SABUserController because the other two `sab_user` operations are not used. + * + * @param Request $request + * @return Response + * @throws \Exception + */ + private function listUsers(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + + try { + $start = $this->getIntParam($request, 'start', true); + $limit = $this->getIntParam($request, 'limit', true); + $searchMode = $this->getStringParam($request, 'search_mode', true); + $piOnly = $this->getBooleanParam($request, 'pi_only'); + } catch (\Exception $e) { + return $this->json([ + 'success' => false, + 'status' => 'invalid_params_specified', + 'message' => 'invalid_params_specified', + 'total_user_count' => 0 + ]); + } + + $dataWarehouse = new XDWarehouse(); + + $nameFilter = $this->getStringParam($request, 'query'); + + $universityId = null; + $userManagement = $this->getBooleanParam($request, 'userManagement'); + + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + + if ($user->hasAcl(ROLE_ID_CAMPUS_CHAMPION) && !isset($userManagement)) { + $universityId = Acls::getDescriptorParamValue($user, ROLE_ID_CAMPUS_CHAMPION, 'provider'); + } + + $searchMethod = null; + switch ($searchMode) { + case 'formal_name': + $searchMethod = FORMAL_NAME_SEARCH; + break; + case 'username': + $searchMethod = USERNAME_SEARCH; + } + + list($userCount, $users) = $dataWarehouse->enumerateGridUsers( + $searchMethod, + $start, + $limit, + $nameFilter, + $piOnly, + $universityId + ); + + $entryId = 0; + $userEntries = []; + foreach ($users as $currentUser) { + $entryId++; + + switch ($searchMode) { + case 'formal_name': + $personName = $currentUser['long_name']; + $personId = $currentUser['id']; + break; + case 'username': + $personName = $currentUser['abusername']; + $personId = $currentUser['id'] . ';' . $currentUser['absusername']; + break; + default: + $personName = 'Invalid'; + $personId = -666; + break; + } + $userEntries[] = [ + 'id' => $entryId, + 'person_id' => $personId, + 'person_name' => $personName + ]; + } + + return $this->json([ + 'success' => true, + 'status' => 'success', + 'message' => 'success', + 'total_user_count' => $userCount, + 'users' => $userEntries + ]); } @@ -290,7 +320,7 @@ public function revokeAPIToken(Request $request): Response * * @param XDUser $user The user object to extract data from. * @return array An associative array of data for the user. - * @throws Exception + * @throws \Exception */ private function extractUserData(XDUser $user) { @@ -337,10 +367,9 @@ function ($item) { private function extractUserSettableProperties(Request $request) { $requestProperties = array(); - $this->logger->debug('Getting User Settable Properties'); foreach (self::$userSettableProperties as $propertyName => $propertyType) { $propertyValue = $request->get($propertyName); - $this->logger->debug('Checking Property', [$propertyName, $propertyValue, $propertyType]); + if ($propertyValue === null) { continue; } @@ -356,7 +385,6 @@ private function extractUserSettableProperties(Request $request) } $requestProperties[$propertyName] = $propertyValue; } - $this->logger->debug('Returning user settable properties', [var_export($requestProperties, true)]); return $requestProperties; } @@ -367,7 +395,7 @@ private function extractUserSettableProperties(Request $request) * @param array $updatedProperties A mapping of properties to update * to their new values. * - * @throws Exception The new property values failed to save. + * @throws \Exception The new property values failed to save. */ private function updateUser(XDUser $user, array $updatedProperties) { @@ -375,18 +403,16 @@ private function updateUser(XDUser $user, array $updatedProperties) // given set of properties. If so, invoke that property's setter on the // given user with the given property value. foreach ($updatedProperties as $propertyName => $propertyValue) { - $this->logger->debug('Checking Update Property', [$propertyName, !array_key_exists($propertyName, self::$propertySettingOptions)]); if (!array_key_exists($propertyName, self::$propertySettingOptions)) { continue; } $propertyOptions = self::$propertySettingOptions[$propertyName]; - $this->logger->debug(sprintf('Calling %s w/ %s', $propertyOptions['setter'], $propertyValue)); + $user->{$propertyOptions['setter']}($propertyValue); } - $this->logger->debug('Saving User!'); + // Attempt to save the user's new details. This will throw an exception // if an error occurs. - $this->logger->debug('Updating User', [$user->getUserId(), $user->getUsername(), var_export($updatedProperties, true)]); $user->saveUser(); } @@ -396,7 +422,7 @@ private function updateUser(XDUser $user, array $updatedProperties) * * @param XDUser $user * @return bool true if the user does not already have a valid API token. - * @throws Exception if there is a problem retrieving a database connection. + * @throws \Exception if there is a problem retrieving a database connection. */ private function canCreateToken(XDUser $user) { @@ -423,8 +449,8 @@ private function canCreateToken(XDUser $user) * * @param XDUser $user whose token data should be retrieved. * @return array in the format array('created_on' => createdOn, 'expiration_date' => expirationDate) - * @throws Exception if there is a problem retrieving a db connection. - * @throws Exception if there is a problem executing the SELECT statement. + * @throws \Exception if there is a problem retrieving a db connection. + * @throws \Exception if there is a problem executing the SELECT statement. */ private function getCurrentAPITokenMetaData(XDUser $user) { @@ -438,7 +464,7 @@ private function getCurrentAPITokenMetaData(XDUser $user) $rows = $db->query($query, array(':user_id' => $user->getUserID())); if (count($rows) !== 1) { - throw new Exception('Invalid token data returned.'); + throw new \Exception('Invalid token data returned.'); } return array( @@ -455,9 +481,9 @@ private function getCurrentAPITokenMetaData(XDUser $user) * * @return array in the format ('token' => newToken, 'expiration_date' => tokenExpirationDate) * - * @throws Exception if unable to retrieve a database connection or if there is a problem generating a random token. - * @throws Exception if the api_token.expiration_interval configuration value ( in portal_settings.ini ) is not set. - * @throws Exception if inserting the newly generated token is unsuccessful. i.e. the number of rows inserted is < 1. + * @throws \Exception if unable to retrieve a database connection or if there is a problem generating a random token. + * @throws \Exception if the api_token.expiration_interval configuration value ( in portal_settings.ini ) is not set. + * @throws \Exception if inserting the newly generated token is unsuccessful. i.e. the number of rows inserted is < 1. */ private function createToken(XDUser $user) { @@ -477,7 +503,7 @@ private function createToken(XDUser $user) $createdOn = date_create()->format('Y-m-d H:m:s'); $expirationInterval = \xd_utilities\getConfiguration('api_token', 'expiration_interval'); if (empty($expirationInterval)) { - throw new Exception('Expiration Interval not provided.'); + throw new \Exception('Expiration Interval not provided.'); } $dateInterval = date_interval_create_from_date_string($expirationInterval); $expirationDate = date_add(date_create(), $dateInterval)->format('Y-m-d H:m:s'); @@ -493,7 +519,7 @@ private function createToken(XDUser $user) ); if ($result !== 1) { - throw new Exception('Unable to create a new API token.'); + throw new \Exception('Unable to create a new API token.'); } return array( @@ -507,8 +533,8 @@ private function createToken(XDUser $user) * * @param XDUser $user whose active token will be revoked. * @return bool true if 1 row was deleted else false. - * @throws Exception if there was a problem retrieving a database connection. - * @throws Exception if there was an error while executing the DELETE statement. + * @throws \Exception if there was a problem retrieving a database connection. + * @throws \Exception if there was an error while executing the DELETE statement. */ private function revokeToken(XDUser $user) { @@ -519,5 +545,4 @@ private function revokeToken(XDUser $user) return $rows === 1; } - } diff --git a/src/Controller/WarehouseController.php b/src/Controller/WarehouseController.php index 83953e3ef6..2b8447d8d9 100644 --- a/src/Controller/WarehouseController.php +++ b/src/Controller/WarehouseController.php @@ -200,9 +200,7 @@ class WarehouseController extends BaseController * * @param Request $request * @return Response - * @throws AccessDeniedException * @throws BadRequestHttpException - * @throws NotFoundHttpException */ #[Route('/warehouse/search/history', methods: ['GET'])] #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['GET'])] @@ -256,7 +254,7 @@ public function searchHistory(Request $request): Response * } * * @param Request $request - * @param int $id + * @param int $id of the Search History record to be retrieved. * @return Response * * @throws UnauthorizedHttpException|AccessDeniedHttpException|Exception @@ -266,10 +264,13 @@ public function searchHistory(Request $request): Response public function getHistoryById(Request $request, int $id): Response { $action = 'getHistoryById'; + $user = $this->authorize($request); + $realm = $this->getStringParam($request, 'realm', true); $searchHistory = $this->getUserStore($user, $realm); + $record = $searchHistory->getById($id); if (isset($record)) { foreach ($record['results'] as &$result) { @@ -323,6 +324,7 @@ private function getHistoryByTitle(XDUser $user, string $realm, string $title): * @param Request $request The request. * @return array decoded search parameters. * @throws MissingMandatoryParametersException If the required parameters are absent. + * @throws BadRequestHttpException if `data.text` is not present. */ private function getSearchParams(Request $request): array { @@ -359,11 +361,11 @@ public function createHistory(Request $request): Response $user = $this->authorize($request); $realm = $this->getStringParam($request, 'realm', true); - $recordId = $this->getIntParam($request, 'recordid'); $history = $this->getUserStore($user, $realm); $decoded = $this->getSearchParams($request); + $recordId = $this->getIntParam($request, 'recordid'); $created = is_numeric($recordId) ? $history->upsert($recordId, $decoded) : $history->insert($decoded); @@ -394,13 +396,9 @@ public function createHistory(Request $request): Response * Attempt to update the Search History Record identified by the provided * 'id' with the contents of the form parameter 'data'. * - * - * * @param Request $request that will be used to complete the requested operation * @param int $id of the Search History Record to be updated. - * * @return Response - * * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ #[Route('/warehouse/search/history/{id}', requirements: ["id" => '\d+'], methods: ['POST', 'PUT'])] @@ -438,9 +436,7 @@ public function updateHistory(Request $request, int $id): Response * * @param Request $request that will be used to complete the requested operation * @param int $id of the Search History Record to be removed. - * * @return Response - * * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ #[Route('/warehouse/search/history/{id}', requirements: ["id" => "\d+"], methods: ['DELETE'])] @@ -468,12 +464,8 @@ public function deleteHistory(Request $request, int $id): Response * Attempt to remove all of the Search History Records for the currently logged in * user making the request. * - * - * * @param Request $request - * * @return Response - * * @throws BadRequestHttpException|AccessDeniedHttpException|Exception */ #[Route('/warehouse/search/history', methods: ['DELETE'])] @@ -517,6 +509,11 @@ public function searchJobs(Request $request): Response $params = $this->getStringParam($request, 'params', true); $params = json_decode($params, true); + + if($params === null) { + throw new BadRequestHttpException('params parameter must be valid JSON'); + } + if ((isset($params['resource_id']) && isset($params['local_job_id'])) || isset($params['jobref'])) { return $this->getJobByPrimaryKey($user, $realm, $params); } else { @@ -556,13 +553,60 @@ public function searchJobsByAction(Request $request, string $action): Response return $this->processJobSearchByAction($request, $user, $action, $realm, $jobId, $actionName); } + /** + * Get the list of resources known to XDMoD and the metadata about them. + * Specifically for the Data Analytics Framework + * + * @param Request $request + * @return Response A response containing the following info: + * success: A boolean indicating if the call was successful. + * results: An object containing data about + * the dimensions retrieved. + * @throws Exception + */ + #[Route('/warehouse/resources', methods: ['GET'])] + #[Route('{prefix}/warehouse/resources', requirements: ['prefix' => '.*'], methods: ['GET'])] + public function getResources(Request $request): Response + { + $this->tokenHelper->authenticate($request); + + $config = \Configuration\XdmodConfiguration::assocArrayFactory('resource_metadata.json', CONFIG_DIR); + + $query_sql = $config['resource_query']; + $params = array(); + $wheres = array(); + + foreach ($config['where_conditions'] as $param => $wherecond) { + $value = $this->getStringParam($request, $param); + if ($value) { + $params[$param] = $value; + array_push($wheres, $wherecond); + } + } + + if (count($wheres) > 0) { + $query_sql .= " WHERE " . implode(" AND ", $wheres); + } + + $db = DB::factory('database'); + $stmt = $db->prepare($query_sql); + $stmt->execute($params); + + $resourceData = array(); + while ($result = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $resourceData[$result['resource_name']] = $result; + } + return $this->json(array( + 'success' => true, + 'results' => $resourceData + )); + } + /** * Get the realms available for the user's active role. * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. * * @return Response A response containing the following info: @@ -575,14 +619,9 @@ public function searchJobsByAction(Request $request, string $action): Response public function getRealms(Request $request): Response { /*TODO: verify that unauthorized users should be able to access this endpoint */ - $user = $this->getUser(); - if (null === $user) { - $user = XDUser::getPublicUser(); - } else { - $user = XDUser::getUserByUserName($user->getUserIdentifier()); - } + $user = $this->authorize($request); - // Get the realms for the query group and the user's active role. + // Get the realms for the user's active role. $realms = Realms::getRealmsForUser($user); // Return the realms found. @@ -595,8 +634,6 @@ public function getRealms(Request $request): Response /** * Return aggregate data from the datawarehouse * - * - * * @param Request $request The request used to make this call. * * @return Response @@ -685,8 +722,6 @@ public function getAggregateData(Request $request): Response * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. @@ -700,10 +735,9 @@ public function getDimensions(Request $request): Response { $user = $this->authorize($request); + // Get parameters. $realm = $this->getStringParam($request, 'realm'); - /*TODO: verify that this is what the expected exception is here.*/ - // Get the dimensions for the query group, realm, and user's active role. try { $groupBys = Acls::getQueryDescripters($user, $realm); @@ -730,6 +764,7 @@ public function getDimensions(Request $request): Response } } + // Return the dimensions found. return $this->json([ 'success' => true, 'results' => $dimensionsToReturn @@ -739,7 +774,7 @@ public function getDimensions(Request $request): Response /** * Get the dimension values available for the user's active role. * - * + * Ported from: classes/REST/DataWarehouse/Explorer.php * * @param Request $request The request used to make this call. * @param string $dimension @@ -762,10 +797,10 @@ public function getDimensionValues(Request $request, string $dimension): Respons $limit = $this->getIntParam($request, 'limit'); $searchText = $this->getStringParam($request, 'search_text'); - $realm = $this->getStringParam($request, 'realm'); + $realmParameter = $this->getStringParam($request, 'realm'); $realms = null; - if (null !== $realm) { - $realms = preg_split('/,\s*/', trim($realm), -1, PREG_SPLIT_NO_EMPTY); + if ($realmParameter !== null) { + $realms = preg_split('/,\s*/', trim($realmParameter), -1, PREG_SPLIT_NO_EMPTY); } // Get the dimension values. @@ -795,10 +830,7 @@ public function getDimensionValues(Request $request, string $dimension): Respons /** * Get a set of quick filters tailored to the current user. * - * - * * @param Request $request The request used to make this call. - * * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about @@ -892,6 +924,7 @@ public function getQuickFilters(Request $request): Response } } + // Return the quick filters. return $this->json([ 'success' => true, 'results' => [ @@ -904,8 +937,6 @@ public function getQuickFilters(Request $request): Response /** * Attempt to retrieve the the name for the provided dimensionId. * - * - * * @param Request $request * @param string $dimensionId * @@ -918,6 +949,7 @@ public function getDimensionName(Request $request, string $dimensionId): Respons { /*TODO: verify that this endpoint is for authorized users only. */ $user = $this->authorize($request); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); $dimensionName = MetricExplorer::getDimensionName($user, $dimensionId); $success = !empty($dimensionName); @@ -941,9 +973,8 @@ public function getDimensionName(Request $request, string $dimensionId): Respons } /** - * Attempt to retrieve the the name for the provided dimensionId and valueId. - * - * + * Attempt to retrieve the the name for the provided dimensionId and + * valueId. * * @param Request $request * @param string $dimensionId @@ -960,7 +991,9 @@ public function getDimensionName(Request $request, string $dimensionId): Respons )] public function getDimensionValueName(Request $request, string $dimensionId, string $valueId): Response { - $user = $this->authorize($request); + // TODO: verify that this should be accessible by unauthorized users. + // $user = $this->authorize($request); + $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); $valueName = MetricExplorer::getDimensionValueName($user, $dimensionId, $valueId); $success = !empty($valueName); @@ -988,15 +1021,11 @@ public function getDimensionValueName(Request $request, string $dimensionId, str * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. - * * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the available aggregation units. - * * @throws Exception */ #[Route('/warehouse/aggregation_units', methods: ['GET'])] @@ -1017,15 +1046,11 @@ public function getAggregationUnits(Request $request): Response * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. - * * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the available dataset types. - * * @throws Exception */ #[Route('/warehouse/dataset/types', methods: ['GET'])] @@ -1035,10 +1060,10 @@ public function getDatasetTypes(Request $request): Response // Return the available dataset types. $datasetTypes = \DataWarehouse\QueryBuilder::getDatasetTypes(); - return $this->json(array( + return $this->json([ 'success' => true, 'results' => $datasetTypes, - )); + ]); } /** @@ -1046,10 +1071,7 @@ public function getDatasetTypes(Request $request): Response * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. - * * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about @@ -1068,12 +1090,8 @@ public function getDatasetOutputFormats(Request $request): Response /** * Generate a dataset using the given parameters. * - * - * * @param Request $request The request used to make this call. - * * @return Response - * * @throws Exception */ #[Route('/datasets', methods: ['GET'])] @@ -1101,7 +1119,6 @@ public function getDatasets(Request $request): Response * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. @@ -1125,8 +1142,6 @@ public function getPlotOutputFormats(Request $request) * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. @@ -1151,8 +1166,6 @@ public function getPlotDisplayTypes(Request $request): Response * * Ported from: classes/REST/DataWarehouse/Explorer.php * - * - * * @param Request $request The request used to make this call. * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. @@ -1175,8 +1188,6 @@ public function getPlotCombineTypes(Request $request): Response /** * Generate a plot using the given parameters. * - * - * * @param Request $request The request used to make this call. * @return Response A response containing the following info * if JSON was requested: @@ -1225,13 +1236,12 @@ private function processJobSearch( $offset = $this->getIntParam($request, 'start', true); $limit = $this->getIntParam($request, 'limit', true); - $searchParams = json_decode( - $this->getStringParam($request, 'params', true), - true - ); + $searchParameterStr = $this->getStringParam($request, 'params', true); + + $searchParams = json_decode($searchParameterStr, true); if ($searchParams === null || !is_array($searchParams)) { - throw new BadRequestHttpException('params parameter must be valid JSON'); + throw new BadRequestHttpException('The params parameter must be a json object'); } $params = array_intersect_key($searchParams, $queryDescripters); @@ -1240,7 +1250,7 @@ private function processJobSearch( throw new BadRequestHttpException('Invalid search parameters specified in params object'); } else { $QueryClass = "\\DataWarehouse\\Query\\$realm\\RawData"; - $query = new $QueryClass($realm, 'day', $startDate, $endDate, null, '', []); + $query = new $QueryClass($realm, "day", $startDate, $endDate, null, "", array()); $allRoles = $user->getAllRoles(); $query->setMultipleRoleParameters($allRoles, $user); @@ -1252,7 +1262,7 @@ private function processJobSearch( $dataSet = new \DataWarehouse\Data\SimpleDataset($query); $raw = $dataSet->getResults($limit, $offset); - $data = []; + $data = array(); foreach ($raw as $row) { $resource = $row['resource']; $localJobId = $row['local_job_id']; @@ -1265,12 +1275,12 @@ private function processJobSearch( $total = $dataSet->getTotalPossibleCount(); $results = $this->json( - [ + array( 'success' => true, 'action' => $action, 'results' => $data, 'totalCount' => $total - ] + ) ); if ($total === 0) { @@ -1279,18 +1289,18 @@ private function processJobSearch( // need to rerun the query without the role params to see if any results come back. // note the data for the priviledged query is not returned to the user. - $privQuery = new $QueryClass('day', $startDate, $endDate, null, '', []); + $privQuery = new $QueryClass("day", $startDate, $endDate, null, "", array()); $privQuery->setRoleParameters($params); $privDataSet = new \DataWarehouse\Data\SimpleDataset($privQuery, 1, 0); $privResults = $privDataSet->getResults(); if (count($privResults) != 0) { $results = $this->json( - [ + array( 'success' => false, 'action' => $action, 'message' => 'Unable to complete the requested operation. Access Denied.' - ], + ), 401 ); } @@ -1389,7 +1399,7 @@ private function processJobSearchByAction( * @throws AccessDeniedException if the provided user does not have access to the specified realm. * @throws NotFoundHttpException if the provided jobId has no data in the provided realm. */ - private function getJobPeers(XDUser $user, string $realm, $jobId, int $start, int $limit): Response + protected function getJobPeers(XDUser $user, string $realm, $jobId, int $start, int $limit): Response { $jobdata = $this->getJobDataSet($user, $realm, $jobId, 'internal'); if (!$jobdata->hasResults()) { @@ -1425,7 +1435,7 @@ private function getJobPeers(XDUser $user, string $realm, $jobId, int $start, in 'ref' => array( 'realm' => $realm, 'jobid' => $jobId, - 'text' => $thisjob['resource'] . '-' . $thisjob['local_job_id'] + "text" => $thisjob['resource'] . '-' . $thisjob['local_job_id'] ) ) ); @@ -1448,11 +1458,11 @@ private function getJobPeers(XDUser $user, string $realm, $jobId, int $start, in } } - return $this->json([ + return $this->json(array( 'success' => true, - 'data' => [$result], + 'data' => array($result), 'total' => count($dataset->getResults()) - ]); + )); } /** @@ -1467,7 +1477,13 @@ private function getJobData(XDUser $user, string $realm, int $jobId, string $act { $dataSet = $this->getJobDataSet($user, $realm, $jobId, $action); - return $this->json(['data' => $dataSet->export(), 'success' => true]); + return $this->json( + array( + 'data' => $dataSet->export(), + 'success' => true + ), + 200 + ); } /** @@ -1481,7 +1497,7 @@ private function getJobData(XDUser $user, string $realm, int $jobId, string $act private function getJobDataSet(XDUser $user, string $realm, $jobId, string $action): RawDataset { if (!\DataWarehouse\Access\RawData::realmExists($user, $realm)) { - throw new AccessDeniedException(); + throw new AccessDeniedHttpException(); } $QueryClass = "\\DataWarehouse\\Query\\$realm\\JobDataset"; @@ -1497,7 +1513,7 @@ private function getJobDataSet(XDUser $user, string $realm, $jobId, string $acti $privilegedQuery = new $QueryClass($params, $action); $results = $privilegedQuery->execute(1); if ($results['count'] != 0) { - throw new AccessDeniedException(); + throw new AccessDeniedHttpException(); } } return $dataSet; @@ -1509,9 +1525,7 @@ private function getJobDataSet(XDUser $user, string $realm, $jobId, string $acti * @param XDUser $user the user that made this particular request. * @param string $realm the data realm in which this request was made. * @param ?int $jobId the unique identifier for the job. - * * @return Response - * * @throws Exception */ private function getJobExecutable(XDUser $user, string $realm, ?int $jobId): Response @@ -1541,7 +1555,7 @@ private function getJobExecutable(XDUser $user, string $realm, ?int $jobId): Res */ private function arrayToStore(array $values): array { - return [['key' => '.', 'value' => '', 'expanded' => true, 'children' => $this->atosRecurse($values)]]; + return array(array('key' => '.', 'value' => '', 'expanded' => true, 'children' => $this->atosRecurse($values))); } /** @@ -1550,19 +1564,14 @@ private function arrayToStore(array $values): array */ private function atosRecurse(array $values): array { - $result = []; + $result = array(); foreach ($values as $key => $value) { if (is_array($value)) { if (count($value) > 0) { - $result[] = [ - 'key' => "$key", - 'value' => '', - 'expanded' => true, - 'children' => $this->atosRecurse($value) - ]; + $result[] = array("key" => "$key", "value" => "", "expanded" => true, "children" => $this->atosRecurse($value)); } } else { - $result[] = ['key' => "$key", 'value' => $value, 'leaf' => true]; + $result[] = array("key" => "$key", "value" => $value, "leaf" => true); } } return $result; @@ -1592,18 +1601,18 @@ private function processJobNodeTimeSeriesRequest( throw new BadRequestHttpException("Node $infoId is a leaf"); } - $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoClass(); + $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoclass(); - $result = []; + $result = array(); foreach ($info->getJobTimeseriesMetricNodeMeta($user, $jobId, $tsId, $nodeId) as $cpu) { - $cpu['url'] = '/warehouse/search/jobs/timeseries'; - $cpu['type'] = 'timeseries'; - $cpu['dtype'] = 'cpuid'; + $cpu['url'] = "/warehouse/search/jobs/timeseries"; + $cpu['type'] = "timeseries"; + $cpu['dtype'] = "cpuid"; $result[] = $cpu; } - return $this->json(['success' => true, 'results' => $result]); + return $this->json(array("success" => true, "results" => $result)); } @@ -1628,18 +1637,20 @@ private function processJobTimeSeriesRequest( throw new BadRequestHttpException("Node $infoId is a leaf"); } - $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoClass(); + $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoclass(); - $result = []; + $result = array(); foreach ($info->getJobTimeseriesMetricMeta($user, $jobId, $tsId) as $node) { - $node['url'] = '/warehouse/search/jobs/timeseries'; - $node['type'] = 'timeseries'; - $node['dtype'] = 'node'; + $node['url'] = "/warehouse/search/jobs/timeseries"; + $node['type'] = "timeseries"; + + /*TODO: verify that this is node not nodeid*/ + $node['dtype'] = "node"; $result[] = $node; } - return $this->json(['success' => true, 'results' => $result]); + return $this->json(array("success" => true, "results" => $result)); } /** @@ -1659,32 +1670,32 @@ private function processJobRequest( switch ($infoId) { - case '' . \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE: + case "" . \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE: $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; $info = new $infoClass(); $result = []; foreach ($info->getJobTimeseriesMetaData($user, $jobId) as $tsid) { - $tsid['url'] = '/warehouse/search/jobs/vmstate'; - $tsid['type'] = 'timeseries'; - $tsid['dtype'] = 'tsid'; + $tsid['url'] = "/warehouse/search/jobs/vmstate"; + $tsid['type'] = "timeseries"; + $tsid['dtype'] = "tsid"; $result[] = $tsid; } - return $this->json(['success' => true, 'results' => $result]); + return $this->json(array("success" => true, "results" => $result)); case '' . \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS: $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; $info = new $infoClass(); $result = []; foreach ($info->getJobTimeseriesMetaData($user, $jobId) as $tsid) { - $tsid['url'] = '/warehouse/search/jobs/timeseries'; - $tsid['type'] = 'timeseries'; - $tsid['dtype'] = 'tsid'; + $tsid['url'] = "/warehouse/search/jobs/timeseries"; + $tsid['type'] = "timeseries"; + $tsid['dtype'] = "tsid"; $result[] = $tsid; } - return $this->json(['success' => true, 'results' => $result]); + return $this->json(array('success' => true, "results" => $result)); default: - throw new BadRequestHttpException('Node is a leaf'); + throw new BadRequestHttpException("Node is a leaf"); } } @@ -1710,11 +1721,11 @@ private function processJobByJobId( $data = array_intersect_key($this->supportedTypes, $jobMetaData); return $this->json( - [ + array( 'success' => true, 'action' => $action, 'results' => array_values($data) - ] + ) ); } @@ -1728,24 +1739,23 @@ private function processHistoryRequest(XDUser $user, string $realm, string $acti { $history = $this->getUserStore($user, $realm); $output = $history->get(); - - $results = []; + $results = array(); foreach ($output as $item) { - $results[] = [ + $results[] = array( 'text' => $item['text'], 'dtype' => 'recordid', 'recordid' => $item['recordid'], 'searchterms' => $item['searchterms'] - ]; + ); } return $this->json( - [ + array( 'success' => true, 'action' => $action, 'results' => $results, 'total' => count($results) - ] + ) ); } @@ -1756,26 +1766,26 @@ private function processHistoryRequest(XDUser $user, string $realm, string $acti */ private function processHistoryDefaultRealmRequest(XDUser $user, string $action): Response { - $results = []; + $results = array(); foreach (\DataWarehouse\Access\RawData::getRawDataRealms($user) as $realmConfig) { $history = $this->getUserStore($user, $realmConfig['name']); $records = $history->get(); if (!empty($records)) { - $results[] = [ + $results[] = array( 'dtype' => 'realm', 'realm' => $realmConfig['name'], 'text' => $realmConfig['display'] - ]; + ); } } return $this->json( - [ + array( 'success' => true, 'action' => $action, 'results' => $results - ] + ) ); } @@ -1785,7 +1795,7 @@ private function processHistoryDefaultRealmRequest(XDUser $user, string $action) */ private function encodeFloatArray(array $in): array { - $out = []; + $out = array(); foreach ($in as $key => $value) { if (is_float($value) && is_nan($value)) { $out[$key] = 'NaN'; @@ -1804,52 +1814,34 @@ private function encodeFloatArray(array $in): array */ private function getJobSummary(XDUser $user, string $realm, int $jobId): Response { - $queryClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $query = new $queryClass(); + $queryclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $query = new $queryclass(); - $jobSummary = $query->getJobSummary($user, $jobId); + $jobsummary = $query->getJobSummary($user, $jobId); - $result = []; + $result = array(); // Really this should be a recursive function! - foreach ($jobSummary as $key => $val) { + foreach ($jobsummary as $key => $val) { $name = "$key"; if (is_array($val)) { if (array_key_exists('avg', $val) && !is_array($val['avg'])) { - $result[] = array_merge( - [ - 'name' => $name, - 'leaf' => true - ], - $this->encodeFloatArray($val) - ); + $result[] = array_merge(array("name" => $name, "leaf" => true), $this->encodeFloatArray($val)); } else { - $l1data = ['name' => $name, 'avg' => '', 'expanded' => 'true', 'children' => []]; - foreach ($val as $subkey => $subVal) { + $l1data = array("name" => $name, "avg" => "", "expanded" => "true", "children" => array()); + foreach ($val as $subkey => $subval) { $subName = "$subkey"; - if (is_array($subVal)) { - if (array_key_exists('avg', $subVal) && !is_array($subVal['avg'])) { - $l1data['children'][] = array_merge( - [ - 'name' => $subName, - 'leaf' => true - ], - $this->encodeFloatArray($subVal) - ); + if (is_array($subval)) { + if (array_key_exists('avg', $subval) && !is_array($subval['avg'])) { + $l1data['children'][] = array_merge(array("name" => $subName, "leaf" => true), $this->encodeFloatArray($subval)); } else { - $l2data = ['name' => $subName, 'avg' => '', 'expanded' => 'true', 'children' => []]; - - foreach ($subVal as $subSubKey => $subSubVal) { - $subSubName = "$subSubKey"; - if (is_array($subSubVal)) { - if (array_key_exists('avg', $subSubVal) && !is_array($subSubVal['avg'])) { - $l2data['children'][] = array_merge( - [ - 'name' => $subSubName, - 'leaf' => true - ], - $this->encodeFloatArray($subSubVal) - ); + $l2data = array("name" => $subName, "avg" => "", "expanded" => "true", "children" => array()); + + foreach ($subval as $subsubkey => $subsubval) { + $subSubName = "$subsubkey"; + if (is_array($subsubval)) { + if (array_key_exists('avg', $subsubval) && !is_array($subsubval['avg'])) { + $l2data['children'][] = array_merge(array("name" => $subSubName, "leaf" => true), $this->encodeFloatArray($subsubval)); } } } @@ -1881,7 +1873,7 @@ private function chartDataResponse(array $data): Response $filename = tempnam(sys_get_temp_dir(), 'xdmod'); $fp = fopen($filename, 'w'); - $columns = ['Time']; + $columns = array('Time'); $numberOfDataPoints = 0; foreach ($data['series'] as $series) { if (isset($series['dtype'])) { @@ -1894,7 +1886,7 @@ private function chartDataResponse(array $data): Response fputcsv($fp, $columns); for ($i = 0; $i < $numberOfDataPoints; $i++) { - $outline = []; + $outline = array(); foreach ($data['series'] as $series) { if (isset($series['dtype'])) { if (count($outline) === 0) { @@ -1948,15 +1940,7 @@ private function chartImageResponse(array $data, string $type, array $settings): 'timezone' => $data['schema']['timezone'] ); - $chartImage = \xd_charting\exportChart( - $chartConfig, - $settings['width'], - $settings['height'], - $settings['scale'], - $type, - $globalConfig, - $settings['fileMetadata'] - ); + $chartImage = \xd_charting\exportChart($chartConfig, $settings['width'], $settings['height'], $settings['scale'], $type, $globalConfig, $settings['fileMetadata']); $chartFilename = $settings['fileMetadata']['title'] . '.' . $type; $mimeOverride = $type == 'svg' ? 'image/svg+xml' : null; @@ -1984,10 +1968,9 @@ private function getJobTimeSeriesData( ?int $cpuId ): Response { - $infoClass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; - $info = new $infoClass(); - - $results = $info->getJobTimeseriesData($user, $jobId, $tsId, $nodeId, $cpuId, $this->logger); + $infoclass = "\\DataWarehouse\\Query\\$realm\\JobMetadata"; + $info = new $infoclass(); + $results = $info->getJobTimeseriesData($user, $jobId, $tsId, $nodeId, $cpuId); if (count($results) === 0) { throw new NotFoundHttpException('The requested resource does not exist'); @@ -1995,28 +1978,26 @@ private function getJobTimeSeriesData( $format = $this->getStringParam($request, 'format', false, 'json'); - if (!in_array($format, ['json', 'png', 'svg', 'pdf', 'csv'])) { + if (!in_array($format, array('json', 'png', 'svg', 'pdf', 'csv'))) { throw new BadRequestHttpException('Unsupported format type.'); } - $subject = $results['schema']['source'] ?? ''; - $title = $results['schema']['description'] ?? ''; switch ($format) { case 'png': case 'pdf': case 'svg': - $exportConfig = [ + $exportConfig = array( 'width' => $this->getIntParam($request, 'width', false, 916), 'height' => $this->getIntParam($request, 'height', false, 484), 'scale' => floatval($this->getStringParam($request, 'scale', false, '1')), 'font_size' => $this->getIntParam($request, 'font_size', false, 3), 'show_title' => $this->getStringParam($request, 'show_title', false, 'y') === 'y', - 'fileMetadata' => [ + 'fileMetadata' => array( 'author' => $user->getFormalName(), - 'subject' => 'Timeseries data for ' . $subject, - 'title' => $title - ] - ]; + 'subject' => 'Timeseries data for ' . $results['schema']['source'] ?? '', + 'title' => $results['schema']['description'] ?? '' + ) + ); $response = $this->chartImageResponse($results, $format, $exportConfig); break; case 'csv': @@ -2024,7 +2005,7 @@ private function getJobTimeSeriesData( break; case 'json': default: - $response = $this->json(['success' => true, 'data' => [$results]]); + $response = $this->json(array("success" => true, "data" => array($results))); break; } @@ -2039,31 +2020,31 @@ private function getJobTimeSeriesData( * * @param XDUser $user * @param string $realm - * @param array $searchParams + * @param array $searchparams * @return Response * @throws AccessDeniedException if the provided user does not have access to the provided realm. */ - private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchParams): Response + private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchparams): Response { if (!\DataWarehouse\Access\RawData::realmExists($user, $realm)) { - throw new AccessDeniedException(); - } - - if (isset($searchParams['jobref']) && is_numeric($searchParams['jobref'])) { - $params = [ - 'primary_key' => $searchParams['jobref'] - ]; - } elseif (isset($searchParams['resource_id']) && isset($searchParams['local_job_id'])) { - $params = [ - 'resource_id' => $searchParams['resource_id'], - 'job_identifier' => $searchParams['local_job_id'] - ]; + throw new AccessDeniedHttpException(); + } + + if (isset($searchparams['jobref']) && is_numeric($searchparams['jobref'])) { + $params = array( + 'primary_key' => $searchparams['jobref'] + ); + } elseif (isset($searchparams['resource_id']) && isset($searchparams['local_job_id'])) { + $params = array( + 'resource_id' => $searchparams['resource_id'], + 'job_identifier' => $searchparams['local_job_id'] + ); } else { throw new BadRequestHttpException('invalid search parameters'); } $QueryClass = "\\DataWarehouse\\Query\\$realm\\JobDataset"; - $query = new $QueryClass($params, 'brief'); + $query = new $QueryClass($params, "brief"); $allRoles = $user->getAllRoles(); $query->setMultipleRoleParameters($allRoles, $user); @@ -2072,13 +2053,13 @@ private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchPa $results = array(); foreach ($dataSet->getResults() as $result) { - $result['text'] = $result['resource'] . '-' . $result['local_job_id']; + $result['text'] = $result['resource'] . "-" . $result['local_job_id']; $result['dtype'] = 'jobid'; $results[] = $result; } if (!$dataSet->hasResults()) { - $privilegedQuery = new $QueryClass($params, 'brief'); + $privilegedQuery = new $QueryClass($params, "brief"); $privilegedResults = $privilegedQuery->execute(1); if ($privilegedResults['count'] != 0) { @@ -2087,11 +2068,11 @@ private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchPa } return $this->json( - [ + array( 'success' => true, 'results' => $results, 'totalCount' => count($results) - ] + ) ); } @@ -2102,13 +2083,7 @@ private function getJobByPrimaryKey(XDUser $user, string $realm, array $searchPa */ private function getUserStore(XDUser $user, string $realm): UserStorage { - $container = implode( - '-', - array_filter([ - self::HISTORY_STORE_KEY, - strtoupper($realm) - ]) - ); + $container = implode('-', array_filter(array(self::HISTORY_STORE_KEY, strtoupper($realm)))); return new UserStorage($user, $container); } @@ -2129,23 +2104,23 @@ private function getUserStore(XDUser $user, string $realm): UserStorage * given dimensions match one of the corresponding given values. * - offset: starting row index of data to get. * - * If successful, the response will be a JSON text sequence. The first line - * will be an array containing the `display` property of each obtained - * field. Subsequent lines will be arrays containing the obtained field - * values for each record. - * - * + * If successful, the response will be a stream of chunks of data of type + * `text/plain`. The beginning of each chunk is a string of hex digits + * indicating the size of the chunk data in octets, followed by `\\r\\n`, + * followed by the chunk data, followed by another `\\r\\n`. The first + * chunk contains an array that contains the `display` property of each + * obtained field. Each subsequent chunk contains an array that contains + * the obtained field values for the next row of raw data. The final chunk + * is of length zero to indicate the end of the stream. * * @param Request $request - * * @return StreamedResponse - * * @throws BadRequestHttpException if any of the required parameters are - * not included; if an invalid start date, - * end date, realm, field alias, or filter - * key is provided; if the end date is - * before the start date; or if the offset - * is negative. + * not included; if an invalid start date, + * end date, realm, field alias, or filter + * key is provided; if the end date is + * before the start date; or if the offset + * is negative. * @throws AccessDeniedException if the user does not have permission to * get raw data from the requested realm. * @throws Exception @@ -2158,36 +2133,32 @@ public function getRawData(Request $request): Response /*TODO: Validate that this is supposed to be here. */ if ($user === null) { - $this->logger->error('Unable to authenticate user by token'); return $this->json(buildError(new Exception('No token provided.')), 401, [ 'WWW-Authenticate' => 'Bearer' ]); } + try { $params = $this->validateRawDataParams($request, $user); } catch (HttpException $e) { - $this->logger->error('Unable to validate parameters'); return $this->json(buildError($e), $e->getStatusCode()); } $realmManager = new RealmManager(); $queryClass = $realmManager->getRawDataQueryClass($params['realm']); $logger = $this->getRawDataLogger(); - $this->logger->debug('Have everything, beginning to stream!'); $streamCallback = function () use ( $user, $params, $queryClass, $logger ) { - $logger->debug('Streaming Starting!'); $reachedOffset = false; $i = 1; $offset = $params['offset']; // Jobs realm has a performance improvement by querying one day at // a time. if ('Jobs' === $params['realm']) { - $logger->debug('Streaming Jobs realm Data'); $currentDate = $params['start_date']; while ($currentDate <= $params['end_date']) { $this->echoRawData( @@ -2209,7 +2180,6 @@ public function getRawData(Request $request): Response ); } } else { - $logger->debug('Streaming other realms'); // All other realms query the entire date range in a single // query. $this->echoRawData( @@ -2231,49 +2201,7 @@ public function getRawData(Request $request): Response return new StreamedResponse($streamCallback, 200, ['Content-Type' => 'application/json-seq']); } - /** - * Specifically for the Data Analytics Framework - * - * @param Request $request - * @return Response - */ - #[Route('/warehouse/resources', methods: ['GET'])] - #[Route('{prefix}/warehouse/resources', requirements: ['prefix' => '.*'], methods: ['GET'])] - public function getResources(Request $request): Response - { - $this->tokenHelper->authenticate($request); - - $config = \Configuration\XdmodConfiguration::assocArrayFactory('resource_metadata.json', CONFIG_DIR); - - $query_sql = $config['resource_query']; - $params = array(); - $wheres = array(); - - foreach ($config['where_conditions'] as $param => $wherecond) { - $value = $this->getStringParam($request, $param); - if ($value) { - $params[$param] = $value; - array_push($wheres, $wherecond); - } - } - - if (count($wheres) > 0) { - $query_sql .= " WHERE " . implode(" AND ", $wheres); - } - - $db = DB::factory('database'); - $stmt = $db->prepare($query_sql); - $stmt->execute($params); - $resourceData = array(); - while ($result = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $resourceData[$result['resource_name']] = $result; - } - return $this->json(array( - 'success' => true, - 'results' => $resourceData - )); - } /** * Validate the parameters of the request from the given user to the raw @@ -2291,7 +2219,6 @@ private function validateRawDataParams($request, $user): array list( $params['start_date'], $params['end_date'] ) = $this->validateRawDataDateParams($request); - $params['realm'] = $this->getStringParam($request, 'realm', true); $allRealmNames = self::getRealmNames(Realms::getRealms()); if (!in_array($params['realm'], $allRealmNames)) { @@ -2308,7 +2235,6 @@ private function validateRawDataParams($request, $user): array 'The requested realm is not configured to provide raw data.' ); } - $queryDescripters = Acls::getQueryDescripters($user, $params['realm']); if (empty($queryDescripters)) { throw new AccessDeniedException( @@ -2532,7 +2458,10 @@ private function getRawDataFieldsArray(Request $request): ?array /** * Validate the optional `filters` parameter of the given request to the - * raw data endpoint (@param Request $request + * raw data endpoint (@see getRawData()), e.g., the parameter + * `filters[foo]=bar,baz` results in `['foo' => ['bar', 'baz']]`. + * + * @param Request $request * @param array $queryDescripters the set of dimensions the user is * authorized to see based on their assigned * ACLs. @@ -2568,7 +2497,7 @@ private function validateRawDataFiltersParams(Request $request, array $queryDesc * dimension does not match any of the provided values. * * @param RawQuery $query - * @param array $params containing a 'filters' key whose value is an + * @param array $params containing a `filters` key whose value is an * associative array of dimensions and dimension * values. * @return RawQuery the query with the filters applied. @@ -2595,7 +2524,11 @@ private function setRawDataQueryFilters(RawQuery $query, array $params): RawQuer /** * Validate a specific filter from the `filters` parameter of a request to - * the raw data endpoint (@param array $queryDescripters the set of dimensions the user is + * the raw data endpoint (@see getRawData()), and return the parsed array + * of values for that filter (e.g., `foo,bar,baz` becomes `['foo', 'bar', + * 'baz']`). + * + * @param array $queryDescripters the set of dimensions the user is * authorized to see based on their assigned * ACLs. * @param string $filterKey the label of a dimension. @@ -2614,7 +2547,9 @@ private function validateRawDataFilterParam( ): array { if (!in_array($filterKey, array_keys($queryDescripters))) { - throw new BadRequestHttpException('Invalid filter key \'' . $filterKey . '\'.', null); + throw new BadRequestHttpException( + 'Invalid filter key \'' . $filterKey . '\'.', null + ); } return explode(',', $filterValuesStr); } From 47506c7ec8e0821a9415344bcfbfa5d94134a575 Mon Sep 17 00:00:00 2001 From: Andrew Stoltman <60742416+aestoltm@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:20:26 -0500 Subject: [PATCH 59/83] Simplify chart exporting (#2127) (#2140) * Simplify the export process for XDMoD. Also fixes a bug with timezones not being considered for image export --- html/gui/js/PlotlyChartWrapper.js | 6 +- html/plotly_template.html | 112 +++++++----------------------- 2 files changed, 30 insertions(+), 88 deletions(-) diff --git a/html/gui/js/PlotlyChartWrapper.js b/html/gui/js/PlotlyChartWrapper.js index 5473cdd448..18df5fc413 100644 --- a/html/gui/js/PlotlyChartWrapper.js +++ b/html/gui/js/PlotlyChartWrapper.js @@ -20,7 +20,8 @@ XDMoD.utils.createChart = function (chartOptions, extraHandlers) { displayModeBar: false, doubleClick: 'reset', doubleClickDelay: 500, - showAxisRangeEntryBoxes: false + showAxisRangeEntryBoxes: false, + staticPlot: chartOptions.isExport }; XDMoD.utils.deepExtend(baseChartOptions, chartOptions); const isEmpty = (!baseChartOptions.data) || (baseChartOptions.data && baseChartOptions.data.length === 0); @@ -61,6 +62,7 @@ XDMoD.utils.createChart = function (chartOptions, extraHandlers) { if (baseChartOptions.data[0].type === 'pie') { baseChartOptions.layout.pieChart = true; + baseChartOptions.layout.margin.t += 30; } if (!baseChartOptions.credits && baseChartOptions.credits === false) { @@ -133,7 +135,7 @@ XDMoD.utils.createChart = function (chartOptions, extraHandlers) { return; } - const update = relayoutChart(chartDiv, baseChartOptions.layout.height, true); + const update = relayoutChart(chartDiv, baseChartOptions.layout.height, true, baseChartOptions.isExport); Plotly.relayout(baseChartOptions.renderTo, update); }); diff --git a/html/plotly_template.html b/html/plotly_template.html index 803d7e3416..20388b4c36 100644 --- a/html/plotly_template.html +++ b/html/plotly_template.html @@ -7,23 +7,22 @@ - - - - - - - - - +
    - + + + + + + + + From 4a23559ecf3add2171db5c722b7a841550ee00b5 Mon Sep 17 00:00:00 2001 From: ryanrath Date: Tue, 20 Jan 2026 10:14:45 -0500 Subject: [PATCH 60/83] Fixing deprecated string interpolation (#2148) --- bin/xdmod-admin | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/xdmod-admin b/bin/xdmod-admin index b75a9d9b9e..ce5ed80dc0 100755 --- a/bin/xdmod-admin +++ b/bin/xdmod-admin @@ -274,7 +274,7 @@ function addSSOUsers(array $data) foreach($data as $user) { $xdmodUserId = \XDUser::userExistsWithUsername($user['username']); if ($xdmodUserId !== INVALID) { - $logger->warning("Skipping user '${user['username']}' - account already exists"); + $logger->warning("Skipping user '{$user['username']}' - account already exists"); continue; } From 8783ce465e831530c4d540d766aacce306d448f2 Mon Sep 17 00:00:00 2001 From: ryanrath Date: Tue, 20 Jan 2026 13:00:45 -0500 Subject: [PATCH 61/83] Syncing WarehouseExportController with `main` (#2143) * Syncing contents w/ main * reverting change to `deleteRequests` --- src/Controller/WarehouseExportController.php | 155 ++++++++++--------- 1 file changed, 79 insertions(+), 76 deletions(-) diff --git a/src/Controller/WarehouseExportController.php b/src/Controller/WarehouseExportController.php index 4c8e7a7cfd..b118924be8 100644 --- a/src/Controller/WarehouseExportController.php +++ b/src/Controller/WarehouseExportController.php @@ -18,6 +18,7 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; use Twig\Environment; use function xd_response\buildError; @@ -37,12 +38,12 @@ class WarehouseExportController extends BaseController /** * @var RealmManager */ - private $realmManager; + private RealmManager $realmManager; /** * @var QueryHandler */ - private $queryHandler; + private QueryHandler $queryHandler; /** * @throws Exception if unable to instantiate the logger. @@ -79,6 +80,7 @@ public function getRealms(Request $request): Response $user = $this->authorize($request); } + $config = RawStatisticsConfiguration::factory(); $realms = array_map( @@ -125,16 +127,14 @@ public function getRequests(Request $request): Response * * @param Request $request * @return Response - * @throws Exception if user is not authorized to access this route. + * @throws UnauthorizedHttpException if user is not authorized to access this route. + * @throws BadRequestHttpException + * @throws Exception */ #[Route('/request', methods: ['POST'])] public function createRequest(Request $request): Response { - $this->logger->debug('Creating Request'); $user = $this->authorize($request); - - $this->logger->debug('User is Authenticated'); - $realm = $this->getStringParam($request, 'realm', true); $realms = array_map( @@ -144,51 +144,38 @@ function ($realm) { $this->realmManager->getRealmsForUser($user) ); if (!in_array($realm, $realms)) { - $this->logger->debug('Invalid Realm'); throw new BadRequestHttpException('Invalid realm'); } - $this->logger->debug('Realm is valid'); $startDate = $this->getDateFromISO8601Param($request, 'start_date', true); $endDate = $this->getDateFromISO8601Param($request, 'end_date', true); $now = new DateTime(); if ($startDate > $now) { - $this->logger->debug('Start Date is invalid'); throw new BadRequestHttpException('Start date cannot be in the future'); } - $this->logger->debug('Start Date is valid.'); - if ($endDate > $now) { - $this->logger->debug('End Date is invalid'); throw new BadRequestHttpException('End date cannot be in the future'); } - $this->logger->debug('End Date is valid'); - $interval = $startDate->diff($endDate); if ($interval === false) { - $this->logger->debug('Interval is Invalid'); throw new BadRequestHttpException('Failed to calculate date interval'); } - $this->logger->debug('Interval is valid'); if ($interval->invert === 1) { - $this->logger->debug('Interval is invalid'); throw new BadRequestHttpException('Start date must be before end date'); } $format = strtoupper($this->getStringParam($request, 'format', true)); if (!in_array($format, ['CSV', 'JSON'])) { - $this->logger->debug('Format is invalid'); throw new BadRequestHttpException('format must be CSV or JSON'); } try { - $this->logger->debug('Creating Export Request'); $id = $this->queryHandler->createRequestRecord( $user->getUserID(), $realm, @@ -197,11 +184,9 @@ function ($realm) { $format ); } catch (Exception $e) { - $this->logger->debug('Failed to create export request'); throw new BadRequestHttpException('Failed to create export request'); } - $this->logger->debug('Created Export Request'); return $this->json([ 'success' => true, 'message' => 'Created export request', @@ -216,17 +201,15 @@ function ($realm) { * @param Request $request * @param int $id * @return Response - * @throws Exception if the user is not authorized for this route. + * @throws AccessDeniedHttpException if the file for the request identified by the provided id is not readable. * @throws NotFoundHttpException if there were no requests for the provided id. * @throws NotFoundHttpException if the file for the request identified by the provided id is not found on the file system. * @throws BadRequestHttpException if the request that corresponds to the provided id is not in the Available state. - * @throws AccessDeniedHttpException if the file for the request identified by the provided id is not readable. - */ + * @throws Exception if the user is not authorized for this route. + */ #[Route('/download/{id}', requirements: ["id" => "\d+"], methods: ['GET'])] public function getExportedDataFile(Request $request, int $id): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->authorize($request); $requests = array_filter( @@ -259,7 +242,16 @@ function ($request) use ($id) { throw new AccessDeniedHttpException('Exported data is not readable'); } - $this->logger->info('Sending data warehouse export file'); + $this->logger->info( + '', + [ + 'module' => self::LOG_MODULE, + 'message' => 'Sending data warehouse export file', + 'event' => 'DOWNLOAD', + 'id' => $id, + 'Users.id' => $user->getUserId() + ] + ); if ($request['downloaded_datetime'] === null) { $this->queryHandler->updateDownloadedDatetime($request['id']); @@ -278,6 +270,45 @@ function ($request) use ($id) { } /** + * Delete a single request. + * + * @param Request $request + * @param string $id + * @return Response + * @throws UnauthorizedHttpException + * @throws NotFoundHttpException + * @throws \Exception + */ + #[Route('/request/{id}', requirements: ["id" => "\w+"], methods: ['DELETE'])] + public function deleteRequest(Request $request, string $id): Response + { + $user = $this->authorize($request); + $count = $this->queryHandler->deleteRequest($id, $user->getUserID()); + + if ($count === 0) { + throw new NotFoundHttpException('Export request not found'); + } + + $this->logger->info('', [ + 'module' => self::LOG_MODULE, + 'message' => 'Deleted data warehouse export request', + 'event' => 'DELETE_BY_USER', + 'id' => $id, + 'Users.id' => $user->getUserId() + ]); + + return $this->json([ + 'success' => true, + 'message' => 'Deleted export request', + 'data' => [['id' => $id]], + 'total' => 1 + ]); + } + + /** + * Delete multiple requests. + * + * The request body content must be a JSON encoded array of request IDs. * * @param Request $request * @return Response @@ -292,16 +323,13 @@ function ($request) use ($id) { #[Route('/requests', methods: ['DELETE'])] public function deleteRequests(Request $request): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->authorize($request); $requestIds = []; try { - $this->logger->debug(var_export($request->request->all(), true)); - $requestIds = json_decode($request->get('ids')); - $this->logger->debug(sprintf('Request ids: %s', var_export($requestIds, true))); + $requestIds = @json_decode($request->get('ids')); + if ($requestIds === null) { throw new Exception('Failed to decode JSON'); } @@ -310,19 +338,15 @@ public function deleteRequests(Request $request): Response throw new Exception('Export request IDs must be in an array'); } - try { - $requestIds = array_map( - function ($value) { - return is_int($value) ? $value : intval($value); - }, - $requestIds - ); - } catch (Exception $e) { - throw new Exception('Export request IDs must integers'); + foreach ($requestIds as $id) { + if (!is_int($id)) { + throw new Exception('Export request IDs must integers'); + } } - } catch (Exception $e) { - return $this->json(buildError('Malformed HTTP request content: ' . $e->getMessage())); + throw new BadRequestHttpException( + 'Malformed HTTP request content: ' . $e->getMessage() + ); } try { @@ -334,8 +358,16 @@ function ($value) { if ($count === 0) { throw new NotFoundHttpException('Export request not found'); } - - $this->logger->info('Deleted data warehouse export request'); + $this->logger->info( + '', + [ + 'module' => self::LOG_MODULE, + 'message' => 'Deleted data warehouse export request', + 'event' => 'DELETE_BY_USER', + 'id' => $id, + 'Users.id' => $user->getUserId() + ] + ); } $dbh->commit(); @@ -344,7 +376,7 @@ function ($value) { throw $e; } catch (Exception $e) { $dbh->rollBack(); - throw new HttpException(500, 'Failed to delete export requests'); + throw new BadRequestHttpException('Failed to delete export requests'); } return $this->json([ @@ -359,33 +391,4 @@ function ($id) { 'total' => count($requestIds) ]); } - - /** - * - * @param Request $request - * @param string $id - * @return Response - * @throws Exception - */ - #[Route('/request/{id}', requirements: ["id" => "\w+"], methods: ['DELETE'])] - public function deleteRequest(Request $request, string $id): Response - { - $user = $this->authorize($request); - - $count = $this->queryHandler->deleteRequest($id, $user->getUserID()); - - if ($count === 0) { - throw new NotFoundHttpException('Export request not found'); - } - - $this->logger->info('Deleted data warehouse export request'); - - return $this->json([ - 'success' => true, - 'message' => 'Deleted export request', - 'data' => [['id' => $id]], - 'total' => 1 - ]); - } - } From fc1548b5f357ec0813830ec21e91a4b67a222adc Mon Sep 17 00:00:00 2001 From: ryanrath Date: Tue, 20 Jan 2026 14:06:01 -0500 Subject: [PATCH 62/83] Sync WarehouseController - Minimizing changes w/ main (#2142) --- src/Controller/WarehouseController.php | 310 +++++++++++++------------ 1 file changed, 156 insertions(+), 154 deletions(-) diff --git a/src/Controller/WarehouseController.php b/src/Controller/WarehouseController.php index 2b8447d8d9..6eb1b81133 100644 --- a/src/Controller/WarehouseController.php +++ b/src/Controller/WarehouseController.php @@ -64,60 +64,60 @@ class WarehouseController extends BaseController * * @var array */ - private $supportedTypes = [ + private $supportedTypes = array( \DataWarehouse\Query\RawQueryTypes::ACCOUNTING => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::ACCOUNTING, - 'dtype' => 'infoid', - 'text' => 'Accounting data', - 'url' => '/warehouse/search/jobs/accounting', - 'documentation' => 'Shows information about the job that was obtained from the resource manager. + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::ACCOUNTING, + "dtype" => "infoid", + "text" => "Accounting data", + "url" => "/rest/v1.0/warehouse/search/jobs/accounting", + "documentation" => "Shows information about the job that was obtained from the resource manager. This includes timing information such as the start and end time of the job as well as administrative information such as the user that submitted the job and - the account that was charged.', - 'type' => 'keyvaluedata', - 'leaf' => true - ], + the account that was charged.", + "type" => "keyvaluedata", + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT, - 'dtype' => 'infoid', - 'text' => 'Job script', - 'url' => '/warehouse/search/jobs/jobscript', - 'documentation' => 'Shows the job batch script that was passed to the resource manager when the - job was submitted. The script is displayed verbatim.', - 'type' => 'utf8-text', - 'leaf' => true - ], + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::BATCH_SCRIPT, + "dtype" => "infoid", + "text" => "Job script", + "url" => "/rest/v1.0/warehouse/search/jobs/jobscript", + "documentation" => "Shows the job batch script that was passed to the resource manager when the + job was submitted. The script is displayed verbatim.", + "type" => "utf8-text", + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::EXECUTABLE => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::EXECUTABLE, - 'dtype' => 'infoid', - 'text' => 'Executable information', - 'url' => '/warehouse/search/jobs/executable', - 'documentation' => 'Shows information about the processes that were run on the compute nodes during + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::EXECUTABLE, + "dtype" => "infoid", + "text" => "Executable information", + "url" => "/rest/v1.0/warehouse/search/jobs/executable", + "documentation" => "Shows information about the processes that were run on the compute nodes during the job. This information includes the names of the various processes and may contain information about the linked libraries, loaded modules and process - environment.', - 'type' => 'nested', - 'leaf' => true], + environment.", + "type" => "nested", + "leaf" => true), \DataWarehouse\Query\RawQueryTypes::PEERS => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::PEERS, - 'dtype' => 'infoid', - 'text' => 'Peers', - 'url' => '/warehouse/search/jobs/peers', + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::PEERS, + "dtype" => "infoid", + "text" => "Peers", + 'url' => '/rest/v1.0/warehouse/search/jobs/peers', 'documentation' => 'Shows the list of other HPC jobs that ran concurrently using the same shared hardware resources.', 'type' => 'ganttchart', - 'leaf' => true - ], + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS, - 'dtype' => 'infoid', - 'text' => 'Summary metrics', - 'url' => '/warehouse/search/jobs/metrics', - 'documentation' => 'shows a table with the performance metrics collected during + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::NORMALIZED_METRICS, + "dtype" => "infoid", + "text" => "Summary metrics", + "url" => "/rest/v1.0/warehouse/search/jobs/metrics", + "documentation" => "shows a table with the performance metrics collected during the job. These are typically average values over the job. The label for each row has a tooltip that describes the metric. The data are grouped into the following categories: @@ -131,51 +131,51 @@ class WarehouseController extends BaseController
  • Network I/O Statistics: information about the data transmitted and received over the network devices.
  • - ', - 'type' => 'metrics', - 'leaf' => true - ], + ", + "type" => "metrics", + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS, - 'dtype' => 'infoid', - 'text' => 'Detailed metrics', - 'url' => '/warehouse/search/jobs/detailedmetrics', - 'documentation' => 'shows the data generated by the job summarization software. Please + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::DETAILED_METRICS, + "dtype" => "infoid", + "text" => "Detailed metrics", + "url" => "/rest/v1.0/warehouse/search/jobs/detailedmetrics", + "documentation" => "shows the data generated by the job summarization software. Please consult the relevant job summarization software documentation for details - about these metrics.', - 'type' => 'detailedmetrics', - 'leaf' => true - ], + about these metrics.", + "type" => "detailedmetrics", + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::ANALYTICS => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::ANALYTICS, - 'dtype' => 'infoid', - 'text' => 'Job analytics', - 'url' => '/warehouse/search/jobs/analytics', - 'documentation' => 'Click the help icon on each plot to show the description of the analytic', - 'type' => 'analytics', - 'hidden' => true, - 'leaf' => true - ], + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::ANALYTICS, + "dtype" => "infoid", + "text" => "Job analytics", + "url" => "/rest/v1.0/warehouse/search/jobs/analytics", + "documentation" => "Click the help icon on each plot to show the description of the analytic", + "type" => "analytics", + "hidden" => true, + "leaf" => true + ), \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS, - 'dtype' => 'infoid', - 'text' => 'Timeseries', - 'leaf' => false - ], + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::TIMESERIES_METRICS, + "dtype" => "infoid", + "text" => "Timeseries", + "leaf" => false + ), \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE => - [ - 'infoid' => \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE, - 'dtype' => 'infoid', - 'text' => 'VM State/Events', - 'documentation' => 'Show the lifecycle of a VM. Green signifies when a VM is active and red signifies when a VM is stopped.', - 'url' => '/warehouse/search/cloud/vmstate', - 'type' => 'vmstate', - 'leaf' => true - ] - ]; + array( + "infoid" => \DataWarehouse\Query\RawQueryTypes::VM_INSTANCE, + "dtype" => "infoid", + "text" => "VM State/Events", + "documentation" => "Show the lifecycle of a VM. Green signifies when a VM is active and red signifies when a VM is stopped.", + "url" => "/rest/v1.0/warehouse/search/cloud/vmstate", + "type" => "vmstate", + "leaf" => true + ) + ); /** * Retrieves the Search History for the user making the request. @@ -197,10 +197,11 @@ class WarehouseController extends BaseController * total: ... number of records in 'data' ... * } * - * * @param Request $request * @return Response + * @throws AccessDeniedHttpException * @throws BadRequestHttpException + * @throws NotFoundHttpException */ #[Route('/warehouse/search/history', methods: ['GET'])] #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['GET'])] @@ -306,11 +307,12 @@ private function getHistoryByTitle(XDUser $user, string $realm, string $title): $search['dtype'] = 'recordid'; } return $this->json( - [ + array( 'action' => $action, 'success' => true, 'data' => $search - ] + ), + 200 ); } } @@ -333,7 +335,9 @@ private function getSearchParams(Request $request): array $decoded = json_decode($data, true); if ($decoded === null || !isset($decoded['text'])) { - throw new BadRequestHttpException('Malformed request. Expected \'data.text\' to be present.'); + throw new BadRequestHttpException( + 'Malformed request. Expected \'data.text\' to be present.' + ); } $decoded['text'] = htmlspecialchars($decoded['text'], ENT_COMPAT | ENT_HTML5); @@ -345,13 +349,11 @@ private function getSearchParams(Request $request): array * Attempt to create a new Search History record with the provided 'data' * form parameter. * - * - * * @param Request $request - * * @return Response - * - * @throws Exception + * @throws AccessDeniedHttpException + * @throws BadRequestHttpException + * @throws \Exception */ #[Route('/warehouse/search/history', methods: ['POST'])] #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['POST'])] @@ -372,8 +374,9 @@ public function createHistory(Request $request): Response if ($created === null) { throw new BadRequestHttpException( - 'Create request will exceed record storage restrictions ' . - '(record count limited to ' . self::MAX_RECORDS . ')' + "Create request will exceed record storage restrictions " . + "(record count limited to " . + self::MAX_RECORDS . ")" ); } @@ -383,12 +386,12 @@ public function createHistory(Request $request): Response return $this->json( - [ + array( 'success' => true, 'action' => $action, 'total' => count($created), 'results' => $created - ] + ) ); } @@ -399,17 +402,20 @@ public function createHistory(Request $request): Response * @param Request $request that will be used to complete the requested operation * @param int $id of the Search History Record to be updated. * @return Response - * @throws BadRequestHttpException|AccessDeniedHttpException|Exception + * @throws BadRequestHttpException + * @throws AccessDeniedHttpException + * @throws Exception */ #[Route('/warehouse/search/history/{id}', requirements: ["id" => '\d+'], methods: ['POST', 'PUT'])] #[Route('{prefix}/warehouse/search/history/{id}', requirements: ["id" => '\d+', 'prefix' => '.*'], methods: ['POST', 'PUT'])] public function updateHistory(Request $request, int $id): Response { + $user = $this->authorize($request); $action = 'updateHistory'; - $user = $this->authorize($request); $data = $this->getSearchParams($request); + $realm = $this->getStringParam($request, 'realm', true); $history = $this->getUserStore($user, $realm); @@ -420,14 +426,16 @@ public function updateHistory(Request $request, int $id): Response $result['dtype'] = 'recordid'; } - return $this->json( - [ + $results = $this->json( + array( 'success' => true, 'action' => $action, - 'total' => 1, 'results' => $result - ] + ), + 200 ); + + return $results; } /** @@ -452,11 +460,11 @@ public function deleteHistory(Request $request, int $id): Response $deleted = $history->delById($id); return $this->json( - [ + array( 'success' => true, 'action' => $action, 'total' => $deleted - ] + ) ); } @@ -466,7 +474,9 @@ public function deleteHistory(Request $request, int $id): Response * * @param Request $request * @return Response - * @throws BadRequestHttpException|AccessDeniedHttpException|Exception + * @throws BadRequestHttpException + * @throws AccessDeniedHttpException + * @throws Exception */ #[Route('/warehouse/search/history', methods: ['DELETE'])] #[Route('{prefix}/warehouse/search/history', requirements: ['prefix' => '.*'], methods: ['DELETE'])] @@ -482,10 +492,10 @@ public function deleteAllHistory(Request $request): Response $history->del(); return $this->json( - [ + array( 'success' => true, 'action' => $action - ] + ) ); } @@ -493,9 +503,7 @@ public function deleteAllHistory(Request $request): Response * Attempt to perform a search of the jobs realm with the criteria provided in the * * @param Request $request - * * @return Response - * * @throws BadRequestHttpException * @throws AccessDeniedException if the user executing this request does not have access to the provided realm. * @throws Exception if a user record is not found in the database that corresponds to the current user's username. @@ -528,8 +536,9 @@ public function searchJobs(Request $request): Response * @param Request $request * @param string $action * @return Response - * @throws BadRequestHttpException|AccessDeniedHttpException|Exception if a user record is not found in the database - * that corresponds to the current user's username. + * @throws BadRequestHttpException + * @throws AccessDeniedHttpException + * @throws Exception if a user record is not found in the database that corresponds to the current user's username. */ #[Route( "/warehouse/search/{realms}/{action}", @@ -544,13 +553,17 @@ public function searchJobs(Request $request): Response public function searchJobsByAction(Request $request, string $action): Response { $user = $this->authorize($request); + $actionName = 'searchJobsByAction'; /*TODO: verify that `ucfirst` is needed */ $realm = ucfirst($this->getStringParam($request, 'realms')); $jobId = $this->getIntParam($request, 'jobid'); - return $this->processJobSearchByAction($request, $user, $action, $realm, $jobId, $actionName); + + $results = $this->processJobSearchByAction($request, $user, $action, $realm, $jobId, $actionName); + + return $results; } /** @@ -709,11 +722,11 @@ public function getAggregateData(Request $request): Response unset($val[$config->group_by . '_order_id']); } return $this->json( - [ + array( 'results' => $results, 'total' => $dataset->getTotalPossibleCount(), 'success' => true - ] + ) ); } @@ -736,18 +749,13 @@ public function getDimensions(Request $request): Response $user = $this->authorize($request); // Get parameters. - $realm = $this->getStringParam($request, 'realm'); + $realmParam = $this->getStringParam($request, 'realm'); // Get the dimensions for the query group, realm, and user's active role. - try { - $groupBys = Acls::getQueryDescripters($user, $realm); - } catch (Exception $e) { - return $this->json([ - 'success' => false, - 'message' => $e->getMessage() - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } - + $groupBys = Acls::getQueryDescripters( + $user, + $realmParam + ); $dimensionsToReturn = array(); foreach ($groupBys as $groupByName => $queryDescriptors) { @@ -765,10 +773,10 @@ public function getDimensions(Request $request): Response } // Return the dimensions found. - return $this->json([ + return $this->json(array( 'success' => true, 'results' => $dimensionsToReturn - ]); + )); } /** @@ -778,12 +786,10 @@ public function getDimensions(Request $request): Response * * @param Request $request The request used to make this call. * @param string $dimension - * * @return Response A response containing the following info: * success: A boolean indicating if the call was successful. * results: An object containing data about * the dimension values retrieved. - * * @throws Exception */ #[Route('/warehouse/dimensions/{dimension}', requirements: ["dimension" => "\w+"], methods: ['GET'])] @@ -821,10 +827,11 @@ public function getDimensionValues(Request $request, string $dimension): Respons $dimensionValue['short_name'] = html_entity_decode($dimensionValue['short_name']); } - return $this->json([ + // Return the found dimension values. + return $this->json(array( 'success' => true, 'results' => $dimensionValuesData - ]); + )); } /** @@ -925,13 +932,13 @@ public function getQuickFilters(Request $request): Response } // Return the quick filters. - return $this->json([ + return $this->json(array( 'success' => true, - 'results' => [ + 'results' => array( 'dimensionNames' => $dimensionIdsToNames, 'filters' => $filters - ] - ]); + ) + )); } /** @@ -939,18 +946,13 @@ public function getQuickFilters(Request $request): Response * * @param Request $request * @param string $dimensionId - * * @return Response - * * @throws Exception if there is no logged in user. */ #[Route('/warehouse/dimensions/{dimensionId}/name', requirements: ["dimensionId" => "(\w|_|-])+"], methods: ['GET'])] public function getDimensionName(Request $request, string $dimensionId): Response { - /*TODO: verify that this endpoint is for authorized users only. */ - $user = $this->authorize($request); - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - + $user = $this->getUserFromRequest($request); $dimensionName = MetricExplorer::getDimensionName($user, $dimensionId); $success = !empty($dimensionName); @@ -979,9 +981,7 @@ public function getDimensionName(Request $request, string $dimensionId): Respons * @param Request $request * @param string $dimensionId * @param string $valueId - * * @return Response - * * @throws Exception */ #[Route( @@ -991,9 +991,7 @@ public function getDimensionName(Request $request, string $dimensionId): Respons )] public function getDimensionValueName(Request $request, string $dimensionId, string $valueId): Response { - // TODO: verify that this should be accessible by unauthorized users. - // $user = $this->authorize($request); - $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $user = $this->getUserFromRequest($request); $valueName = MetricExplorer::getDimensionValueName($user, $dimensionId, $valueId); $success = !empty($valueName); @@ -1035,10 +1033,10 @@ public function getAggregationUnits(Request $request): Response // Return the available aggregation units. $aggregation_units = \DataWarehouse\QueryBuilder::getAggregationUnits(); - return $this->json([ + return $this->json(array( 'success' => true, 'results' => array_keys($aggregation_units), - ]); + )); } /** @@ -1060,10 +1058,10 @@ public function getDatasetTypes(Request $request): Response // Return the available dataset types. $datasetTypes = \DataWarehouse\QueryBuilder::getDatasetTypes(); - return $this->json([ + return $this->json(array( 'success' => true, 'results' => $datasetTypes, - ]); + )); } /** @@ -1080,6 +1078,8 @@ public function getDatasetTypes(Request $request): Response #[Route('/warehouse/dataset/output_formats', methods: ['GET'])] public function getDatasetOutputFormats(Request $request): Response { + $this->authorize($request); + // Return the available dataset output formats. return $this->json(array( 'success' => true, @@ -1202,6 +1202,7 @@ public function getPlotCombineTypes(Request $request): Response #[Route('/warehouse/plots', methods: ['GET'])] public function getPlots(Request $request): Response { + $this->authorize($request); return $this->getDatasets($request); @@ -1317,9 +1318,7 @@ private function processJobSearch( * @param string $realm * @param ?int $jobId * @param string $actionName - * * @return Response - * * @throws AccessDeniedException if the provided user does not have access to the specified realm. * @throws Exception if executable information unavailable for the provided jobId. */ @@ -1376,11 +1375,11 @@ private function processJobSearchByAction( break; default: $results = $this->json( - [ + array( 'success' => false, 'action' => $actionName, 'message' => "Unable to process the requested operation. Unsupported action $action." - ], + ), 400 ); break; @@ -1390,6 +1389,8 @@ private function processJobSearchByAction( } /** + * Return data about a job's peers. + * * @param XDUser $user the logged in user. * @param string $realm data realm. * @param int $jobId the unique identifier for the job. @@ -1555,7 +1556,7 @@ private function getJobExecutable(XDUser $user, string $realm, ?int $jobId): Res */ private function arrayToStore(array $values): array { - return array(array('key' => '.', 'value' => '', 'expanded' => true, 'children' => $this->atosRecurse($values))); + return array(array("key" => ".", "value" => "", "expanded" => true, "children" => $this->atosrecurse($values, false) )); } /** @@ -1585,6 +1586,7 @@ private function atosRecurse(array $values): array * @param int $nodeId * @param int $infoId * @return Response + * @throws BadRequestHttpException * @noinspection PhpTooManyParametersInspection */ private function processJobNodeTimeSeriesRequest( From b68db4f40a0185dfb19c1bd73afdaaab8438e937 Mon Sep 17 00:00:00 2001 From: ryanrath Date: Tue, 20 Jan 2026 14:43:36 -0500 Subject: [PATCH 63/83] Update `DashboardController` to be easier to review. (#2150) * Standardizing Exceptions thrown * Minimizing changes for DashboardController --- src/Controller/DashboardController.php | 368 ++++++++++++------------- 1 file changed, 181 insertions(+), 187 deletions(-) diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index ef461ea563..05c2f7efb3 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -9,7 +9,6 @@ use Exception; use Models\Services\Acls; -use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -24,6 +23,45 @@ #[Route('{prefix}/dashboard', requirements: ['prefix' => '.*'])] class DashboardController extends BaseController { + + /** + * Get the column layout manager for the user + * + * @param XDUser $user + * @return ColumnLayout + */ + private function getLayout(XDUser $user): ColumnLayout + { + $defaultLayout = null; + $defaultColumnCount = 2; + + if ($user->isPublicUser() === false) { + $layoutStore = new \UserStorage($user, 'summary_layout'); + $record = $layoutStore->getById(0); + if ($record) { + $defaultLayout = $record['layout']; + $defaultColumnCount = $record['columns']; + } + } + + return new ColumnLayout($defaultColumnCount, $defaultLayout); + } + + /** + * @param XDUser $user + * @return array + */ + private function getConfigVariables(XDUser $user): array + { + $person_id = $user->getPersonID(true); + $obj_warehouse = new \XDWarehouse(); + + return array( + 'PERSON_ID' => $person_id, + 'PERSON_NAME' => $obj_warehouse->resolveName($person_id) + ); + } + /** * The individual dashboard components have a namespace prefix to simplify * the implementation of the algorithm that determines which @@ -55,7 +93,7 @@ public function getComponents(Request $request): Response { $user = $this->getXDUser($request->getSession()); - $dashboardComponents = []; + $dashboardComponents = array(); $mostPrivilegedAcl = Acls::getMostPrivilegedAcl($user)->getName(); @@ -65,7 +103,7 @@ public function getComponents(Request $request): Response 'roles.json', CONFIG_DIR, null, - ['config_variables' => $this->getConfigVariables($user)] + array('config_variables' => $this->getConfigVariables($user)) ); $presets = $roleConfig['roles'][$mostPrivilegedAcl]; @@ -125,30 +163,32 @@ public function getComponents(Request $request): Response list($chartLocation, $column) = $layout->getLocation($name); - $dashboardComponents[$chartLocation] = [ + $dashboardComponents[$chartLocation] = array( 'name' => $name, 'type' => 'xdmod-dash-chart-cmp', - 'config' => [ + 'config' => array( 'name' => $query['name'], 'chart' => $queryConfig - ], + ), 'column' => $column - ]; + ); } } } ksort($dashboardComponents); - return $this->json([ + return $this->json(array( 'success' => true, 'total' => count($dashboardComponents), - 'portalConfig' => ['columns' => $layout->getColumnCount()], + 'portalConfig' => array('columns' => $layout->getColumnCount()), 'data' => array_values($dashboardComponents) - ]); + )); } /** + * Set the layout metadata + * * @param Request $request * @return Response * @throws BadRequestHttpException if the data parameter is not present and does not contain a layout and columns @@ -163,19 +203,21 @@ public function setLayout(Request $request): Response $content = json_decode($this->getStringParam($request, 'data', true), true); if ($content === null || !isset($content['layout']) || !isset($content['columns'])) { - throw new BadRequestException('Invalid data parameter'); + throw new BadRequestHttpException('Invalid data parameter'); } $storage = new \UserStorage($user, 'summary_layout'); - return $this->json([ + return $this->json(array( 'success' => true, 'total' => 1, 'data' => $storage->upsert(0, $content) - ]); + )); } /** + * clear the layout metadata + * * @param Request $request * @return Response * @throws Exception if there is a problem authorizing the current user. @@ -183,170 +225,181 @@ public function setLayout(Request $request): Response #[Route('/layout', methods: ['DELETE'])] public function resetLayout(Request $request): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $user = $this->authorize($request); $storage = new \UserStorage($user, 'summary_layout'); $storage->del(); - return $this->json([ + return $this->json(array( 'success' => true, 'total' => 1 - ]); + )); } /** + * Set value for if a user should view the help tour or not + * * @param Request $request * @return Response + * @throws BadRequestHttpException + * @throws Exception + */ + #[Route('/viewedUserTour', methods: ['POST'])] + public function setViewedUserTour(Request $request): Response + { + $user = $this->authorize($request); + $viewedTour = $this->getIntParam($request, 'viewedTour', true); + + if (!in_array($viewedTour, [0,1])) { + throw new BadRequestHttpException('Invalid data parameter'); + } + + $storage = new \UserStorage($user, 'viewed_user_tour'); + + return $this->json(array( + 'success' => true, + 'total' => 1, + 'msg' => $storage->upsert(0, ['viewedTour' => $viewedTour]) + )); + } + /** + * Get charts based on role. + * + * @param Request $request + * @return Response + * @throws NotFoundHttpException * @throws Exception if there is a problem authorizing the current user. */ #[Route('/rolereport', methods: ['GET'])] public function getRoleReport(Request $request): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $user = $this->authorize($request); - $role = $user->getMostPrivilegedRole()->getName(); $report_id_suffix = 'autogenerated-' . $role; $report_id = $user->getUserID() . '-' . $report_id_suffix; - $userReport = null; - $rm = new \XDReportManager($user); - $reports = $rm->fetchReportTable(); - foreach ($reports as &$report) { - if ($report['report_id'] === $report_id) { - $userReport = $report; - } - } - if (is_null($userReport)){ - $availTemplates = $rm::enumerateReportTemplates([$role], 'Dashboard Tab Report'); - if (empty($availTemplates)) { - throw new NotFoundHttpException("No dashboard tab report template available for $role"); - } - - $template = $rm::retrieveReportTemplate($user, $availTemplates[0]['id']); - $template->buildReportFromTemplate($_REQUEST, $report_id_suffix); + if (isset($user)) { + $userReport = null; + $rm = new \XDReportManager($user); $reports = $rm->fetchReportTable(); foreach ($reports as &$report) { if ($report['report_id'] === $report_id) { $userReport = $report; } } - } - $data = $rm->loadReportData($userReport['report_id']); - $count = 0; - foreach($data['queue'] as $queue) { - $chart_id = explode('&', $queue['chart_id']); - $chart_id_parsed = array(); - foreach($chart_id as $value) { - list($key, $value) = explode('=', $value); - $key = urldecode($key); - $value = urldecode($value); - $json = json_decode($value, true); - - if ($key === 'timeseries') { - $value = $value === 'y' || $value === 'true'; - } elseif ($json !== null) { - $value = $json; + if (is_null($userReport)) { + $availTemplates = $rm::enumerateReportTemplates([$role], 'Dashboard Tab Report'); + if (empty($availTemplates)) { + throw new NotFoundHttpException("No dashboard tab report template available for $role"); } - $chart_id_parsed[$key] = $value; - } - $data['queue'][$count]['chart_id'] = $chart_id_parsed; - $count++; - } - return $this->json([ - 'success' => true, - 'total' => count($data), - 'data' => $data - ]); - } - /** - * @param Request $request - * @return Response - * @throws Exception if there is a problem authorizing the current user. - */ - #[Route('/savedchartsreports', methods: ['GET'])] - public function getSavedChartReports(Request $request): Response - { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); - $user = $this->authorize($request); - // fetch charts - $queries = new \UserStorage($user, 'queries_store'); - $data = $queries->get(); - foreach ($data as &$query) { - $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); - $query['type'] = 'Chart'; - } - // fetch reports - $rm = new \XDReportManager($user); - $reports = $rm->fetchReportTable(); - foreach ($reports as &$report) { - $tmp = []; - $tmp['type'] = 'Report'; - $tmp['name'] = $report['report_name']; - $tmp['chart_count'] = $report['chart_count']; - $tmp['charts_per_page'] = $report['charts_per_page']; - $tmp['creation_method'] = $report['creation_method']; - $tmp['report_delivery'] = $report['report_delivery']; - $tmp['report_format'] = $report['report_format']; - $tmp['report_id'] = $report['report_id']; - $tmp['report_name'] = $report['report_name']; - $tmp['report_schedule'] = $report['report_schedule']; - $tmp['report_title'] = $report['report_title']; - $tmp['ts'] = $report['last_modified']; - $tmp['config'] = $report['report_id']; - $data[] = $tmp; + $template = $rm::retrieveReportTemplate($user, $availTemplates[0]['id']); + $template->buildReportFromTemplate($_REQUEST, $report_id_suffix); + $reports = $rm->fetchReportTable(); + foreach ($reports as &$report) { + if ($report['report_id'] === $report_id) { + $userReport = $report; + } + } + } + $data = $rm->loadReportData($userReport['report_id']); + $count = 0; + foreach ($data['queue'] as $queue) { + $chart_id = explode("&", $queue['chart_id']); + $chart_id_parsed = array(); + foreach ($chart_id as $value) { + list($key, $value) = explode("=", $value); + $key = urldecode($key); + $value = urldecode($value); + $json = json_decode($value, true); + + if ($key === 'timeseries') { + $value = $value === 'y' || $value === 'true'; + } elseif ($json !== null) { + $value = $json; + } + $chart_id_parsed[$key] = $value; + } + $data['queue'][$count]['chart_id'] = $chart_id_parsed; + $count++; + } + return $this->json(array( + 'success' => true, + 'total' => count($data), + 'data' => $data + )); } - return $this->json([ - 'success' => true, - 'total' => count($data), - 'data' => $data - ]); } /** + * Get stored value for if a user should view the help tour or not + * * @param Request $request * @return Response + * @throws Exception */ - #[Route('/viewedUserTour', methods: ['POST'])] - public function setViewedUserTour(Request $request): Response + #[Route('/viewedUserTour', methods: ['GET'])] + public function getViewedUserTour(Request $request): Response { $user = $this->authorize($request); - $viewedTour = $this->getIntParam($request, 'viewedTour', true); - - if (!in_array($viewedTour, [0,1])) { - throw new BadRequestHttpException('Invalid data parameter'); - } - $storage = new \UserStorage($user, 'viewed_user_tour'); - - return $this->json([ + return $this->json(array( 'success' => true, 'total' => 1, - 'msg' => $storage->upsert(0, ['viewedTour' => $viewedTour]) - ]); + 'data' => $storage->get() + )); } /** + * Get saved charts and reports. * * @param Request $request * @return Response + * @throws Exception if there is a problem authorizing the current user. */ - #[Route('/viewedUserTour', methods: ['GET'])] - public function getViewedUserTour(Request $request): Response + #[Route('/savedchartsreports', methods: ['GET'])] + public function getSavedChartReports(Request $request): Response { - $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $user = $this->authorize($request); - $storage = new \UserStorage($user, 'viewed_user_tour'); - return $this->json([ - 'success' => true, - 'total' => 1, - 'data' => $storage->get() - ]); + if (isset($user)) { + // fetch charts + $queries = new \UserStorage($user, 'queries_store'); + $data = $queries->get(); + foreach ($data as &$query) { + $query['name'] = htmlspecialchars($query['name'], ENT_COMPAT, 'UTF-8', false); + $query['type'] = 'Chart'; + } + // fetch reports + $rm = new \XDReportManager($user); + $reports = $rm->fetchReportTable(); + foreach ($reports as &$report) { + $tmp = array(); + $tmp['type'] = 'Report'; + $tmp['name'] = $report['report_name']; + $tmp['chart_count'] = $report['chart_count']; + $tmp['charts_per_page'] = $report['charts_per_page']; + $tmp['creation_method'] = $report['creation_method']; + $tmp['report_delivery'] = $report['report_delivery']; + $tmp['report_format'] = $report['report_format']; + $tmp['report_id'] = $report['report_id']; + $tmp['report_name'] = $report['report_name']; + $tmp['report_schedule'] = $report['report_schedule']; + $tmp['report_title'] = $report['report_title']; + $tmp['ts'] = $report['last_modified']; + $tmp['config'] = $report['report_id']; + $data[] = $tmp; + } + return $this->json(array( + 'success' => true, + 'total' => count($data), + 'data' => $data + )); + } } /** + * Retrieve summary statistics + * * @param Request $request * @return Response * @throws Exception @@ -354,11 +407,7 @@ public function getViewedUserTour(Request $request): Response #[Route('/statistics', methods: ['GET'])] public function getStatistics(Request $request): Response { - try { - $user = $this->authorize($request); - } catch (Exception $e) { - $user = XDUser::getPublicUser(); - } + $user = $this->getXDUser($request->getSession()); $aggregationUnit = $request->get('aggregation_unit', 'auto'); @@ -367,13 +416,11 @@ public function getStatistics(Request $request): Response $this->checkDateRange($startDate, $endDate); - $this->logger->debug('Date Range is Copacetic!'); // This try/catch block is intended to replace the "Base table or // view not found: 1146 Table 'modw_aggregates.jobfact_by_day' // doesn't exist" error message with something more informative for // Open XDMoD users. try { - $this->logger->debug('Running Aggregate Query!'); $query = new \DataWarehouse\Query\AggregateQuery( 'Jobs', $aggregationUnit, @@ -385,7 +432,6 @@ public function getStatistics(Request $request): Response $result = $query->execute(); } catch (PDOException $e) { - $this->logger->debug('Exception while running query: %s', buildError($e)); if ($e->getCode() === '42S02' && strpos($e->getMessage(), 'modw_aggregates.jobfact_by_') !== false) { $msg = 'Aggregate table not found, have you ingested your data?'; throw new Exception($msg); @@ -393,53 +439,26 @@ public function getStatistics(Request $request): Response throw $e; } } catch (Exception $e) { - $this->logger->debug('Exception while running query: %s', buildError($e)); throw new BadRequestHttpException($e->getMessage()); } - $this->logger->debug('Successfully ran query!'); $rawRoles = XdmodConfiguration::assocArrayFactory('roles.json', CONFIG_DIR); $mostPrivileged = $user->getMostPrivilegedRole()->getName(); $formats = $rawRoles['roles'][$mostPrivileged]['statistics_formats']; - $this->logger->debug('Returning Data'); return $this->json( - [ + array( 'totalCount' => 1, 'success' => true, 'message' => '', 'formats' => $formats, 'data' => [$result] - ] + ) ); } - /* - * Get the column layout manager for the user - * - * @return \CCR\ColumnLayout - */ - /** - * @param XDUser $user - * @return ColumnLayout - */ - private function getLayout(XDUser $user): ColumnLayout - { - $defaultLayout = null; - $defaultColumnCount = 2; - if ($user->isPublicUser() === false) { - $layoutStore = new \UserStorage($user, 'summary_layout'); - $record = $layoutStore->getById(0); - if ($record) { - $defaultLayout = $record['layout']; - $defaultColumnCount = $record['columns']; - } - } - - return new ColumnLayout($defaultColumnCount, $defaultLayout); - } /** * Checks that the `$[start|end]Date` values are valid ( `Y-m-d` ) dates and that `$startDate` @@ -452,13 +471,9 @@ private function getLayout(XDUser $user): ColumnLayout */ protected function checkDateRange($startDate, $endDate) { - $this->logger->debug('Checking Date Rage'); $startTimestamp = $this->getTimestamp($startDate, 'start_date'); $endTimestamp = $this->getTimestamp($endDate, 'end_date'); - $this->logger->debug(sprintf('Start Timestamp: %s', $startTimestamp)); - $this->logger->debug(sprintf('End Timestamp: %s', $endTimestamp)); - if ($startTimestamp > $endTimestamp) { throw new BadRequestHttpException('Start Date must not be after End Date'); } @@ -476,44 +491,23 @@ protected function checkDateRange($startDate, $endDate) */ protected function getTimestamp($date, $paramName = 'date', $format = 'Y-m-d') { - $this->logger->debug(sprintf('Getting Timestamp for %s %s', $date, $format)); - $parsed = date_parse_from_format($format, $date); - $this->logger->debug(sprintf('Parsed: %s', var_export($parsed, true))); + if ($parsed['year'] === false || $parsed['month'] === false || $parsed['day'] === false) { - $this->logger->debug(sprintf('Unable to parse %s', $paramName)); throw new BadRequestHttpException("Unable to parse $paramName"); } $date = mktime( $parsed['hour'] !== false ? $parsed['hour'] : 0, $parsed['minute'] !== false ? $parsed['minute'] : 0, - $parsed['second'] !== false ? $parsed['second' ] : 0, + $parsed['second'] !== false ? $parsed['second'] : 0, $parsed['month'], $parsed['day'], $parsed['year'] ); - $this->logger->debug(sprintf('Date: %s', var_export($date, true))); if ($date === false || $parsed['error_count'] > 0) { - $this->logger->debug('Unable to get timestamp!'); throw new BadRequestHttpException("Unable to parse $paramName"); } - $this->logger->debug('Successfully made timestamp!'); return $date; } - - /** - * @param XDUser $user - * @return array - */ - private function getConfigVariables(XDUser $user): array - { - $person_id = $user->getPersonID(true); - $obj_warehouse = new \XDWarehouse(); - - return [ - 'PERSON_ID' => $person_id, - 'PERSON_NAME' => $obj_warehouse->resolveName($person_id) - ]; - } } From d6a6e9d638c16c8f80f8a497ea2639cd3acb9ab0 Mon Sep 17 00:00:00 2001 From: ryanrath Date: Thu, 29 Jan 2026 09:36:26 -0500 Subject: [PATCH 64/83] Sync realm realm (#2141) * Reverting unneeded space change * Updating comment to be more intelligible --- classes/Realm/Realm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/Realm/Realm.php b/classes/Realm/Realm.php index 583be7807d..9f08859940 100644 --- a/classes/Realm/Realm.php +++ b/classes/Realm/Realm.php @@ -366,7 +366,7 @@ private static function getSortedObjectList( // Skip disabled configs - if (isset($config->disabled) && $config->disabled) { + if ( isset($config->disabled) && $config->disabled ) { continue; } @@ -388,7 +388,7 @@ private static function getSortedObjectList( $factoryClassName = sprintf('\\%s\\%s', __NAMESPACE__, $factoryClassName); } - // We are using the array format for a callable instead of a string due to the use of `static::` being deprecated w/ the string version. + // When using the string form of a callable, the use of `static::` is deprecated, hence switching to using the array format for a callable. $factoryCallable = [$factoryClassName, 'factory']; if ('Realm' == $className) { // The Realm class already has the configuration and does not need it to be passed From 35f6b7c242ddc5efc7ac0fccd680d5c92a077b09 Mon Sep 17 00:00:00 2001 From: ryanrath Date: Thu, 29 Jan 2026 13:31:31 -0500 Subject: [PATCH 65/83] Merging `main` into `symfony_migration` (#2159) * updating playwright version to 1.58.0 (#2153) * updated xdmod-admin to remove jobs for a resource in shredder (#2155) updated to allow removal of jobs for a given resource in xdmod-admin --------- Co-authored-by: Rose Tovar <15618284+rvtovar@users.noreply.github.com> --- bin/xdmod-admin | 140 ++++++++++++++++++++- tests/playwright/Docker/docker-compose.yml | 2 +- tests/playwright/package-lock.json | 24 ++-- tests/playwright/package.json | 2 +- 4 files changed, 152 insertions(+), 16 deletions(-) diff --git a/bin/xdmod-admin b/bin/xdmod-admin index ce5ed80dc0..0c3e9da180 100755 --- a/bin/xdmod-admin +++ b/bin/xdmod-admin @@ -1,5 +1,6 @@ #!/usr/bin/env php false, @@ -202,6 +214,9 @@ function main() case 'truncate': truncateJobs(); break; + case 'delete': + truncateJobsForResource($params); + break; default: $logger->critical("Cannot perform '$action' on '$entity'"); exit(1); @@ -271,7 +286,7 @@ function addSSOUsers(array $data) $load_count = 0; - foreach($data as $user) { + foreach ($data as $user) { $xdmodUserId = \XDUser::userExistsWithUsername($user['username']); if ($xdmodUserId !== INVALID) { $logger->warning("Skipping user '{$user['username']}' - account already exists"); @@ -438,6 +453,123 @@ function truncateJobs() } } } +} +/** + * This function searches multiple databases (shredder, hpcdb, and datawarehouse) resource tables for a record that + * matches the provided $resourceName. If a resource is found then it's added to the array to be returned as + * `dbName` => `resourceId`. + * + * @param string $resourceName the name of the resource to find ids for. + * + * @return array an array of "dbName" => "resourceId" + * + */ +function resourceFinder(string $resourceName): array +{ + $resources = array( + array('shredder', 'resource_id', 'staging_resource', 'resource_name'), + array('hpcdb', 'resource_id', 'hpcdb_resources', 'resource_code'), + array('datawarehouse', 'id', 'resourcefact', 'code') + ); + $data = []; + foreach ($resources as $resource) { + list($dbName, $idColumn, $table, $whereColumn) = $resource; + $db = DB::factory($dbName); + $sql = "SELECT $idColumn as id from $table where $whereColumn = '$resourceName'"; + $id = $db->query($sql)[0]['id']; + + if (!empty($id)) { + $data[$dbName] = $id; + } + }; + return $data; +} +/* + * @param string $resourceName + * @return void + */ +function truncateJobsForResource(string $resourceName) +{ + global $logger, $force; + + if (!$force && !confirm("Truncate Job Data For $resourceName")) { + return; + } + + $shredderDb = DB::factory('shredder'); + $hpcDb = DB::factory('hpcdb'); + $modwDb = DB::factory('datawarehouse'); + $rids = resourceFinder($resourceName); + + if (empty($rids)) { + echo "$resourceName is not Found\n"; + return; + } + + $dbsAndTables = array( + array( + 'db' => $shredderDb, + 'tables' => array( + array('shredded_job_pbs', 'host', $resourceName), + array('shredded_job_sge', 'clustername', $resourceName), + array('shredded_job_slurm', 'cluster_name', $resourceName), + array('shredded_job_lsf', 'resource_name', $resourceName), + array('shredded_job', 'resource_name', $resourceName), + array('staging_job', 'resource_name', $resourceName), + ), + ), + array( + 'db' => $hpcDb, + 'tables' => array( + array('hpcdb_jobs', 'resource_id' , $rids["hpcdb"]), + ), + ), + array( + 'db' => $modwDb, + 'tables' => array( + array('modw.job_records', 'resource_id', $rids["datawarehouse"]), + array('modw.jobhosts', 'host_id', $rids["datawarehouse"]), + array('modw.job_tasks', 'resource_id', $rids["datawarehouse"]), + array('modw_aggregates.jobfact_by_year','record_resource_id', $rids["datawarehouse"]), + array('modw_aggregates.jobfact_by_quarter','record_resource_id', $rids["datawarehouse"]), + array('modw_aggregates.jobfact_by_month','record_resource_id', $rids["datawarehouse"]), + array('modw_aggregates.jobfact_by_day','record_resource_id', $rids["datawarehouse"]), + ), + ), + ); + $logger->notice("Deleting job data from $resourceName"); + foreach ($dbsAndTables as $dbAndTables) { + $db = $dbAndTables['db']; + $tables = $dbAndTables['tables']; + + $helper = MySQLHelper::factory($db); + + $dbName = $db->_db_name; + $logger->info("Truncating job tables in $dbName"); + foreach ($tables as $table) { + list($tableName, $idColumn, $idValue) = $table; + if ($helper->tableExists($tableName)) { + $logger->info("Deleting Job Data from $tableName for $resourceName"); + if ($tableName == "modw.jobhosts") { + $sql = " + DELETE FROM jobhosts + where job_id IN ( + SELECT job_id + FROM job_tasks + WHERE resource_id = $idValue + ); + "; + + } else { + $sql = "DELETE FROM $tableName WHERE $idColumn = '$idValue'"; + } + $db->execute($sql); + } + } + } + + + } /** @@ -512,10 +644,14 @@ Usage: xdmod-admin [-v] --load FILENAME Load information from file FILENAME (only used for users). + --delete RESOURCE_NAME + Delete Jobs for a specified resource (only used for jobs) Examples: xdmod-admin --jobs --truncate + xdmod-admin --jobs --delete RESOURCE_NAME + xdmod-admin --resources --list xdmod-admin --users --load PATH/TO/USERS-CSV-FILE.csv diff --git a/tests/playwright/Docker/docker-compose.yml b/tests/playwright/Docker/docker-compose.yml index eb7970ddf6..87f1f2874c 100644 --- a/tests/playwright/Docker/docker-compose.yml +++ b/tests/playwright/Docker/docker-compose.yml @@ -15,7 +15,7 @@ services: tty: true command: sleep infinity playwright: - image: mcr.microsoft.com/playwright:v1.57.0-jammy + image: mcr.microsoft.com/playwright:v1.58.0-jammy hostname: playwright container_name: playwright networks: diff --git a/tests/playwright/package-lock.json b/tests/playwright/package-lock.json index faa8c87ebd..e1d0c09fbf 100644 --- a/tests/playwright/package-lock.json +++ b/tests/playwright/package-lock.json @@ -9,17 +9,17 @@ "version": "1.0.0", "license": "GPL-3.0", "devDependencies": { - "@playwright/test": "^1.57.0" + "@playwright/test": "^1.58.0" } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.58.0" }, "bin": { "playwright": "cli.js" @@ -44,13 +44,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.58.0" }, "bin": { "playwright": "cli.js" @@ -63,9 +63,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/tests/playwright/package.json b/tests/playwright/package.json index 6d2a5a2009..0c17442b09 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -7,6 +7,6 @@ "license": "GPL-3.0", "repository": "https://github.com/ubccr/xdmod", "devDependencies": { - "@playwright/test": "^1.57.0" + "@playwright/test": "^1.58.0" } } From e68cd7c994ebbc11ffb010dd4a52db018cae01ad Mon Sep 17 00:00:00 2001 From: ryanrath Date: Wed, 4 Feb 2026 10:27:22 -0500 Subject: [PATCH 66/83] Symfony Migration - Update index.html.twig (#2163) * Make XDMoD.TrackEvent a no-op --- html/gui/js/CCR.js | 53 +--------------------------------- templates/twig/index.html.twig | 5 ---- 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/html/gui/js/CCR.js b/html/gui/js/CCR.js index 40f5731cfb..f098a6371f 100644 --- a/html/gui/js/CCR.js +++ b/html/gui/js/CCR.js @@ -16,58 +16,7 @@ XDMoD.Tracking = { }; XDMoD.TrackEvent = function (category, action, details, suppress_close_handler) { - // Tracking is not implemented outside of the XSEDE XDMoD instance. - if (!CCR.xdmod.features) { - return; - } - if (!CCR.xdmod.features.xsede) { - return; - } - - details = details || ''; - suppress_close_handler = suppress_close_handler || false; - - XDMoD.Tracking.suppress_close_handler = suppress_close_handler; - - if (typeof details !== 'string') { - details = JSON.stringify(details); - } - - var dimension_limit = 150; // dimension limit imposed by Google - var action_dimension_slots = 3; // how many custom dimensions are dedicated to storing action details - - var action_details = []; - var i = 0; - - for (i = 0; i < action_dimension_slots; i++) { - action_details.push((details.substr(0, dimension_limit).length > 0) ? details.substr(0, dimension_limit) : '-'); - details = details.substr(dimension_limit); - } - - XDMoD.Tracking.sequence_index++; - - var current_date = new Date(); - var current_timestamp = current_date.getTime(); - var timezone_offset = current_date.getTimezoneOffset(); - - var time_delta = current_timestamp - XDMoD.Tracking.timestamp; - - ga('send', 'event', category, action, { - 'dimension1': XDMoD.Tracking.sequence_index, - 'dimension2': CCR.xdmod.ui.username, - 'dimension3': current_timestamp.toString(), - 'dimension4': XDMoD.REST.token, - 'dimension5': (timezone_offset / 60).toString(), - 'dimension6': action_details[0], - 'dimension7': action_details[1], - 'dimension8': action_details[2], - 'metric1': time_delta.toString() - }); - - XDMoD.Tracking.timestamp = current_timestamp; - - _gaq.push(['_trackEvent', CCR.xdmod.ui.username, category, action]); - + /* This function is now a no-op as Google Analytics is no longer used in Open XDMoD.*/ }; //XDMoD.TrackEvent // ============================================================== diff --git a/templates/twig/index.html.twig b/templates/twig/index.html.twig index 2bd7508532..ca8c094971 100644 --- a/templates/twig/index.html.twig +++ b/templates/twig/index.html.twig @@ -327,11 +327,6 @@ {% endif %} - {# From gaq/xdmod.php #} - {% endif %} {% if use_center_logo %} From 3922bf6c07d5c99c3f7ed117f5301c5d64263b2c Mon Sep 17 00:00:00 2001 From: ryanrath Date: Wed, 4 Feb 2026 10:30:18 -0500 Subject: [PATCH 67/83] Updates for the Internal Dashboard (#2166) - Added the error codes from `\XDError::getErrorCodes` to the internal dashboard controller / twig template and removed refernces to `Error.js.php` as it no longer exists. - Updated the version of jquery for the internal dashboard login page to the version that XDMoD provides. - Moved the `ext-stop-flash-check.js` to the bottom of `body` so that `document.body` is populated before it runs. --- .../InternalDashboard/InternalDashboardController.php | 1 + templates/twig/internal_dashboard.html.twig | 7 +++++++ templates/twig/internal_dashboard_login.html.twig | 7 ++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Controller/InternalDashboard/InternalDashboardController.php b/src/Controller/InternalDashboard/InternalDashboardController.php index 0602999346..98df2f2d0e 100644 --- a/src/Controller/InternalDashboard/InternalDashboardController.php +++ b/src/Controller/InternalDashboard/InternalDashboardController.php @@ -60,6 +60,7 @@ public function index(Request $request): Response 'is_logged_in' => !$user->isPublicUser(), 'is_public_user' => $user->isPublicUser(), 'asset_paths' => Assets::generateAssetTags('internal_dashboard'), + 'error_codes' => \XDError::getErrorCodes() ]; if ($user->isPublicUser()) { diff --git a/templates/twig/internal_dashboard.html.twig b/templates/twig/internal_dashboard.html.twig index 5cceaced62..7cf2f01dfc 100644 --- a/templates/twig/internal_dashboard.html.twig +++ b/templates/twig/internal_dashboard.html.twig @@ -55,6 +55,13 @@ + {# The script block below is from gui/js/Error.js.php #} + diff --git a/templates/twig/internal_dashboard_login.html.twig b/templates/twig/internal_dashboard_login.html.twig index f2c02792d0..6e2e211fde 100644 --- a/templates/twig/internal_dashboard_login.html.twig +++ b/templates/twig/internal_dashboard_login.html.twig @@ -9,8 +9,7 @@ - - + @@ -23,7 +22,7 @@ - + + + From 0507805d7e44a1be7bc0ccd56e60f93d71f86b0b Mon Sep 17 00:00:00 2001 From: ryanrath Date: Wed, 4 Feb 2026 10:32:36 -0500 Subject: [PATCH 68/83] Making it so that linting runs for symfony_migration (#2162) --- .github/workflows/linter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5dc30714e4..0a6a5a01e4 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - symfony_migration env: XDMOD_IS_CORE: 'true' From 461235b1bad16aaf42b703129c9b833f343f6fde Mon Sep 17 00:00:00 2001 From: ryanrath Date: Wed, 4 Feb 2026 11:07:31 -0500 Subject: [PATCH 69/83] Reverting OrgConfig parsing (#2167) Whatever reason I had to change the way `$orgConfig` was processed was is no longer relevant so these changes revert that ( no need for the array_pop ). and Updated the name to just `$org`. --- html/index.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/html/index.php b/html/index.php index 90c2d5021a..f2823be2d7 100644 --- a/html/index.php +++ b/html/index.php @@ -7,12 +7,10 @@ // Configurable constants --------------------------- -$orgConfig = \Configuration\XdmodConfiguration::assocArrayFactory( +$org = \Configuration\XdmodConfiguration::assocArrayFactory( 'organization.json', CONFIG_DIR ); -// orgConfig is returned as array(0=>array('name' => '', 'abbrev' => '')) -$org = array_shift($orgConfig); define('ORGANIZATION_NAME', $org['name']); $org_abbrev = $org['abbrev']; if (empty($org_abbrev)) { From e3c204509ba1f9e9b8f6464f1f855d70e287a60d Mon Sep 17 00:00:00 2001 From: ryanrath Date: Wed, 4 Feb 2026 14:33:15 -0500 Subject: [PATCH 70/83] Maybe know the structure before interacting with it (#2169) --- html/index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/html/index.php b/html/index.php index f2823be2d7..e5d48ca2ce 100644 --- a/html/index.php +++ b/html/index.php @@ -11,6 +11,11 @@ 'organization.json', CONFIG_DIR ); + +if (!array_key_exists('name', $org)) { + $org = array_shift($org); +} + define('ORGANIZATION_NAME', $org['name']); $org_abbrev = $org['abbrev']; if (empty($org_abbrev)) { From 7f45f009ba484519e2734dfad8c24a7bed7cd985 Mon Sep 17 00:00:00 2001 From: Joe White Date: Mon, 2 Mar 2026 10:24:33 -0500 Subject: [PATCH 71/83] Merge changes from main to symfony_migration branch (#2174) * updating playwright version to 1.58.0 (#2153) * updated xdmod-admin to remove jobs for a resource in shredder (#2155) updated to allow removal of jobs for a given resource in xdmod-admin * Updating Commands and Faq to explain new Command for Xdmod-admin (#2157) Updates to docs for new command to delete jobs for a given resource. Authored-by: Alex Tovar Co-authored-by: Aaron Weeden <31246768+aaronweeden@users.noreply.github.com> * Updates to support the Allocations Realm (#2122) * Updates to support the allocations realm changes. * Fix typo in journal helper * Add migration and tests. * Create scheam for upgrade path * Linting fixes * Add documentation for new functions --------- Co-authored-by: ryanrath Co-authored-by: Rose Tovar <15618284+rvtovar@users.noreply.github.com> Co-authored-by: Aaron Weeden <31246768+aaronweeden@users.noreply.github.com> --- classes/DB/EtlJournalHelper.php | 145 ++++++++++++ classes/ETL/Aggregator/pdoAggregator.php | 208 +++++++++++++++--- classes/ETL/EtlOverseerOptions.php | 22 +- .../Migration/DatabasesMigration.php | 7 +- .../Version1102To1150/DatabasesMigration.php | 16 ++ classes/OpenXdmod/Setup/DatabaseSetup.php | 4 +- configuration/etl/etl.d/journal.json | 27 +++ .../etl.d/xdmod-migration-11_0_2-11_5_0.json | 18 ++ .../etl/etl_tables.d/journal/log.json | 83 +++++++ docs/commands.md | 1 + docs/faq.md | 10 + tests/ci/scripts/xdmod-upgrade.tcl | 36 +++ tests/component/lib/DB/EtlJournalTest.php | 30 +++ 13 files changed, 571 insertions(+), 36 deletions(-) create mode 100644 classes/DB/EtlJournalHelper.php create mode 100644 configuration/etl/etl.d/journal.json create mode 100644 configuration/etl/etl_tables.d/journal/log.json create mode 100644 tests/component/lib/DB/EtlJournalTest.php diff --git a/classes/DB/EtlJournalHelper.php b/classes/DB/EtlJournalHelper.php new file mode 100644 index 0000000000..cb7697481d --- /dev/null +++ b/classes/DB/EtlJournalHelper.php @@ -0,0 +1,145 @@ +getLastModified(); + * + * $process_start_time = date('Y-m-d H:i:s'); + * [ run ETL pipeline that selects rows newer than $last_modified ] + * $process_end_time = date('Y-m-d H:i:s'); + * + * $journal->markAsDone($process_start_time, $process_end_time); + * + * Theory of operations: + * getLastModified() queries the source table to find the newest record. This + * value is stored in memory and will be written to the database _if_ ingestion + * completes without error (i.e. markAsDone is called). getLastModified + * then retrieves the timestamp of the last successful ingest and returns it + * + * This is safe against race conditions as long as the 'last_modfied' column in + * the source table contains non-decreasing timestamps. If the source table + * changes between the call to getLastModified() and the actual ingest then the + * changed rows will just be ingested again next time. So more compute cycles + * used but no data loss. + * + * @param string $schema The name of the database that contains the table + * that will be written to. + * @param string $table The name of the table that will be written to. + * @param string $database The name of the configuration section that + * contains the database credentials + * @param string $last_modified_column The name of the column in the + * target table that contains a timestamp of when + * the row was updated + */ + public function __construct($schema, $table, $database = 'datawarehouse', $last_modified_column = 'last_modified') { + + $this->schema = $schema; + $this->table = $table; + $this->lastModifiedColumn = $last_modified_column; + + $this->sourcedb = \CCR\DB::factory($database, false); + $this->dwdb = \CCR\DB::factory('datawarehouse', false); + + // These are both POSIX timestamps + $this->lastModifiedTs = null; + $this->mostRecentTs = null; + } + + /** + * Return the timestamp of the latest record that + * was sucessfully processed. Or null if there is no + * recorded log entry. + * + * This function also stores the timestamp of the latest + * record that exists in the source table. + * @return string posix timestamp of the row in the source table that + * had been successfully ingested previously or null if there is + * no previous run logged. + */ + public function getLastModified() { + + if (get_class($this->sourcedb) == 'CCR\DB\PostgresDB') { + $srcQuery = 'SELECT FLOOR(EXTRACT(EPOCH FROM ' . $this->lastModifiedColumn . ')) AS most_recent FROM ' . $this->schema . '.' . $this->table . ' ORDER BY ' . $this->lastModifiedColumn . ' DESC LIMIT 1'; + } else { + $srcQuery = 'SELECT UNIX_TIMESTAMP( MAX(' . $this->lastModifiedColumn . ')) + 1 AS most_recent FROM `' . $this->schema . '`.`' . $this->table . '`'; + } + + $mostRecent = $this->sourcedb->query($srcQuery); + + $this->sourcedb->disconnect(); + + if (count($mostRecent) > 0) { + $this->mostRecentTs = $mostRecent[0]['most_recent']; + } + + $lastRunInfo = $this->dwdb->query( + 'SELECT FROM_UNIXTIME(max_index) AS last_modified, max_index AS last_modified_ts FROM modw_etl.log WHERE etlProfileName = ? ORDER BY max_index DESC LIMIT 1', + array($this->schema . '.' . $this->table) + ); + + $this->dwdb->disconnect(); + + $lastModifiedStr = null; + + if (count($lastRunInfo) > 0) { + $this->lastModifiedTs = $lastRunInfo[0]['last_modified_ts']; + if (get_class($this->sourcedb) == 'CCR\DB\PostgresDB') { + $dti = new \DateTimeImmutable('@' . $this->lastModifiedTs); + $lastModifiedStr = $dti->format(\DateTimeInterface::RFC3339); + } else { + $lastModifiedStr = $lastRunInfo[0]['last_modified']; + } + } + + return $lastModifiedStr; + } + + /* + * Record the successful ingestion of the source table with timestamps + * form a the previous call to getLastModified. You MUST only call this + * function on a successful ETL run. DO NOT call in an exception handler + * or finally clause or without checking for errors. + * The timestamps are recorded in the database to allow analysis of ETL + * process runtimes. + * + * @param string $process_start_time The time before the ETL process started in the format date('Y-m-d H:i:s') + * @param string $process_end_time The time after the ETL process ended in the format date('Y-m-d H:i:s') + */ + public function markAsDone($process_start_time, $process_end_time) { + + $markAsDone = $this->dwdb->prepare( + 'INSERT INTO modw_etl.log (etlProfileName, min_index, max_index, start_ts, end_ts) VALUES (?, ?, ?, UNIX_TIMESTAMP(?), UNIX_TIMESTAMP(?))' + ); + + $markAsDone->execute( + array( + $this->schema . '.' . $this->table, + $this->lastModifiedTs, + $this->mostRecentTs, + $process_start_time, + $process_end_time + ) + ); + + $this->dwdb->disconnect(); + } +} diff --git a/classes/ETL/Aggregator/pdoAggregator.php b/classes/ETL/Aggregator/pdoAggregator.php index db2113d718..e47afe427a 100644 --- a/classes/ETL/Aggregator/pdoAggregator.php +++ b/classes/ETL/Aggregator/pdoAggregator.php @@ -83,6 +83,15 @@ class pdoAggregator extends aAggregator // A Query object containing the source query for this ingestor protected $etlSourceQuery = null; + // An optional query object with the intermediate staging query. The intermediate staging query + // is used to support aggregation where two source tables are joined at aggregation + // time. The stage query allows the second source table to also be batched up to improve + // performance. + protected $etlStageQuery = null; + + // The optional query object with the intermediate staging query when using batch mode + protected $etlStageBatchQuery = null; + // This action does not (yet) support multiple destination tables. If multiple destination // tables are present, store the first here and use it. protected $etlDestinationTable = null; @@ -96,6 +105,9 @@ class pdoAggregator extends aAggregator // Unqualified name of the temporary table to use when batching const BATCH_TMP_TABLE_NAME = "agg_tmp"; + // Unqualified name of the temporary table to use when batching staging table + const BATCH_STAGE_TABLE_NAME = "agg_tmp_stage"; + // The INSERT, SELECT, and INSERT INTO ... SELECT statements for the aggregation query. protected $insertSql = null; protected $selectSql = null; @@ -161,6 +173,29 @@ public function initialize(EtlOverseerOptions $etlOverseerOptions = null) ); } // ( null === $this->etlSourceQuery ) + if ( null === $this->etlStageQuery && isset($this->parsedDefinitionFile->stage_query) ) { + $this->logger->debug("Create ETL stage query object"); + $this->etlStageQuery = new Query( + $this->parsedDefinitionFile->stage_query, + $this->sourceEndpoint->getSystemQuoteChar() + ); + $this->etlStageBatchQuery = new Query( + $this->parsedDefinitionFile->stage_query, + $this->sourceEndpoint->getSystemQuoteChar() + ); + + $sourceJoins = $this->etlStageBatchQuery->joins; + $firstJoin = array_shift($sourceJoins); + $newFirstJoin = clone $firstJoin; + $newFirstJoin->name = self::BATCH_STAGE_TABLE_NAME; + $newFirstJoin->schema = $this->sourceEndpoint->getSchema(); + + $this->etlStageBatchQuery->joins = array($newFirstJoin); + foreach ( $sourceJoins as $join ) { + $this->etlStageBatchQuery->addJoin($join); + } + } + // -------------------------------------------------------------------------------- // Create the list of supported macros. Macros starting with a colon (:) are PDO bind // paramaters passed in the loop of dirty date ids. If this list is modified, be sure to update @@ -203,6 +238,10 @@ public function initialize(EtlOverseerOptions $etlOverseerOptions = null) $this->getEtlOverseerOptions()->applyOverseerRestrictions($this->etlSourceQuery, $this->sourceEndpoint, $this); + if ($this->etlStageQuery) { + $this->getEtlOverseerOptions()->applyOverseerRestrictions($this->etlStageQuery, $this->sourceEndpoint, $this); + } + // Group by fields must match existing column names. Variables are not substituted at this point // but it doesn't matter because the naming will still be consistent. @@ -804,7 +843,7 @@ protected function _execute($aggregationUnit) $this->etlSourceQueryModified = false; } // else ( $enableBatchAggregation && ! $this->etlSourceQueryModified ) - $this->buildSqlStatements($aggregationUnit); + list($this->selectSql, $this->insertSql, $this->optimizedInsertSql) = $this->getSqlStatements($this->etlSourceQuery, $aggregationUnit); // ------------------------------------------------------------------------------------------ // Set up the select and insert statements used for aggregation and determine if we can @@ -1021,6 +1060,10 @@ function ($k, $first, $last) { ); } + if ($this->etlStageQuery) { + $this->createStageBatchTempTable($minDayId, $maxDayId, $availableParams); + } + $this->logger->info("[batch aggregation] Setup for batch $minPeriodId - $maxPeriodId (day_id $minDayId - $maxDayId): " . round((microtime(true) - $batchStartTime), 2) . "s"); @@ -1114,6 +1157,13 @@ protected function processAggregationPeriods( return 0; } + if(!$this->destinationHandle->beginTransaction()) { + $this->logAndThrowException( + "Could not start transaction. Skipping ingestion.", + array('endpoint' => $this) + ); + } + $optimize = $this->allowSingleDatabaseOptimization(); $numPeriodsProcessed = 0; @@ -1168,6 +1218,10 @@ protected function processAggregationPeriods( $this->logger->debug("Aggregating $aggregationUnit $periodId"); + if ($this->etlStageQuery !== null) { + $this->stageData($aggregationUnit, $availableParams); + } + if ( $optimize ) { try { @@ -1235,6 +1289,8 @@ protected function processAggregationPeriods( } // foreach ($aggregationPeriodList as $aggregationPeriodInfo) + $this->destinationHandle->commit(); + return $numPeriodsProcessed; } // processAggregationPeriods() @@ -1311,24 +1367,22 @@ protected function allowSingleDatabaseOptimization() * Build the INSERT, SELECT, and INSERT INTO ... SELECT statements for the aggregation * query. Note that the list of fields may contain PDO parameter references. * + * @param $etlQuery the ETL query object to use to create the SQL. * @param $aggregationUnit The current aggregation unit - * @param $includeSchema TRUE if the schema should be included in table names * - * @return TRUE on success, FALSE on failure + * @return array of strings containing the SQL statements for selecting the data from the + * source table, inserting to the dest table, and the optimized single database + * insert into select from, respectively * ------------------------------------------------------------------------------------------ */ - - protected function buildSqlStatements($aggregationUnit, $includeSchema = true) + protected function getSqlStatements($etlQuery, $aggregationUnit) { // Build the statements for this aggregation unit. The source query may contain variables that, // when substituted, will result in duplicate column names (e.g., "year" in the aggregation - // tables). Remove duplicates, keeping only the first one, and add them back to the query after - // generating the SQL. + // tables). Remove duplicates, keeping only the first one. - // *** Should this functionality be included in the Query itself? *** - - $sourceRecords = $this->etlSourceQuery->records; + $sourceRecords = $etlQuery->records; $substitutedRecordNames = array(); $duplicateRecords = array(); @@ -1337,7 +1391,7 @@ protected function buildSqlStatements($aggregationUnit, $includeSchema = true) $substitutedName = $this->variableStore->substitute($name); if ( in_array($substitutedName, $substitutedRecordNames) ) { - $duplicateRecords[$name] = $this->etlSourceQuery->removeRecord($name); + $duplicateRecords[$name] = $etlQuery->removeRecord($name); $msg = "Duplicate column after substitution: (\"$name: $formula\") '$name' -> '$substitutedName'"; // Note that we are logging duplicate year columns differently because it is known @@ -1355,43 +1409,139 @@ protected function buildSqlStatements($aggregationUnit, $includeSchema = true) } } - $this->selectSql = $this->etlSourceQuery->getSql($includeSchema); + $selectSql = $etlQuery->getSql(true); - $this->insertSql = "INSERT INTO " . $this->etlDestinationTable->getFullName($includeSchema) . "\n" . + $insertSql = "INSERT INTO " . $this->etlDestinationTable->getFullName(true) . "\n" . "(" - . implode(",\n", $this->quoteIdentifierNames(array_keys($this->etlSourceQuery->records))) + . implode(",\n", $this->quoteIdentifierNames(array_keys($etlQuery->records))) . ")\nVALUES\n(" - . implode(",\n", Utilities::createPdoBindVarsFromArrayKeys($this->etlSourceQuery->records)) + . implode(",\n", Utilities::createPdoBindVarsFromArrayKeys($etlQuery->records)) . ")"; - $this->optimizedInsertSql = "INSERT INTO " . $this->etlDestinationTable->getFullName($includeSchema) . "\n" . + $optimizedInsertSql = "INSERT INTO " . $this->etlDestinationTable->getFullName(true) . "\n" . "(" . - implode(",\n", $this->quoteIdentifierNames(array_keys($this->etlSourceQuery->records))) + implode(",\n", $this->quoteIdentifierNames(array_keys($etlQuery->records))) . ")\n" . - $this->selectSql; + $selectSql; - $this->selectSql = $this->variableStore->substitute( - $this->selectSql, + $selectSql = $this->variableStore->substitute( + $selectSql, "Undefined macros found in select SQL" ); - $this->insertSql = $this->variableStore->substitute( - $this->insertSql, + $insertSql = $this->variableStore->substitute( + $insertSql, "Undefined macros found in insert SQL" ); - $this->optimizedInsertSql = $this->variableStore->substitute( - $this->optimizedInsertSql, + $optimizedInsertSql = $this->variableStore->substitute( + $optimizedInsertSql, "Undefined macros found in optimized insert SQL" ); - // Put any records that we removed back into the Query + return array($selectSql, $insertSql, $optimizedInsertSql); + } - foreach ( $duplicateRecords as $record => $formula) { - $this->etlSourceQuery->addRecord($record, $formula); + /** + * Create and populate the temporary table used in batch mode aggregation + * as defined by the "stage_query" parameter setting in the ETL action + * definition. This table will contain all of the rows between the specified + * start and end periods. + * + * @param $minDayId string the start of the batch aggregation slide + * @param $maxDayId string the end of the batch aggregation slide + * @param $availableParams array the PDO parameters to substitute in the select statement + */ + protected function createStageBatchTempTable($minDayId, $maxDayId, $availableParams) + { + $qualifiedTmpTableName = $this->sourceEndpoint->getSchema(true) . "." . $this->sourceEndpoint->quoteSystemIdentifier(self::BATCH_STAGE_TABLE_NAME); + $origTableName = $this->sourceEndpoint->getSchema(true) . "." . $this->sourceEndpoint->quoteSystemIdentifier($this->etlStageQuery->joins[0]->name); + $tmpTableAlias = $this->sourceEndpoint->quoteSystemIdentifier($this->etlStageQuery->joins[0]->alias); + + $this->logger->debug("[batch aggregation] Create temporary table $qualifiedTmpTableName with min period = $minDayId, max period = $maxDayId"); + + $sql = "DROP TEMPORARY TABLE IF EXISTS $qualifiedTmpTableName"; + + try { + $this->sourceHandle->execute($sql); + } catch (PDOException $e ) { + $this->logAndThrowException( + "Error removing temporary batch stage table", + array('exception' => $e, 'sql' => $sql) + ); } - return true; + try { + $whereClause = $this->variableStore->substitute( + implode(" AND ", $this->etlStageBatchQuery->where), + "Undefined macros found in WHERE clause" + ); + + $matches = array(); + $bindParams = array(); + preg_match_all('/(:[a-zA-Z0-9_-]+)/', $whereClause, $matches); + $bindParams = $matches[0]; + $usedParams = array_intersect_key($availableParams, array_fill_keys($bindParams, 0)); + + $sql = + "CREATE TEMPORARY TABLE $qualifiedTmpTableName AS " + . "SELECT * FROM $origTableName $tmpTableAlias WHERE " . $whereClause; + + $this->logger->debug( + sprintf("[batch aggregation] Batch temp table %s: %s", $this->sourceEndpoint, $sql) + ); + $this->sourceHandle->execute($sql, $usedParams); + } catch (PDOException $e ) { + $this->logAndThrowException( + "Error creating temporary batch aggregation table", + array('exception' => $e, 'sql' => $sql) + ); + } + } + + /** + * Create a temporary table that contains the rows for a given time + * period in batch mode aggregation. The table contents will be determined + * by the "stage_query" parameter setting in the ETL action definition. + * + * @param $aggregationUnit string the aggregation unit year, month, day, etc + * @param $availableParams array the PDO parameters in the select query + */ + protected function stageData($aggregationUnit, $availableParams) + { + if ($this->etlStageQuery === null) { + return; + } + + $query = $this->etlStageQuery; + if ($this->etlSourceQueryModified) { + $query = $this->etlStageBatchQuery; + } + + list($stageSelectSql, $unused, $unused) = $this->getSqlStatements($query, $aggregationUnit); + + $stageTableName = $this->sourceEndpoint->getSchema(true) . "." . $this->sourceEndpoint->quoteSystemIdentifier($this->etlSourceQuery->joins[1]->name); + + $matches = array(); + $bindParamRegex = '/(:[a-zA-Z0-9_-]+)/'; + preg_match_all($bindParamRegex, $stageSelectSql, $matches); + $discoveredStageBindParams = array_unique($matches[0]); + + $stagePrepSQL = "DROP TEMPORARY TABLE IF EXISTS $stageTableName"; + $stageInsertSQL = "CREATE TEMPORARY TABLE $stageTableName $stageSelectSql"; + + $this->logger->debug("DROP stage table"); + + $this->destinationHandle->execute($stagePrepSQL); + + $stageStmt = $this->destinationHandle->prepare($stageInsertSQL); + $stageBindParams = array_intersect_key($availableParams, array_fill_keys($discoveredStageBindParams, 0)); + + $this->logger->debug("Create STAGE table " . json_encode($stageBindParams) ); + + $stageStmt->execute($stageBindParams); + + $this->logger->debug("STAGE query rowCount " . $stageStmt->rowCount()); - } // buildSqlStatements() + } } // class pdoAggregator diff --git a/classes/ETL/EtlOverseerOptions.php b/classes/ETL/EtlOverseerOptions.php index 628e6296bd..b724fa75e5 100644 --- a/classes/ETL/EtlOverseerOptions.php +++ b/classes/ETL/EtlOverseerOptions.php @@ -415,20 +415,32 @@ public function getLastModifiedStartDate() * database. If date is NULL, use the current date to ensure that the date is always set. * * @param $date A date representation or null to use the current date. + * @param $parseDateString If true then try to parse the date string into a mysql-compatible + * local time string. If false then use the string unmodified. The + * parseDateString option is ignore if a date of NULL is used. * * @return This object to support method chaining. * ------------------------------------------------------------------------------------------ */ - public function setLastModifiedStartDate($date) + public function setLastModifiedStartDate($date, $parseDateString = true) { if ( null === $date ) { $this->lastModifiedStartDate = null; - } elseif ( false === ( $ts = strtotime($date)) ) { - $msg = get_class($this) . ": Could not parse last modified start date '$date'"; - throw new Exception($msg); + return $this; + } + + if ($parseDateString) { + $ts = strtotime($date); + + if ($ts === false) { + $msg = get_class($this) . ": Could not parse last modified start date '$date'"; + throw new Exception($msg); + } else { + $this->lastModifiedStartDate = date("Y-m-d H:i:s", $ts); + } } else { - $this->lastModifiedStartDate = date("Y-m-d H:i:s", $ts); + $this->lastModifiedStartDate = $date; } return $this; diff --git a/classes/OpenXdmod/Migration/DatabasesMigration.php b/classes/OpenXdmod/Migration/DatabasesMigration.php index bb2fdbc1ed..114038f130 100644 --- a/classes/OpenXdmod/Migration/DatabasesMigration.php +++ b/classes/OpenXdmod/Migration/DatabasesMigration.php @@ -131,9 +131,14 @@ protected function updateDatabase($section, $file) protected function requestMysqlAdminCredentials() { if (self::$mysqlAdminCredentials === null) { + echo <<<"EOF" +One or more migrations in this upgrade require admin credentials for the +MySQL database. + +EOF; $console = Console::factory(); $proceed = $console->prompt( - "One or more migrations in this upgrade require admin credentials for the MySQL database. Would you like to proceed?", + "Would you like to proceed?", 'yes', array('yes', 'no') ) === 'yes'; diff --git a/classes/OpenXdmod/Migration/Version1102To1150/DatabasesMigration.php b/classes/OpenXdmod/Migration/Version1102To1150/DatabasesMigration.php index 0f252f99ff..c8d96203e1 100644 --- a/classes/OpenXdmod/Migration/Version1102To1150/DatabasesMigration.php +++ b/classes/OpenXdmod/Migration/Version1102To1150/DatabasesMigration.php @@ -6,6 +6,7 @@ namespace OpenXdmod\Migration\Version1102To1150; use OpenXdmod\Migration\DatabasesMigration as AbstractDatabasesMigration; +use OpenXdmod\Shared\DatabaseHelper; use ETL\Utilities; use CCR\DB; use CCR\DB\MySQLHelper; @@ -14,6 +15,21 @@ class DatabasesMigration extends AbstractDatabasesMigration { public function execute() { + $dataWarehouseAdminCredentials = $this->requestMysqlAdminCredentials(); + + // Create the modw_etl schema. + DatabaseHelper::createDatabases( + $dataWarehouseAdminCredentials['user'], + $dataWarehouseAdminCredentials['pass'], + array( + 'db_host' => \xd_utilities\getConfiguration('datawarehouse', 'host'), + 'db_port' => \xd_utilities\getConfiguration('datawarehouse', 'port'), + 'db_user' => \xd_utilities\getConfiguration('datawarehouse', 'user'), + 'db_pass' => \xd_utilities\getConfiguration('datawarehouse', 'pass'), + ), + array('modw_etl') + ); + parent::execute(); $dbh = DB::factory('datawarehouse'); diff --git a/classes/OpenXdmod/Setup/DatabaseSetup.php b/classes/OpenXdmod/Setup/DatabaseSetup.php index 3a785441c0..dc78a66511 100644 --- a/classes/OpenXdmod/Setup/DatabaseSetup.php +++ b/classes/OpenXdmod/Setup/DatabaseSetup.php @@ -99,6 +99,7 @@ public function handle() 'modw_aggregates', 'modw_filters', 'mod_logger', + 'modw_etl', ); $this->createDatabases( @@ -150,7 +151,8 @@ public function handle() 'staging-bootstrap', 'hpcdb-bootstrap', 'acls-xdmod-management', - 'logger-bootstrap' + 'logger-bootstrap', + 'etl-journal-bootstrap' ), $logger); diff --git a/configuration/etl/etl.d/journal.json b/configuration/etl/etl.d/journal.json new file mode 100644 index 0000000000..497f0be923 --- /dev/null +++ b/configuration/etl/etl.d/journal.json @@ -0,0 +1,27 @@ +{ + "defaults": { + "global": { + "endpoints": { + "destination": { + "type": "mysql", + "name": "Logger database", + "config": "logger", + "schema": "modw_etl", + "create_schema_if_not_exists": true + } + } + } + }, + "etl-journal-bootstrap": [ + { + "name": "manage-tables", + "description": "Manage mod_logger tables", + "namespace": "ETL\\Maintenance", + "class": "ManageTables", + "options_class": "MaintenanceOptions", + "definition_file_list": [ + "journal/log.json" + ] + } + ] +} diff --git a/configuration/etl/etl.d/xdmod-migration-11_0_2-11_5_0.json b/configuration/etl/etl.d/xdmod-migration-11_0_2-11_5_0.json index db765db610..aadcd698c4 100644 --- a/configuration/etl/etl.d/xdmod-migration-11_0_2-11_5_0.json +++ b/configuration/etl/etl.d/xdmod-migration-11_0_2-11_5_0.json @@ -109,6 +109,24 @@ "logger/log_table.json" ] }, + { + "name": "update-journal-tables", + "description": "Update managed tables in modw_etl", + "namespace": "ETL\\Maintenance", + "options_class": "MaintenanceOptions", + "class": "ManageTables", + "endpoints": { + "destination": { + "type": "mysql", + "name": "Logging Database", + "config": "database", + "schema": "modw_etl" + } + }, + "definition_file_list": [ + "journal/log.json" + ] + }, { "name": "update-cloud-tables", "description": "Update managed tables in modw_cloud", diff --git a/configuration/etl/etl_tables.d/journal/log.json b/configuration/etl/etl_tables.d/journal/log.json new file mode 100644 index 0000000000..4b0bc05b1e --- /dev/null +++ b/configuration/etl/etl_tables.d/journal/log.json @@ -0,0 +1,83 @@ +{ + "table_definition": { + "schema": "modw_etl", + "name": "log", + "engine": "InnoDB", + "charset": "utf8", + "collation": "utf8_unicode_ci", + "columns": [ + { + "name": "id", + "type": "int(11)", + "extra": "AUTO_INCREMENT", + "nullable": false + }, + { + "name": "etlProfileName", + "type": "varchar(256)", + "nullable": true + }, + { + "name": "etlProfileVersion", + "type": "int(11)", + "nullable": true + }, + { + "name": "dataset", + "type": "varchar(256)", + "nullable": true + }, + { + "name": "start_ts", + "type": "bigint(20)", + "nullable": true + }, + { + "name": "end_ts", + "type": "bigint(20)", + "nullable": true + }, + { + "name": "min_index", + "type": "bigint(20)", + "nullable": true + }, + { + "name": "max_index", + "type": "bigint(20)", + "nullable": true + }, + { + "name": "processed", + "type": "int(11)", + "nullable": true + }, + { + "name": "good", + "type": "int(11)", + "nullable": true + }, + { + "name": "details", + "type": "text", + "nullable": true + }, + { + "name": "aggregated", + "type": "tinyint(1)", + "nullable": false, + "default": 0 + } + ], + "indexes": [ + { + "name": "PRIMARY", + "columns": [ + "id" + ], + "type": "BTREE", + "is_unique": true + } + ] + } +} diff --git a/docs/commands.md b/docs/commands.md index 67d31f60f6..ac6234e895 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -26,6 +26,7 @@ tasks. Currently supported tasks are listed in the table below: | Task | Example command line | Description | | ---- | -------------------- | ----------- | | Delete Jobs Data | `xdmod-admin --truncate --jobs` | This command removes all data from the Jobs realm. | +| Delete Jobs Data for Resource | `xdmod-admin --jobs --delete RESOURCE_NAME` | This command removes all data for a specific resource in the Jobs Realm. | | List configured resources | `xdmod-admin --list --resources` | This command lists all resources that are configured in the resources.json configuration file. | | Preconfigure SSO user accounts | `xdmod-admin --users --load PATH/TO/USERSFILE.csv` | Preconfigure user account settings for SSO users. | diff --git a/docs/faq.md b/docs/faq.md index 5c00b163d3..20af5794f6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -72,6 +72,16 @@ command: Running this command will truncate all the tables containing job data and you can then re-shred and re-ingest your resource manager data. +### How do I delete all my job data for a specific resource in Open XDMoD? + +If you want to delete the job data for a particular resource you can use the following +command: + + $ xdmod-admin --jobs --delete RESOURCE_NAME + +Running this command will delete all the jobs for that resource in the tables containing job data +and you can then re-shred and re-ingest for that resource. + ### Why do I see the error message "It is not safe to rely on the system's timezone settings..."? You need to set your timezone in your `php.ini` file. Add the diff --git a/tests/ci/scripts/xdmod-upgrade.tcl b/tests/ci/scripts/xdmod-upgrade.tcl index 43b74ebffd..6914f9808e 100644 --- a/tests/ci/scripts/xdmod-upgrade.tcl +++ b/tests/ci/scripts/xdmod-upgrade.tcl @@ -22,6 +22,42 @@ set timeout 180 spawn "xdmod-upgrade" confirmUpgrade +expect { + timeout { + send_user "\nFailed to get prompt\n"; exit 1 + } + -re "\nWould you like to proceed.*\\\] " { + send yes\n + } +} + +expect { + timeout { + send_user "\nFailed to get prompt\n"; exit 1 + } + -re "\nMySQL Admin Username: \\\[.*\\\] " { + send root\n + } +} + +expect { + timeout { + send_user "\nFailed to get prompt\n"; exit 1 + } + -re "\nMySQL Admin Password: " { + send \n + } +} + +expect { + timeout { + send_user "\nFailed to get prompt\n"; exit 1 + } + -re "\n\\(confirm\\) MySQL Admin Password: " { + send \n + } +} + expect { timeout { send_user "\nFailed to get prompt\n"; exit 1 diff --git a/tests/component/lib/DB/EtlJournalTest.php b/tests/component/lib/DB/EtlJournalTest.php new file mode 100644 index 0000000000..fe4a9d198d --- /dev/null +++ b/tests/component/lib/DB/EtlJournalTest.php @@ -0,0 +1,30 @@ +execute("DROP TABLE IF EXISTS `$table_name`"); + $db->execute("CREATE TABLE `$table_name` (`id` INT(11), `last_modified` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp())"); + $db->execute("INSERT INTO `$table_name` (id) VALUES(1)"); + $ref_data = $db->query("SELECT FROM_UNIXTIME(UNIX_TIMESTAMP(last_modified) + 1) AS lm FROM `$table_name` WHERE id = 1"); + + $helper = new \DB\EtlJournalHelper('modw', $table_name); + + $last_modified_1 = $helper->getLastModified(); + + $this->assertNull($last_modified_1); + + $helper->markasDone('2025-01-01', '2025-01-31'); + + $last_modified_2 = $helper->getLastModified(); + + $this->assertEquals($ref_data[0]['lm'], $last_modified_2); + + $db->execute("DROP TABLE `$table_name`"); + } +} From f2d87205d81607d2bfc2ccfc874776ccc6f7f69e Mon Sep 17 00:00:00 2001 From: Joseph White Date: Mon, 2 Mar 2026 11:34:15 -0500 Subject: [PATCH 72/83] Move html unit test files to new location --- {html => tests/html}/unit_tests/.eslintrc.json | 0 {html => tests/html}/unit_tests/coverage.html | 0 {html => tests/html}/unit_tests/index.html | 0 {html => tests/html}/unit_tests/spec/.eslintrc.json | 0 {html => tests/html}/unit_tests/spec/CCRTokenizeSpec.js | 0 {html => tests/html}/unit_tests/spec/ChangeStackSpec.js | 0 {html => tests/html}/unit_tests/spec/JobViewerSpec.js | 0 {html => tests/html}/unit_tests/spec/XDMoDFormatSpec.js | 0 tests/playwright/install_unit.sh | 2 +- 9 files changed, 1 insertion(+), 1 deletion(-) rename {html => tests/html}/unit_tests/.eslintrc.json (100%) rename {html => tests/html}/unit_tests/coverage.html (100%) rename {html => tests/html}/unit_tests/index.html (100%) rename {html => tests/html}/unit_tests/spec/.eslintrc.json (100%) rename {html => tests/html}/unit_tests/spec/CCRTokenizeSpec.js (100%) rename {html => tests/html}/unit_tests/spec/ChangeStackSpec.js (100%) rename {html => tests/html}/unit_tests/spec/JobViewerSpec.js (100%) rename {html => tests/html}/unit_tests/spec/XDMoDFormatSpec.js (100%) diff --git a/html/unit_tests/.eslintrc.json b/tests/html/unit_tests/.eslintrc.json similarity index 100% rename from html/unit_tests/.eslintrc.json rename to tests/html/unit_tests/.eslintrc.json diff --git a/html/unit_tests/coverage.html b/tests/html/unit_tests/coverage.html similarity index 100% rename from html/unit_tests/coverage.html rename to tests/html/unit_tests/coverage.html diff --git a/html/unit_tests/index.html b/tests/html/unit_tests/index.html similarity index 100% rename from html/unit_tests/index.html rename to tests/html/unit_tests/index.html diff --git a/html/unit_tests/spec/.eslintrc.json b/tests/html/unit_tests/spec/.eslintrc.json similarity index 100% rename from html/unit_tests/spec/.eslintrc.json rename to tests/html/unit_tests/spec/.eslintrc.json diff --git a/html/unit_tests/spec/CCRTokenizeSpec.js b/tests/html/unit_tests/spec/CCRTokenizeSpec.js similarity index 100% rename from html/unit_tests/spec/CCRTokenizeSpec.js rename to tests/html/unit_tests/spec/CCRTokenizeSpec.js diff --git a/html/unit_tests/spec/ChangeStackSpec.js b/tests/html/unit_tests/spec/ChangeStackSpec.js similarity index 100% rename from html/unit_tests/spec/ChangeStackSpec.js rename to tests/html/unit_tests/spec/ChangeStackSpec.js diff --git a/html/unit_tests/spec/JobViewerSpec.js b/tests/html/unit_tests/spec/JobViewerSpec.js similarity index 100% rename from html/unit_tests/spec/JobViewerSpec.js rename to tests/html/unit_tests/spec/JobViewerSpec.js diff --git a/html/unit_tests/spec/XDMoDFormatSpec.js b/tests/html/unit_tests/spec/XDMoDFormatSpec.js similarity index 100% rename from html/unit_tests/spec/XDMoDFormatSpec.js rename to tests/html/unit_tests/spec/XDMoDFormatSpec.js diff --git a/tests/playwright/install_unit.sh b/tests/playwright/install_unit.sh index 66467792f3..7dd68e381c 100755 --- a/tests/playwright/install_unit.sh +++ b/tests/playwright/install_unit.sh @@ -2,4 +2,4 @@ BASEDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPODIR=$(realpath $BASEDIR/../../) -cp -r $REPODIR/html/unit_tests /usr/share/xdmod/html +cp -r $REPODIR/tests/html/unit_tests /usr/share/xdmod/html From 4e7271f760a40c1b9c358b68db06c074fcee49e7 Mon Sep 17 00:00:00 2001 From: Joseph White Date: Mon, 2 Mar 2026 12:24:58 -0500 Subject: [PATCH 73/83] Eslint configuration sync --- tests/html/.eslintrc.json | 1 + 1 file changed, 1 insertion(+) create mode 120000 tests/html/.eslintrc.json diff --git a/tests/html/.eslintrc.json b/tests/html/.eslintrc.json new file mode 120000 index 0000000000..f10469a2a4 --- /dev/null +++ b/tests/html/.eslintrc.json @@ -0,0 +1 @@ +../../html/.eslintrc.json \ No newline at end of file From 6cbcffa09dbd4318e6dac75bc8da125985419324 Mon Sep 17 00:00:00 2001 From: Joseph White Date: Mon, 2 Mar 2026 13:31:37 -0500 Subject: [PATCH 74/83] Fix linting errors. These errors were in the original non-symfony code, but the file move caused them to be alerted on. Might as well fix now we know about them (low risk due to being in test code). --- tests/html/unit_tests/.eslintrc.json | 1 - tests/html/unit_tests/coverage.html | 5 +- tests/html/unit_tests/index.html | 1 - tests/html/unit_tests/spec/.eslintrc.json | 4 +- tests/html/unit_tests/spec/CCRTokenizeSpec.js | 42 +++---- tests/html/unit_tests/spec/ChangeStackSpec.js | 106 +++++++++--------- tests/html/unit_tests/spec/JobViewerSpec.js | 32 +++--- tests/html/unit_tests/spec/XDMoDFormatSpec.js | 22 ++-- 8 files changed, 106 insertions(+), 107 deletions(-) diff --git a/tests/html/unit_tests/.eslintrc.json b/tests/html/unit_tests/.eslintrc.json index 2316c5085c..cfcb8e5418 100644 --- a/tests/html/unit_tests/.eslintrc.json +++ b/tests/html/unit_tests/.eslintrc.json @@ -6,4 +6,3 @@ "mocha": true } } - diff --git a/tests/html/unit_tests/coverage.html b/tests/html/unit_tests/coverage.html index 25135854ae..c68a9925f6 100644 --- a/tests/html/unit_tests/coverage.html +++ b/tests/html/unit_tests/coverage.html @@ -27,7 +27,7 @@ - + @@ -43,7 +43,7 @@ - + - - - - - - @@ -28,8 +20,8 @@ jQuery.noConflict(); - + - + diff --git a/tests/ci/bootstrap.sh b/tests/ci/bootstrap.sh index e6b7273dcf..64bc55ee96 100755 --- a/tests/ci/bootstrap.sh +++ b/tests/ci/bootstrap.sh @@ -185,3 +185,6 @@ then # Restart so that the above changes take effect. ~/bin/services restart fi + +# Clearing the Symfony cache so that we start fresh. +console cache:clear diff --git a/tests/ci/samlSetup.sh b/tests/ci/samlSetup.sh index 0080e82dc3..26bd629ee1 100755 --- a/tests/ci/samlSetup.sh +++ b/tests/ci/samlSetup.sh @@ -33,10 +33,37 @@ color () { log () { header=$1 message=$2 + + # echo out the log entry to saml_setup.log so we have a "durable" place to look for these logs. + echo "[$header] $message" >> /var/log/xdmod/saml_setup.log + + # output the pretty colored messages for when we're watching the build / setup process. prefix=$(color $header 'green') printf "[$prefix] $message\n" } +function configurePortalSettings() +{ + host=$1; + log "xdmod" "Configuring /etc/xdmod/portal_settings.ini" + + grep -ie "auth_referer=https://xdmod:7000" /etc/xdmod/portal_settings.ini + exit_code=$? + if [[ $exit_code -eq 1 ]]; then + log "xdmod" "Updating auth_referer in portal_settings.ini" + # Add the auth_referer property to portal_settings.ini + sed -i "s|auth_referer=|auth_referer=$host|g" /etc/xdmod/portal_settings.ini + + # Make sure that the cache is reset so that `auth_referer` shows up in Symfony at runtime. + log "xdmod" "Clearing Symfony cache" + console cache:clear + else + log "xdmod" "portal_settings already has an auth_referer, skipping" + fi + + log "xdmod" "portal_settings.ini configured!" +} + function configureSimplesamlPHP() { log "SimpleSamlPHP" "Stopping HTTPD" @@ -170,7 +197,7 @@ localSSO() { cd /tmp || exit log "setup" "installing saml idp server" - if [ -f $CACHE_FILE ]; + if [[ -f $CACHE_FILE ]]; then log "setup" "using cached copy" tar -zxf $CACHE_FILE @@ -287,10 +314,13 @@ EOF # Configure SimplesamlPHP, stops / starts httpd as appropriate configureSimplesamlPHP; + log "xdmod" "Configuring XDMoD" + configurePortalSettings "https://xdmod:7000" + AUD_URL=https://$HOSTNAME/xdmod-sp # The ACS url is the only one that needs the port specified. - if [ -n "$PORT" ]; then + if [[ -n "$PORT" ]]; then HOSTNAME="${HOSTNAME}:${PORT}" fi @@ -314,7 +344,7 @@ if [[ "$TYPE" == 'local' ]]; then log "settings" "Host: $HOSTNAME" log "settings" "Port: $PORT" localSSO -elif [ "$TYPE" == "keycloak" ]; then +elif [[ "$TYPE" == "keycloak" ]]; then keycloakSSO else echo "You must provide a type of setup ( -t ) to continue "; diff --git a/tests/ci/scripts/xdmod-setup-start.tcl b/tests/ci/scripts/xdmod-setup-start.tcl index 8bcfdba4e4..a7a7dda4c9 100644 --- a/tests/ci/scripts/xdmod-setup-start.tcl +++ b/tests/ci/scripts/xdmod-setup-start.tcl @@ -22,8 +22,6 @@ provideInput {Center Logo Path:} {} provideInput {Enable Dashboard Tab*} {off} confirmFileWrite yes enterToContinue -confirmFileWrite yes -enterToContinue selectMenuOption 2 answerQuestion {DB Hostname or IP} localhost diff --git a/tests/integration/lib/TestHarness/XdmodTestHelper.php b/tests/integration/lib/TestHarness/XdmodTestHelper.php index 1b71160f39..aa90e0963e 100644 --- a/tests/integration/lib/TestHarness/XdmodTestHelper.php +++ b/tests/integration/lib/TestHarness/XdmodTestHelper.php @@ -197,7 +197,7 @@ private function getHTMLFormData($html) */ public function authenticateSSO($parameters, $includeDefault = true) { - $result = $this->get('rest/auth/idpredirect', array('returnTo' => '/gui/general/login.php')); + $result = $this->get('rest/auth/idpredirect', array('returnTo' => '/')); $nextlocation = $result[0]; $result = $this->get($nextlocation, null, true); diff --git a/tests/playwright/Docker/docker-compose.yml b/tests/playwright/Docker/docker-compose.yml index 87f1f2874c..fac17f9c81 100644 --- a/tests/playwright/Docker/docker-compose.yml +++ b/tests/playwright/Docker/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: xdmod hostname: xdmod networks: - - testing-rocky8 + - xdmod_network shm_size: 2g ports: - 9006:443 @@ -15,11 +15,11 @@ services: tty: true command: sleep infinity playwright: - image: mcr.microsoft.com/playwright:v1.58.0-jammy + image: mcr.microsoft.com/playwright:v1.59.1-jammy hostname: playwright container_name: playwright networks: - - testing-rocky8 + - xdmod_network stdin_open: true tty: true ipc: host @@ -32,4 +32,4 @@ services: XDMOD_REALMS: 'jobs,storage,cloud,resourcespecifications' command: sleep infinity networks: - testing-rocky8: + xdmod_network: diff --git a/tests/unit/lib/OpenXdmod/Tests/Shredder/SlurmShredderTest.php b/tests/unit/lib/OpenXdmod/Tests/Shredder/SlurmShredderTest.php index 1c8cd0db15..92baf094f9 100644 --- a/tests/unit/lib/OpenXdmod/Tests/Shredder/SlurmShredderTest.php +++ b/tests/unit/lib/OpenXdmod/Tests/Shredder/SlurmShredderTest.php @@ -101,7 +101,7 @@ public function testNonEndedJobStateHandling($line, $messages) $logger = $this - ->getMockBuilder('\CCR\Logger') + ->getMockBuilder('\Monolog\Logger') ->setConstructorArgs(array('slurm-shredder-test')) ->onlyMethods(['debug', 'warning']) ->getMock(); @@ -140,7 +140,7 @@ public function testUnknownJobStateHandling($line, $messages) ->method('insertRow'); $logger = $this - ->getMockBuilder('\CCR\Logger') + ->getMockBuilder('\Monolog\Logger') ->setConstructorArgs(array('slurm-shredder-test')) ->onlyMethods(['debug', 'warning']) ->getMock(); From d9679106b998d087980c4f2b67092ab56710ad21 Mon Sep 17 00:00:00 2001 From: Conner Saeli <51850219+connersaeli@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:34:56 -0400 Subject: [PATCH 81/83] DotEnvConfigMigration for Symfony Migration (#2187) * Refactoring dotenv setup and migration. * Fixing incorrect import. --- .../Migration/DotEnvConfigMigration.php | 20 +-------- classes/OpenXdmod/Setup/GeneralSetup.php | 21 +--------- classes/OpenXdmod/Setup/SetupItem.php | 5 --- src/Helper/SymfonyCommandHelper.php | 42 ++++++++++++------- 4 files changed, 29 insertions(+), 59 deletions(-) diff --git a/classes/OpenXdmod/Migration/DotEnvConfigMigration.php b/classes/OpenXdmod/Migration/DotEnvConfigMigration.php index 4bd541e352..99f603066e 100644 --- a/classes/OpenXdmod/Migration/DotEnvConfigMigration.php +++ b/classes/OpenXdmod/Migration/DotEnvConfigMigration.php @@ -12,27 +12,9 @@ public function execute() { $dotEnvPath = BASE_DIR . '/.env'; if (!file_exists($dotEnvPath)) { - // .env doesn't need anything in it, but it does need to exist. file_put_contents(BASE_DIR . '/.env', ''); - - // Make sure to clear the cache before dumping the dotenv so we start clean. - try { - SymfonyCommandHelper::executeCommand('cache:clear'); - } catch (\Exception $e) { - throw new \RuntimeException('Error occurred executing cache:clear', $e); - } - - - // Dump dotenv data so we don't read .env each time in prod. - // Note: this means that if you want to start debugging stuff you'll need to delete the generated .env. - try { - SymfonyCommandHelper::executeCommand("dotenv:dump"); - } catch (\Exception $e) { - throw new \RuntimeException('Error occurred executing dotenv:dump', $e); - } - + SymfonyCommandHelper::dumpDotEnv(); } } - } diff --git a/classes/OpenXdmod/Setup/GeneralSetup.php b/classes/OpenXdmod/Setup/GeneralSetup.php index 6e1ec58926..a8bd35acb4 100644 --- a/classes/OpenXdmod/Setup/GeneralSetup.php +++ b/classes/OpenXdmod/Setup/GeneralSetup.php @@ -128,24 +128,7 @@ public function handle() $this->saveIniConfig($settings, 'portal_settings'); - # we don't actually need any of the contents of .env but it does need to exist. - file_put_contents(BASE_DIR . '/.env', ""); - - // Make sure to clear the cache before dumping the dotenv so we start clean. - try { - SymfonyCommandHelper::executeCommand('cache:clear'); - } catch (\Exception $e) { - throw new \RuntimeException('Error occurred executing cache:clear', $e); - } - - - // Dump dotenv data so we don't read .env each time in prod. - // Note: this means that if you want to start debugging stuff you'll need to delete the generated .env. - try { - SymfonyCommandHelper::executeCommand("dotenv:dump"); - } catch (\Exception $e) { - throw new \RuntimeException('Error occurred executing dotenv:dump', $e); - } - + file_put_contents(BASE_DIR . '/.env', ''); + SymfonyCommandHelper::dumpDotEnv(); } } diff --git a/classes/OpenXdmod/Setup/SetupItem.php b/classes/OpenXdmod/Setup/SetupItem.php index 3c05272392..b3e4791a56 100644 --- a/classes/OpenXdmod/Setup/SetupItem.php +++ b/classes/OpenXdmod/Setup/SetupItem.php @@ -5,11 +5,6 @@ namespace OpenXdmod\Setup; -use CCR\Kernel; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Dotenv\Dotenv; use xd_utilities; use CCR\Json; use Xdmod\Template; diff --git a/src/Helper/SymfonyCommandHelper.php b/src/Helper/SymfonyCommandHelper.php index 2b85cc898b..dadee13d6e 100644 --- a/src/Helper/SymfonyCommandHelper.php +++ b/src/Helper/SymfonyCommandHelper.php @@ -7,6 +7,8 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Dotenv\Dotenv; +use Symfony\Component\Dotenv\Exception\FormatException; +use Symfony\Component\Dotenv\Exception\PathException; /** * The purpose of this class is to serve as a bridge to allow our existing code in `classes` to execute Symfony Commands @@ -17,7 +19,6 @@ */ class SymfonyCommandHelper { - /** * Execute the provided Symfony $command. * @@ -27,47 +28,46 @@ class SymfonyCommandHelper * @throws \Exception if an error is encountered while running the specified command. * @throws \RuntimeException if a non-zero exit code is returned by the Symfony Command. */ - public static function executeCommand(string $command, string $env = 'prod', bool $debug = false, array $options = []): void + public static function executeCommand(string $command, array $options = [], string $env = 'prod', bool $debug = false): void { - list($statusCode, $output) = self::doExecuteCommand($command, $env, $debug, $options); + list($statusCode, $output) = self::doExecuteCommand($command, $options, $env, $debug); if ($statusCode !== 0) { - throw new \RuntimeException("Error Running $command\n$output"); + throw new \RuntimeException("Error Running Symfony Command $command\n$output"); } } /** - * The function that actually executes the Symfony commmand. + * Execute the provided Symfony console command. * * @param string $command + * @param array $options * @param string $env * @param bool $debug - * @param array $options - * @return array - * @throws \LogicException - * @throws \Exception - * @throws \RuntimeException + * @throws \LogicException if the command is empty. + * @throws \RuntimeException if the environment file cannot be loaded or + * if a non-zero exit code is returned by the Symfony console command */ - private static function doExecuteCommand(string $command, string $env, bool $debug, array $options): array + private static function doExecuteCommand(string $command, array $options, string $env, bool $debug): array { if (empty($command)) { throw new \LogicException('Command must not be empty.'); } try { - $envPath = BASE_DIR . "/.env"; + $envPath = BASE_DIR . '/.env'; (new Dotenv())->bootEnv($envPath); - } catch(\Exception $e) { - throw new \RuntimeException('Error booting the Symfony Environment', $e->getCode(), $e); + } catch(FormatException | PathException $e) { + throw new \RuntimeException('Unable to load environment file', $e->getCode(), $e); } - // Setup our Kernel / Application. + // Setup our Kernel / Application $kernel = new Kernel($env, $debug); $application = new Application($kernel); // we set this so that it doesn't `exit` whatever php script is calling this function. $application->setAutoExit(false); - // Set the Symfony command that is to be executed. + // Set the Symfony command to execute. array_unshift($options, $command); $input = new ArrayInput($options); @@ -79,4 +79,14 @@ private static function doExecuteCommand(string $command, string $env, bool $deb throw new \RuntimeException("Error while running Symfony Command", $e->getCode(), $e); } } + + public static function dumpDotEnv(): void + { + // Make sure to clear the cache before dumping the dotenv so we start clean. + SymfonyCommandHelper::executeCommand('cache:clear'); + + // Dump dotenv data so we don't read .env each time in prod. + // Note: this means that if you want to start debugging stuff you'll need to delete the generated .env. + SymfonyCommandHelper::executeCommand('dotenv:dump'); + } } From 586bf5ec1f89fc4eaf21b0f1541af379b43e0559 Mon Sep 17 00:00:00 2001 From: Conner Saeli <51850219+connersaeli@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:03:38 -0400 Subject: [PATCH 82/83] Assorted logger changes (#2197) --- classes/XDReportManager.php | 13 ++-------- src/Controller/ReportBuilderController.php | 30 ++++++++-------------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/classes/XDReportManager.php b/classes/XDReportManager.php index 076863c9d9..fbed90641c 100644 --- a/classes/XDReportManager.php +++ b/classes/XDReportManager.php @@ -1140,8 +1140,7 @@ private function ripTransform(&$arr, $item) public function fetchChartBlob( $type, $insertion_rank, - $chart_id_cache_file = null, - $logger = null + $chart_id_cache_file = null ) { $pdo = DB::factory('database'); $trace = ""; @@ -1433,13 +1432,9 @@ public function generateChartBlob( $type, $insertion_rank, $start_date, - $end_date, - $logger = null + $end_date ) { $pdo = DB::factory('database'); - if (!is_null($logger)) { - $logger->debug("Generating Chart Blob - Type: $type"); - } switch ($type) { case 'volatile': $temp_file = $this->generateCachedFilename( @@ -1450,10 +1445,6 @@ public function generateChartBlob( $temp_file = str_replace('.png', '.xrc', $temp_file); $iq = array(); - if (!is_null($logger)) { - $logger->debug("Checking if Volatile File Exists; $temp_file"); - } - if (file_exists($temp_file) == true) { $chart_id_config = file($temp_file); $iq[] = array('chart_id' => $chart_id_config[0]); diff --git a/src/Controller/ReportBuilderController.php b/src/Controller/ReportBuilderController.php index 560f32379e..5d5401d7f7 100644 --- a/src/Controller/ReportBuilderController.php +++ b/src/Controller/ReportBuilderController.php @@ -376,9 +376,9 @@ public function saveReport(Request $request): Response 'rank' => $rank, 'did' => '', ]; - $this->logger->error('Saving Report', ['volatile', $insertion_rank, $start_date, $end_date]); + $this->logger->info('Saving Report', ['volatile', $insertion_rank, $start_date, $end_date]); $cached_blob = $start_date . ',' . $end_date . ';' - . $reportManager->generateChartBlob('volatile', $insertion_rank, $start_date, $end_date, $this->logger); + . $reportManager->generateChartBlob('volatile', $insertion_rank, $start_date, $end_date); } else { $cached_blob = $start_date . ',' . $end_date . ';' . file_get_contents($location); } @@ -515,14 +515,14 @@ public function getTemplates(Request $request): Response #[Route('/report_image_renderer.php', name: 'report_image_renderer_legacy', methods: ['GET'])] public function generateReportImage(Request $request): Response { - $this->logger->warning('Generating a Report Image'); + $this->logger->debug('Generating report image'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $userId = null; try { - $this->logger->warning('Report Image Authenticated'); $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); + $this->logger->debug('Report image authenticated'); $type = $this->getStringParam($request, 'type', true, null, ReportGenerator::REPORT_CHART_TYPE_REGEX); $ref = $this->getStringParam($request, 'ref', true, null, ReportGenerator::REPORT_CHART_REF_REGEX); @@ -534,7 +534,7 @@ public function generateReportImage(Request $request): Response switch ($type) { case 'chart_pool': case 'volatile': - $this->logger->warning('Report Image Volatile / chart Pool'); + $this->logger->debug('Report image type is', [$type]); $numMatches = preg_match('/^(\d+);(\d+)$/', $ref, $matches); if ($numMatches === 0) { @@ -602,40 +602,31 @@ public function generateReportImage(Request $request): Response throw new AccessDeniedHttpException(sprintf('Invalid User Request. Expected %s, Actual: %s', $user->getUserID(), $userId)); } - $this->logger->warning('Valid User Request'); - $reportManager = null; try { - $this->logger->warning('Instantiating XDREportManager'); $reportManager = new XDReportManager($user); } catch (Exception $exception) { - $this->logger->error('Error instantiating Report Manager'); + $this->logger->error('Error instantiating XDReportManager', [$exception->getMessage()]); } - $this->logger->warning('After Report Manager.'); - if (!empty($reportManager)) { - $this->logger->warning('Fetching Chart Blob', [$type, $insertionRank]); - $blob = $reportManager->fetchChartBlob($type, $insertionRank, null, $this->logger); - $this->logger->warning('Substringing Blob'); + $this->logger->debug('Fetching chart blob', [$type, $insertionRank]); + $blob = $reportManager->fetchChartBlob($type, $insertionRank, null); $image_data_header = substr($blob, 0, 8); - $this->logger->warning('Chart BLob Fetched!'); if ($image_data_header != "\x89PNG\x0d\x0a\x1a\x0a") { throw new Exception($blob); } - $this->logger->warning('Blob is a png'); + // If the blob is empty, than substitute the image below to be returned to the user. if (in_array(md5($blob), self::$emptyBlobs)) { $blob = file_get_contents(dirname(__FILE__) . '/gui/images/report_thumbnail_no_data.png'); } $headers = ['Content-Type' => 'image/png']; - $this->logger->warning('Returning PNG'); - $this->logger->warning('Headers: ', [$headers]); return new Response($blob, 200, $headers); } else { - $this->logger->error('Oops, we shouldnt be here.'); + $this->logger->error('An error occurred generating the report image.'); } return $this->json(['message' => 'Unable to instantiate report manager'], 500); @@ -663,7 +654,6 @@ public function getReportData(Request $request, string $reportId): Response $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $user = XDUser::getUserByUserName($this->getUser()->getUserIdentifier()); - $this->logger->warning('get Report Data Start'); $reportManager = new \XDReportManager($user); $flushCache = $this->getBooleanParam($request, 'flush_cache'); From ea7a002a58153dcc9703cff2aef334db47f82093 Mon Sep 17 00:00:00 2001 From: Conner Saeli <51850219+connersaeli@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:41:53 -0400 Subject: [PATCH 83/83] XDUser for Symfony Migration (#2200) * Reviewing for Symfony Migration * Updating from review comment. --- classes/XDUser.php | 73 ++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/classes/XDUser.php b/classes/XDUser.php index 561212d4cc..01acf103e6 100644 --- a/classes/XDUser.php +++ b/classes/XDUser.php @@ -878,31 +878,6 @@ public function getInsertQuery($updateToken = false, $includePassword = false) return $result; } - /** - * Accepts an array and outputs a meaningful string representation of said - * array. - * - * @param array $array the array that is to be converted into a string. - * - * @return string representation of the array parameter passed in. - */ - public function arrayToString($array = array()) - { - $values = array_reduce( - array_values($array), - function ($carry, $item) { - $carry[] = var_export($item, true); - return $carry; - }, - [] - ); - $result = 'Keys [ '; - $result .= implode(', ', array_keys($array)) . ']'; - $result .= 'Values [ '; - $result .= implode(', ', $values) . ']'; - return $result; - } - // --------------------------- /** @@ -1025,7 +1000,16 @@ public function saveUser() $this->_id = $new_user_id; } } catch (Exception $e) { - throw new Exception("Exception occured while inserting / updating. UpdateToken: [{$this->_update_token}] Query: [$query] data: [{$this->arrayToString($update_data)}]", null, $e); + $values = array_reduce( + array_values($update_data), + function ($carry, $item) { + $carry[] = var_export($item, true); + return $carry; + } + ); + $formattedOutput = 'Keys [' . implode(', ', array_keys($update_data)) . ']'; + $formattedOutput .= 'Values [' . implode(', ', $values) . ']'; + throw new Exception("Exception occured while inserting / updating. UpdateToken: [{$this->_update_token}] Query: [$query] data: [{$formattedOutput}]", null, $e); } /* END: Execute the query */ @@ -1804,16 +1788,14 @@ public function getActiveOrganization() public function getRoles($flag = 'informal'): array { + $roles = array(); if ($flag == 'informal') { $roles = array_reduce($this->_acls, function ($carry, Acl $item) { $carry[] = $item->getName(); return $carry; - }, array()); - return $roles; - } - - if ($flag == 'formal') { + }, $roles); + } elseif ($flag == 'formal') { $query = << $this->_id, )); - $roles = array(); - foreach ($results as $roleSet) { - $roles[$roleSet['display']] = $roleSet['name']; - } - - return $roles; } - return []; + + return $roles; }//getRoles // --------------------------- @@ -1933,22 +1910,20 @@ public function getUserID() public function getPersonID($default = FALSE) { - - // NOTE: RESTful services do not operate on the concept of a session, so we need to check for $_SESSION[..] entities using isset - $session = \xd_security\SessionSingleton::getSession(); + $session = SessionSingleton::getSession(); $xdUserId = $session->get('xdUser'); if (isset($xdUserId) && ($xdUserId === $this->_id) && ($default == FALSE)) { // The user object pertains to the user logged in.. $assumedPersonId = $session->get('assumed_person_id'); if (isset($assumedPersonId)) { - return $assumedPersonId; + $personID = $assumedPersonId; } + } else { + $personID = (empty($this->_personID)) ? '0' : $this->_personID; } - - return (empty($this->_personID)) ? '0' : $this->_personID; - + return $personID; }//getPersonID // --------------------------- @@ -2761,11 +2736,13 @@ public function __unserialize(array $data): void private function hash($password) { - if (!isset($this->hasher)) { - return password_hash($password, PASSWORD_DEFAULT); + if (isset($this->hasher)) { + $hashedPassword = $this->hasher->hash($password); } else { - return $this->hasher->hash($password); + // Fall back to MD5 if Symfony hasher is not set + $hashedPassword = password_hash($password, PASSWORD_DEFAULT); } + return $hashedPassword; } /**