diff --git a/.env.ci.test b/.env.ci similarity index 54% rename from .env.ci.test rename to .env.ci index ab52aac3..4b9d2273 100644 --- a/.env.ci.test +++ b/.env.ci @@ -3,6 +3,15 @@ APP_ENV=development APP_KEY= APP_DEBUG=true APP_URL=http://localhost:80 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US +APP_MAINTENANCE_DRIVER=file +APP_MAINTENANCE_STORE=database +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 APP_DESCRIPTION='nmrXiv is currently developed as the FAIR, consensus-driven NMR data repository and computational platform. The ultimate goal is to accelerate broader coordination and data sharing among natural product (NP) researchers by enabling the storage, management, sharing and analysis of NMR data.' COOL_OFF_PERIOD=10 SCHEMA_VERSION=beta @@ -14,16 +23,21 @@ EUROPEMC_WS_API=https://www.ebi.ac.uk/europepmc/webservices/rest/search ORCID_ID_SEARCH_API=https://pub.orcid.org/v2.1/search ORCID_ID_EMPLOYMENT_API=https://pub.orcid.org/v3.0/{orcid_id}/employments ORCID_ID_PERSON_API=https://pub.orcid.org/v3.0/{orcid_id}/person +CM_API=https://api.cheminf.studio/latest/ +CROSSREF_API=https://api.crossref.org/works/ +DATACITE_API=https://api.datacite.org/ +DATACITE_TEST_API=https://api.test.datacite.org LOG_CHANNEL=stack +LOG_STACK=single LOG_LEVEL=debug DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 -DB_DATABASE=nmrxiv +DB_DATABASE=nmrxiv_test DB_USERNAME=postgres -DB_PASSWORD=postgres +DB_PASSWORD=password BROADCAST_CONNECTION=log CACHE_STORE=file @@ -31,6 +45,9 @@ FILESYSTEM_DRIVER=local QUEUE_CONNECTION=redis SESSION_DRIVER=redis SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null MEMCACHED_HOST=memcached @@ -58,6 +75,7 @@ AWS_BUCKET=nmrxiv AWS_BUCKET_PUBLIC=nmrxiv-public AWS_ENDPOINT=https://s3.uni-jena.de AWS_URL=https://s3.uni-jena.de +AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID= PUSHER_APP_KEY= @@ -67,9 +85,9 @@ PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -SCOUT_DRIVER=meilisearch -SCOUT_PREFIX=dev_ -MEILISEARCH_HOST=https://msdev.nmrxiv.org +SCOUT_DRIVER=null +SCOUT_PREFIX=test_ +MEILISEARCH_HOST=http://localhost:7700/ MEILISEARCH_KEY= MEILISEARCH_PUBLICKEY= @@ -81,4 +99,40 @@ TWITTER_CLIENT_ID= TWITTER_CLIENT_SECRET= TWITTER_REDIRECT_URL=http://localhost:80/auth/login/twitter/callback +ORCID_CLIENT_ID= +ORCID_CLIENT_SECRET= +ORCID_REDIRECT_URL=http://localhost/auth/login/orcid/callback +ORCID_ENVIRONMENT=sandbox + +NFDIAAI_CLIENT_ID= +NFDIAAI_CLIENT_SECRET= +NFDIAAI_REDIRECT_URL="${APP_URL}/auth/login/regapp/callback" + TELESCOPE_ENABLED=false + +#DATACITE Properties +DOI_HOST=datacite +DATACITE_USERNAME= +DATACITE_SECRET= +DATACITE_PREFIX= +DATACITE_ENDPOINT=https://api.test.datacite.org + +NMRKIT_URL=https://nodejs.nmrxiv.org +PUBCHEM_URL=https://pubchem.ncbi.nlm.nih.gov +COMMON_CHEMISTRY_URL=https://commonchemistry.cas.org/api +CAS_API_TOKEN= +CHEMISTRY_STANDARDIZE_URL=https://api.cheminf.studio/latest/chem/standardize + +BACKUP_KEEP_DAYS=7 + +# CSP Configuration +CSP_ENABLED=true +CSP_REPORT_URI="/csp-violation-report" +CSP_NONCE_ENABLED=true +CSP_ENABLED_WHILE_HOT_RELOADING=false + +# Additional CSP sources (comma-separated, no spaces) +# CSP_ADDITIONAL_CONNECT_SRC="https://api.example.com,https://analytics.example.com" +# CSP_ADDITIONAL_IMG_SRC="https://cdn.example.com,https://images.example.com" +# CSP_ADDITIONAL_SCRIPT_SRC="https://cdn.example.com" +# CSP_ADDITIONAL_STYLE_SRC="https://fonts.example.com" \ No newline at end of file diff --git a/.env.example b/.env.example index 9c88138b..3c06ca5b 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,7 @@ ORCID_ID_PERSON_API=https://pub.orcid.org/v3.0/{orcid_id}/person CM_API=https://api.cheminf.studio/latest/ CROSSREF_API=https://api.crossref.org/works/ DATACITE_API=https://api.datacite.org/ +DATACITE_TEST_API=https://api.test.datacite.org LOG_CHANNEL=stack @@ -115,12 +116,23 @@ DOI_HOST=datacite DATACITE_USERNAME= DATACITE_SECRET= DATACITE_PREFIX= -DATACITE_ENDPOINT= +DATACITE_ENDPOINT=https://api.test.datacite.org NMRKIT_URL=https://nodejs.nmrxiv.org -CAS_URL=https://commonchemistry.cas.org PUBCHEM_URL=https://pubchem.ncbi.nlm.nih.gov -COMMON_CHEMISTRY_URL=https://commonchemistry.cas.org +COMMON_CHEMISTRY_URL=https://commonchemistry.cas.org/api +CAS_API_TOKEN= CHEMISTRY_STANDARDIZE_URL=https://api.cheminf.studio/latest/chem/standardize -BACKUP_KEEP_DAYS=7 \ No newline at end of file +BACKUP_KEEP_DAYS=7 + +# CSP Configuration +CSP_ENABLED=true +CSP_NONCE_ENABLED=false +CSP_ENABLED_WHILE_HOT_RELOADING=false + +# Additional CSP sources (comma-separated, no spaces) +# CSP_ADDITIONAL_CONNECT_SRC="https://api.example.com,https://analytics.example.com" +# CSP_ADDITIONAL_IMG_SRC="https://cdn.example.com,https://images.example.com" +# CSP_ADDITIONAL_SCRIPT_SRC="https://cdn.example.com" +# CSP_ADDITIONAL_STYLE_SRC="https://fonts.example.com" diff --git a/.github/workflows/lint-security-check.yml b/.github/workflows/lint-security-check.yml index 287ab2d4..166f8cc5 100644 --- a/.github/workflows/lint-security-check.yml +++ b/.github/workflows/lint-security-check.yml @@ -40,7 +40,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: '8.4' coverage: none tools: composer extensions: mbstring, intl, pdo, pdo_mysql, pdo_pgsql diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint-test.yml similarity index 65% rename from .github/workflows/pr-lint.yml rename to .github/workflows/pr-lint-test.yml index fb1d6f38..cbb9ef69 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint-test.yml @@ -1,4 +1,4 @@ -name: PR Lint & Security +name: PR Lint, Security and Tests on: pull_request: @@ -10,6 +10,8 @@ on: permissions: contents: read security-events: write + pull-requests: write + checks: write jobs: lint-security: @@ -19,3 +21,8 @@ jobs: run_php: true run_js: true run_secrets: true + + test-coverage: + name: Tests & Coverage (PHP 8.4) + uses: ./.github/workflows/test-coverage.yml + secrets: inherit diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 00000000..f06a487b --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,79 @@ +name: PHPUnit Tests & Coverage Analysis + +on: + workflow_call: + +jobs: + test-coverage: + name: Tests & Coverage (PHP 8.4) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: nmrxiv_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, soap, intl, gd, exif, iconv + coverage: pcov + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install composer dependencies + run: composer install --ignore-platform-reqs + + - name: Prepare Laravel Application + run: | + php -r "file_exists('.env') || copy('.env.ci', '.env');" + + echo AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID_DEV }} >> .env + echo AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }} >> .env + echo MEILISEARCH_KEY=${{ secrets.MEILISEARCH_KEY_DEV }} >> .env + echo MEILISEARCH_PUBLICKEY=${{ secrets.MEILISEARCH_PUBLICKEY_DEV }} >> .env + + php artisan key:generate + php artisan migrate --seed + + - name: Cache Node modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install front-end dependencies + run: npm ci + + - name: Build front-end assets + run: npm run build + + - name: Run tests and collect coverage + run: vendor/bin/phpunit --coverage-clover coverage.xml --display-skipped + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 3978544a..24386942 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ /vendor .env .env.production -.env.* .env.backup .phpunit.result.cache docker-compose.override.yml @@ -24,3 +23,8 @@ docs/.vitepress/cache **/caddy frankenphp frankenphp-worker.php + +# Coverage reports +coverage.xml +coverage-report/ +.phpunit.cache diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results deleted file mode 100644 index 4a6a82fc..00000000 --- a/.phpunit.cache/test-results +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":{"Tests\\Feature\\ApiTokenPermissionsTest::test_api_token_permissions_can_be_updated":8,"Tests\\Feature\\AuthenticationTest::test_login_screen_can_be_rendered":8,"Tests\\Feature\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":8,"Tests\\Feature\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":8,"Tests\\Feature\\BrowserSessionsTest::test_other_browser_sessions_can_be_logged_out":8,"Tests\\Feature\\CreateApiTokenTest::test_api_tokens_can_be_created":8,"Tests\\Feature\\CreateTeamTest::test_teams_can_be_created":8,"Tests\\Feature\\DeleteAccountTest::test_user_accounts_can_be_deleted":1,"Tests\\Feature\\DeleteAccountTest::test_correct_password_must_be_provided_before_account_can_be_deleted":1,"Tests\\Feature\\DeleteApiTokenTest::test_api_tokens_can_be_deleted":8,"Tests\\Feature\\DeleteTeamTest::test_teams_can_be_deleted":8,"Tests\\Feature\\DeleteTeamTest::test_personal_teams_cant_be_deleted":8,"Tests\\Feature\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":8,"Tests\\Feature\\EmailVerificationTest::test_email_can_be_verified":8,"Tests\\Feature\\EmailVerificationTest::test_email_can_not_verified_with_invalid_hash":8,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":7,"Tests\\Feature\\InviteTeamMemberTest::test_team_members_can_be_invited_to_team":8,"Tests\\Feature\\InviteTeamMemberTest::test_team_member_invitations_can_be_cancelled":8,"Tests\\Feature\\LeaveTeamTest::test_users_can_leave_teams":8,"Tests\\Feature\\LeaveTeamTest::test_team_owners_cant_leave_their_own_team":8,"Tests\\Feature\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":8,"Tests\\Feature\\PasswordConfirmationTest::test_password_can_be_confirmed":8,"Tests\\Feature\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":8,"Tests\\Feature\\PasswordResetTest::test_reset_password_link_screen_can_be_rendered":8,"Tests\\Feature\\PasswordResetTest::test_reset_password_link_can_be_requested":8,"Tests\\Feature\\PasswordResetTest::test_reset_password_screen_can_be_rendered":8,"Tests\\Feature\\PasswordResetTest::test_password_can_be_reset_with_valid_token":8,"Tests\\Feature\\ProfileInformationTest::test_profile_information_can_be_updated":8,"Tests\\Feature\\RegistrationTest::test_registration_screen_can_be_rendered":8,"Tests\\Feature\\RegistrationTest::test_registration_screen_cannot_be_rendered_if_support_is_disabled":1,"Tests\\Feature\\RegistrationTest::test_new_users_can_register":8,"Tests\\Feature\\RemoveTeamMemberTest::test_team_members_can_be_removed_from_teams":8,"Tests\\Feature\\RemoveTeamMemberTest::test_only_team_owner_can_remove_team_members":8,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_two_factor_authentication_can_be_enabled":8,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_recovery_codes_can_be_regenerated":8,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_two_factor_authentication_can_be_disabled":8,"Tests\\Feature\\UpdatePasswordTest::test_password_can_be_updated":8,"Tests\\Feature\\UpdatePasswordTest::test_current_password_must_be_correct":8,"Tests\\Feature\\UpdatePasswordTest::test_new_passwords_must_match":8,"Tests\\Feature\\UpdateTeamMemberRoleTest::test_team_member_roles_can_be_updated":8,"Tests\\Feature\\UpdateTeamMemberRoleTest::test_only_team_owner_can_update_team_member_roles":8,"Tests\\Feature\\UpdateTeamNameTest::test_team_names_can_be_updated":8,"Tests\\Feature\\OEmbedTest::it_can_generate_oembed_response_for_study":8,"Tests\\Feature\\OEmbedTest::it_can_generate_oembed_response_for_dataset":8,"Tests\\Feature\\OEmbedTest::it_accepts_custom_width_and_height_parameters":8,"Tests\\Feature\\OEmbedTest::it_handles_study_without_thumbnail":8,"Tests\\Feature\\OEmbedTest::it_returns_400_when_url_parameter_is_missing":8,"Tests\\Feature\\OEmbedTest::it_returns_400_when_url_format_is_invalid":8,"Tests\\Feature\\OEmbedTest::it_returns_400_when_identifier_is_missing_from_url":8,"Tests\\Feature\\OEmbedTest::it_returns_404_when_identifier_cannot_be_resolved":8,"Tests\\Feature\\OEmbedTest::it_returns_404_when_identifier_format_is_invalid":8,"Tests\\Feature\\OEmbedTest::it_can_render_embedded_study_content":8,"Tests\\Feature\\OEmbedTest::it_can_render_embedded_dataset_content":8,"Tests\\Feature\\OEmbedTest::it_returns_400_when_embed_identifier_is_empty":8,"Tests\\Feature\\OEmbedTest::it_returns_404_when_embed_identifier_cannot_be_resolved":8,"Tests\\Feature\\OEmbedTest::it_returns_404_when_embed_identifier_format_is_invalid":8,"Tests\\Feature\\OEmbedTest::it_returns_400_for_unsupported_content_type_in_embed":8,"Tests\\Feature\\OEmbedTest::it_handles_dataset_without_associated_study":8,"Tests\\Feature\\OEmbedTest::it_handles_nmrxiv_prefix_in_identifier":8,"Tests\\Feature\\OEmbedTest::it_handles_case_insensitive_identifiers":8,"Tests\\Feature\\OEmbedTest::it_supports_json_format_parameter":8,"Tests\\Feature\\OEmbedTest::it_handles_server_errors_gracefully":8,"Tests\\Feature\\OEmbedTest::test_it_can_generate_oembed_response_for_study":7,"Tests\\Feature\\OEmbedTest::test_it_can_generate_oembed_response_for_dataset":8,"Tests\\Feature\\OEmbedTest::test_it_accepts_custom_width_and_height_parameters":8,"Tests\\Feature\\OEmbedTest::test_it_handles_study_without_thumbnail":8,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_url_parameter_is_missing":7,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_url_format_is_invalid":7,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_identifier_is_missing_from_url":8,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_identifier_cannot_be_resolved":7,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_identifier_format_is_invalid":7,"Tests\\Feature\\OEmbedTest::test_it_can_render_embedded_study_content":7,"Tests\\Feature\\OEmbedTest::test_it_can_render_embedded_dataset_content":7,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_embed_identifier_is_empty":8,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_embed_identifier_cannot_be_resolved":7,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_embed_identifier_format_is_invalid":7,"Tests\\Feature\\OEmbedTest::test_it_returns_400_for_unsupported_content_type_in_embed":7,"Tests\\Feature\\OEmbedTest::test_it_handles_dataset_without_associated_study":7,"Tests\\Feature\\OEmbedTest::test_it_handles_nmrxiv_prefix_in_identifier":8,"Tests\\Feature\\OEmbedTest::test_it_handles_case_insensitive_identifiers":8,"Tests\\Feature\\OEmbedTest::test_it_supports_json_format_parameter":8,"Tests\\Feature\\OEmbedTest::test_it_handles_server_errors_gracefully":8,"Tests\\Feature\\OEmbedTest::test_it_validates_width_and_height_parameters":7,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_with_json_data":7,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_creates_new_authors":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_reuses_existing_authors_by_email":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_handles_empty_array":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_validates_required_fields":8,"Tests\\Unit\\AuthorServiceTest::test_remove_author_from_project_detaches_successfully":8,"Tests\\Unit\\AuthorServiceTest::test_remove_author_from_project_handles_nonexistent_author":8,"Tests\\Unit\\AuthorServiceTest::test_update_contributor_type_changes_role":8,"Tests\\Unit\\AuthorServiceTest::test_update_contributor_type_validates_role":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_uses_database_transaction":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_creates_composite_index_optimized_queries":8,"Tests\\Unit\\AuthorServiceTest::test_sync_authors_prevents_n_plus_one_queries":8,"Tests\\Unit\\ProcessDraftELNSubmissionProxyTest::test_http_client_uses_proxy_when_configured":8,"Tests\\Unit\\ProcessDraftELNSubmissionProxyTest::test_http_client_works_without_proxy_configuration":8,"Tests\\Feature\\ManageAuthorsTest::test_author_cannot_be_updated_or_detached_if_project_is_public":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_can_be_created_with_submitted_through_field":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_creates_sample_with_null_submitted_through_by_default":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_can_create_sample_with_eln_state":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_can_create_sample_with_custom_submitted_through":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_can_be_updated_with_submitted_through_field":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_is_fillable":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_accepts_null_values":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_accepts_various_string_values":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_store_processing_logs_from_draft":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_be_created_with_submitted_through_field":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_creates_study_with_null_submitted_through_by_default":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_eln_state":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_default_eln":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_custom_submitted_through":8,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_be_updated_with_submitted_through_field":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_tracking_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_tracking_authentication_failure":5,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_trackings_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_by_id_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_items_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_item_by_name_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_eln_submission_tracking_success":7,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_update_eln_submission_status_success":8,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_api_error_handling":5,"Tests\\Feature\\ELNSubmissionTrackingTest::test_eln_submission_creates_tracking_when_received":7,"Tests\\Feature\\ELNSubmissionTrackingTest::test_study_publication_creates_tracking_when_published":8,"Tests\\Feature\\ELNSubmissionTrackingTest::test_tracking_failure_does_not_break_submission":7,"Tests\\Feature\\ELNSubmissionTrackingTest::test_non_eln_study_publication_does_not_create_tracking":8,"Tests\\Feature\\SearchControllerSecurityTest::test_sql_injection_in_query_parameter":8,"Tests\\Feature\\SearchControllerSecurityTest::test_sql_injection_in_filter_queries":7,"Tests\\Feature\\SearchControllerSecurityTest::test_input_validation":8,"Tests\\Feature\\SearchControllerSecurityTest::test_query_length_limits":8,"Tests\\Feature\\SearchControllerSecurityTest::test_control_character_filtering":8,"Tests\\Feature\\SearchControllerSecurityTest::test_smiles_injection_attempts":7,"Tests\\Feature\\SearchControllerSecurityTest::test_inchi_injection_attempts":8,"Tests\\Feature\\SearchControllerSecurityTest::test_legitimate_queries_still_work":8,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_handles_ranges":5,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_sanitizes_text":5,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_validates_booleans":5,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_validates_database_names":5,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_malicious_filter_queries_handled_safely":5,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_allows_safe_html":7,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_preserves_markdown_formatting":7,"Tests\\Unit\\MarkdownXSSSecurityTest::test_javascript_urls_are_removed":7,"Tests\\Feature\\RouteStructureTest::test_project_route_accepts_valid_identifiers":8,"Tests\\Feature\\RouteStructureTest::test_sample_route_accepts_valid_identifiers":8,"Tests\\Feature\\RouteStructureTest::test_compound_route_accepts_valid_identifiers":8},"times":{"Tests\\Unit\\ExampleTest::test_example":0.003,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.181,"Tests\\Feature\\ApiTokenPermissionsTest::test_api_token_permissions_can_be_updated":0.047,"Tests\\Feature\\AuthenticationTest::test_login_screen_can_be_rendered":0.035,"Tests\\Feature\\AuthenticationTest::test_users_can_authenticate_using_the_login_screen":0.061,"Tests\\Feature\\AuthenticationTest::test_users_can_not_authenticate_with_invalid_password":0.052,"Tests\\Feature\\BrowserSessionsTest::test_other_browser_sessions_can_be_logged_out":0.099,"Tests\\Feature\\CreateApiTokenTest::test_api_tokens_can_be_created":0.01,"Tests\\Feature\\CreateTeamTest::test_teams_can_be_created":0.011,"Tests\\Feature\\DeleteAccountTest::test_user_accounts_can_be_deleted":0.013,"Tests\\Feature\\DeleteAccountTest::test_correct_password_must_be_provided_before_account_can_be_deleted":0,"Tests\\Feature\\DeleteApiTokenTest::test_api_tokens_can_be_deleted":0.007,"Tests\\Feature\\DeleteTeamTest::test_teams_can_be_deleted":0.071,"Tests\\Feature\\DeleteTeamTest::test_personal_teams_cant_be_deleted":0.211,"Tests\\Feature\\EmailVerificationTest::test_email_verification_screen_can_be_rendered":0.015,"Tests\\Feature\\EmailVerificationTest::test_email_can_be_verified":0.007,"Tests\\Feature\\EmailVerificationTest::test_email_can_not_verified_with_invalid_hash":0.007,"Tests\\Feature\\InviteTeamMemberTest::test_team_members_can_be_invited_to_team":0.019,"Tests\\Feature\\InviteTeamMemberTest::test_team_member_invitations_can_be_cancelled":0.007,"Tests\\Feature\\LeaveTeamTest::test_users_can_leave_teams":0.01,"Tests\\Feature\\LeaveTeamTest::test_team_owners_cant_leave_their_own_team":0.006,"Tests\\Feature\\PasswordConfirmationTest::test_confirm_password_screen_can_be_rendered":0.012,"Tests\\Feature\\PasswordConfirmationTest::test_password_can_be_confirmed":0.047,"Tests\\Feature\\PasswordConfirmationTest::test_password_is_not_confirmed_with_invalid_password":0.209,"Tests\\Feature\\PasswordResetTest::test_reset_password_link_screen_can_be_rendered":0.007,"Tests\\Feature\\PasswordResetTest::test_reset_password_link_can_be_requested":0.216,"Tests\\Feature\\PasswordResetTest::test_reset_password_screen_can_be_rendered":0.216,"Tests\\Feature\\PasswordResetTest::test_password_can_be_reset_with_valid_token":0.229,"Tests\\Feature\\ProfileInformationTest::test_profile_information_can_be_updated":0.078,"Tests\\Feature\\RegistrationTest::test_registration_screen_can_be_rendered":0.006,"Tests\\Feature\\RegistrationTest::test_registration_screen_cannot_be_rendered_if_support_is_disabled":0,"Tests\\Feature\\RegistrationTest::test_new_users_can_register":0.012,"Tests\\Feature\\RemoveTeamMemberTest::test_team_members_can_be_removed_from_teams":0.008,"Tests\\Feature\\RemoveTeamMemberTest::test_only_team_owner_can_remove_team_members":0.009,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_two_factor_authentication_can_be_enabled":0.009,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_recovery_codes_can_be_regenerated":0.009,"Tests\\Feature\\TwoFactorAuthenticationSettingsTest::test_two_factor_authentication_can_be_disabled":0.006,"Tests\\Feature\\UpdatePasswordTest::test_password_can_be_updated":0.048,"Tests\\Feature\\UpdatePasswordTest::test_current_password_must_be_correct":0.086,"Tests\\Feature\\UpdatePasswordTest::test_new_passwords_must_match":0.086,"Tests\\Feature\\UpdateTeamMemberRoleTest::test_team_member_roles_can_be_updated":0.01,"Tests\\Feature\\UpdateTeamMemberRoleTest::test_only_team_owner_can_update_team_member_roles":0.746,"Tests\\Feature\\UpdateTeamNameTest::test_team_names_can_be_updated":0.008,"Tests\\Feature\\OEmbedTest::test_it_can_generate_oembed_response_for_study":0.009,"Tests\\Feature\\OEmbedTest::test_it_can_generate_oembed_response_for_dataset":0.005,"Tests\\Feature\\OEmbedTest::test_it_accepts_custom_width_and_height_parameters":0.005,"Tests\\Feature\\OEmbedTest::test_it_handles_study_without_thumbnail":0.007,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_url_parameter_is_missing":0.005,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_url_format_is_invalid":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_identifier_is_missing_from_url":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_identifier_cannot_be_resolved":0.007,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_identifier_format_is_invalid":0.009,"Tests\\Feature\\OEmbedTest::test_it_can_render_embedded_study_content":0.017,"Tests\\Feature\\OEmbedTest::test_it_can_render_embedded_dataset_content":0.013,"Tests\\Feature\\OEmbedTest::test_it_returns_400_when_embed_identifier_is_empty":0.005,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_embed_identifier_cannot_be_resolved":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_404_when_embed_identifier_format_is_invalid":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_400_for_unsupported_content_type_in_embed":0.012,"Tests\\Feature\\OEmbedTest::test_it_handles_dataset_without_associated_study":0.006,"Tests\\Feature\\OEmbedTest::test_it_handles_nmrxiv_prefix_in_identifier":0.006,"Tests\\Feature\\OEmbedTest::test_it_handles_case_insensitive_identifiers":0.008,"Tests\\Feature\\OEmbedTest::test_it_supports_json_format_parameter":0.006,"Tests\\Feature\\OEmbedTest::test_it_handles_server_errors_gracefully":0.006,"Tests\\Feature\\OEmbedTest::test_it_returns_500_when_identifier_format_is_invalid":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_500_when_embed_identifier_format_is_invalid":0.004,"Tests\\Feature\\OEmbedTest::test_it_returns_404_for_unsupported_content_type_in_embed":0.005,"Tests\\Feature\\OEmbedTest::test_it_blocks_private_content_in_oembed":0.007,"Tests\\Feature\\OEmbedTest::test_it_blocks_private_content_in_embed":0.006,"Tests\\Feature\\OEmbedTest::test_it_blocks_external_domain_urls":0.007,"Tests\\Feature\\OEmbedTest::test_it_validates_width_and_height_parameters":0.009,"Tests\\Feature\\OEmbedTest::test_iframe_html_is_properly_sanitized":0.005,"Tests\\Feature\\ManageAuthorsTest::test_author_can_be_created_and_updated":0.025,"Tests\\Feature\\ManageAuthorsTest::test_author_can_be_updated":0.024,"Tests\\Feature\\ManageAuthorsTest::test_author_can_be_detached":0.019,"Tests\\Feature\\ManageAuthorsTest::test_author_cannot_be_updated_or_deleted_by_reviewer":0.016,"Tests\\Feature\\ManageAuthorsTest::test_author_cannot_be_updated_or_detached_if_project_is_public":0.013,"Tests\\Feature\\ManageAuthorsTest::test_role_of_an_author_can_be_updated":0.013,"Tests\\Feature\\ManageAuthorsTest::test_role_of_an_author_cannot_be_updated_by_reviewer":0.011,"Tests\\Feature\\ManageAuthorsTest::test_role_of_an_author_cannot_be_updated_for_random_contributor_types":0.011,"Tests\\Unit\\ProcessDraftELNSubmissionProxyTest::test_http_client_uses_proxy_when_configured":0.067,"Tests\\Unit\\ProcessDraftELNSubmissionProxyTest::test_http_client_works_without_proxy_configuration":0.003,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_preserves_newlines":0.003,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_removes_problematic_unicode":0,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_preserves_ascii_printable":0,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_with_json_data":0,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_in_array_preserves_newlines":0,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_in_nmrium_data_preserves_newlines":0,"Tests\\Unit\\HelperFunctionsTest::test_sanitize_unicode_string_converts_escape_sequences":0,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_can_be_created_with_submitted_through_field":0.007,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_creates_sample_with_null_submitted_through_by_default":0.002,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_can_create_sample_with_eln_state":0.002,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_factory_can_create_sample_with_custom_submitted_through":0.002,"Tests\\Feature\\SampleSubmittedThroughTest::test_sample_can_be_updated_with_submitted_through_field":0.002,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_is_fillable":0.001,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_accepts_null_values":0.003,"Tests\\Feature\\SampleSubmittedThroughTest::test_submitted_through_field_accepts_various_string_values":0.01,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_be_created_with_submitted_through_field":0.008,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_creates_study_with_null_submitted_through_by_default":0.004,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_eln_state":0.003,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_default_eln":0.003,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_factory_can_create_study_with_custom_submitted_through":0.003,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_be_updated_with_submitted_through_field":0.005,"Tests\\Feature\\SampleSubmittedThroughTest::test_study_can_store_processing_logs_from_draft":0.003,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_tracking_success":0.02,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_tracking_authentication_failure":0.004,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_trackings_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_by_id_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_items_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_get_tracking_item_by_name_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_create_eln_submission_tracking_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_update_eln_submission_status_success":0.001,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_api_error_handling":0.001,"Tests\\Feature\\ELNSubmissionTrackingTest::test_eln_submission_creates_tracking_when_received":0.356,"Tests\\Feature\\ELNSubmissionTrackingTest::test_study_publication_creates_tracking_when_published":0.025,"Tests\\Feature\\ELNSubmissionTrackingTest::test_tracking_failure_does_not_break_submission":0.015,"Tests\\Feature\\ELNSubmissionTrackingTest::test_non_eln_study_publication_does_not_create_tracking":0.007,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_is_enabled_returns_correct_value":0,"Tests\\Unit\\ChemotionRepositoryTrackerServiceTest::test_status_validation":0.001,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_sanitize_query_removes_control_characters":0.003,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_sanitize_query_trims_whitespace":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_sanitize_query_limits_length":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_sanitize_query_handles_null":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_validates_fields":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_handles_ranges":0.005,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_sanitizes_text":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_validates_booleans":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_build_secure_filter_query_validates_database_names":0,"Tests\\Unit\\SearchControllerSecurityUnitTest::test_malicious_filter_queries_handled_safely":0,"Tests\\Feature\\SearchControllerSecurityTest::test_sql_injection_in_query_parameter":0.037,"Tests\\Feature\\SearchControllerSecurityTest::test_sql_injection_in_filter_queries":0.012,"Tests\\Feature\\SearchControllerSecurityTest::test_input_validation":0.008,"Tests\\Feature\\SearchControllerSecurityTest::test_query_length_limits":0.003,"Tests\\Feature\\SearchControllerSecurityTest::test_control_character_filtering":0.003,"Tests\\Feature\\SearchControllerSecurityTest::test_smiles_injection_attempts":0.006,"Tests\\Feature\\SearchControllerSecurityTest::test_inchi_injection_attempts":0.005,"Tests\\Feature\\SearchControllerSecurityTest::test_legitimate_queries_still_work":0.006,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_sanitizes_script_tags":0.004,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_allows_safe_html":0.008,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_handles_empty_input":0,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_prevents_various_xss_attacks":0,"Tests\\Unit\\MarkdownSecurityTest::test_md_function_preserves_markdown_formatting":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_script_tags_are_removed":0.003,"Tests\\Unit\\MarkdownXSSSecurityTest::test_event_handlers_are_removed":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_javascript_urls_are_removed":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_dangerous_html_tags_are_removed":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_safe_markdown_is_preserved":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_empty_input_is_handled_safely":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_sanitize_html_function_prevents_xss":0,"Tests\\Unit\\MarkdownXSSSecurityTest::test_sanitize_html_handles_license_content":0,"Tests\\Feature\\RouteStructureTest::test_new_project_route_structure_is_registered":0.003,"Tests\\Feature\\RouteStructureTest::test_new_sample_route_structure_is_registered":0,"Tests\\Feature\\RouteStructureTest::test_new_compound_route_structure_is_registered":0,"Tests\\Feature\\RouteStructureTest::test_project_route_accepts_valid_identifiers":0.001,"Tests\\Feature\\RouteStructureTest::test_sample_route_accepts_valid_identifiers":0,"Tests\\Feature\\RouteStructureTest::test_compound_route_accepts_valid_identifiers":0}} \ No newline at end of file diff --git a/app/Actions/Project/ArchiveProject.php b/app/Actions/Project/ArchiveProject.php index c08b6f5d..d0a4d117 100644 --- a/app/Actions/Project/ArchiveProject.php +++ b/app/Actions/Project/ArchiveProject.php @@ -12,14 +12,24 @@ class ArchiveProject * @param mixed $project * @return void */ - public function toggle($project) + public function toggleArchive($project) { $archiveState = ! $project->is_archived; - $project->studies()->update(['is_archived' => $archiveState]); + $project->studies()->update([ + 'is_archived' => $archiveState, + 'status' => $archiveState ? 'archived' : 'published', + ]); + foreach ($project->studies as $study) { - $study->datasets()->update(['is_archived' => $archiveState]); + $study->datasets()->update([ + 'is_archived' => $archiveState, + 'status' => $archiveState ? 'archived' : 'published', + ]); } + $project->is_archived = $archiveState; + $project->status = $archiveState ? 'archived' : 'published'; + if ($project->is_archived) { $project->sendNotification('archival', $this->prepareSendList($project)); } diff --git a/app/Actions/Project/CreateNewProject.php b/app/Actions/Project/CreateNewProject.php index 8de33cca..5cb395ef 100644 --- a/app/Actions/Project/CreateNewProject.php +++ b/app/Actions/Project/CreateNewProject.php @@ -19,7 +19,7 @@ class CreateNewProject */ public function create(array $input) { - $license = $input['license']; + $license = $input['license'] ?? null; $errorMessages = [ 'license.required_if' => 'The license field is required when the project is made public.', ]; diff --git a/app/Actions/Project/DeleteProject.php b/app/Actions/Project/DeleteProject.php index ba027273..a472b89c 100644 --- a/app/Actions/Project/DeleteProject.php +++ b/app/Actions/Project/DeleteProject.php @@ -30,8 +30,8 @@ public function delete($project) } else { $project->studies()->update(['is_deleted' => true]); foreach ($project->studies as $study) { - $study->update(['is_deleted' => true]); - $study->datasets()->update(['is_deleted' => true]); + $study->update(['is_deleted' => true, 'status' => 'deleted']); + $study->datasets()->update(['is_deleted' => true, 'status' => 'deleted']); } $draft = $project->draft; if ($draft) { @@ -40,6 +40,7 @@ public function delete($project) $project->name = $project->name; $project->deleted_on = Carbon::now(); $project->is_deleted = true; + $project->status = 'deleted'; $project->sendNotification('deletion', $this->prepareSendList($project)); } $project->save(); diff --git a/app/Actions/Project/RestoreProject.php b/app/Actions/Project/RestoreProject.php index e6d0564a..1f119d9c 100644 --- a/app/Actions/Project/RestoreProject.php +++ b/app/Actions/Project/RestoreProject.php @@ -27,8 +27,10 @@ public function restore($project) $study->datasets()->update(['is_deleted' => false]); } $draft = $project->draft; - $draft->is_deleted = false; - $draft->save(); + if ($draft) { + $draft->is_deleted = false; + $draft->save(); + } $project->is_deleted = false; $project->save(); } diff --git a/app/Actions/Study/UpdateStudy.php b/app/Actions/Study/UpdateStudy.php index 5fc4ff16..d8371b61 100644 --- a/app/Actions/Study/UpdateStudy.php +++ b/app/Actions/Study/UpdateStudy.php @@ -4,6 +4,7 @@ use App\Models\Study; use App\Models\Ticker; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -29,7 +30,7 @@ public function update(Study $study, array $input) $study->forceFill([ 'name' => $input['name'], 'slug' => Str::slug($input['name'], '-'), - 'description' => $input['description'] ? $input['description'] : $study->description, + 'description' => array_key_exists('description', $input) ? $input['description'] : $study->description, 'color' => array_key_exists('color', $input) ? $input['color'] : $study->color, 'starred' => array_key_exists('starred', $input) ? $input['starred'] : $study->starred, 'location' => array_key_exists('location', $input) ? $input['location'] : $study->location, @@ -56,30 +57,32 @@ public function update(Study $study, array $input) $release_date = Carbon::now()->timestamp; $sample = $study->sample; - $sampleIdentifier = $sample->identifier ? $sample->identifier : null; + if ($sample) { + $sampleIdentifier = $sample->identifier ? $sample->identifier : null; - if ($sampleIdentifier == null) { - $sampleTicker = Ticker::whereType('sample')->first(); - $sampleIdentifier = $sampleTicker->index + 1; - $sample->identifier = $sampleIdentifier; - $sample->save(); + if ($sampleIdentifier == null) { + $sampleTicker = Ticker::whereType('sample')->first(); + $sampleIdentifier = $sampleTicker->index + 1; + $sample->identifier = $sampleIdentifier; + $sample->save(); - $sampleTicker->index = $sampleIdentifier; - $sampleTicker->save(); - } + $sampleTicker->index = $sampleIdentifier; + $sampleTicker->save(); + } - $molecules = $sample->molecules; + $molecules = $sample->molecules; - foreach ($molecules as $molecule) { - $moleculeIdentifier = $molecule->identifier ? $molecule->identifier : null; - if ($moleculeIdentifier == null) { - $moleculeTicker = Ticker::whereType('molecule')->first(); - $moleculeIdentifier = $moleculeTicker->index + 1; - $molecule->identifier = $moleculeIdentifier; - $molecule->save(); + foreach ($molecules as $molecule) { + $moleculeIdentifier = $molecule->identifier ? $molecule->identifier : null; + if ($moleculeIdentifier == null) { + $moleculeTicker = Ticker::whereType('molecule')->first(); + $moleculeIdentifier = $moleculeTicker->index + 1; + $molecule->identifier = $moleculeIdentifier; + $molecule->save(); - $moleculeTicker->index = $moleculeIdentifier; - $moleculeTicker->save(); + $moleculeTicker->index = $moleculeIdentifier; + $moleculeTicker->save(); + } } } } diff --git a/app/Console/Commands/PublishReleasedProjects.php b/app/Console/Commands/PublishReleasedProjects.php index a91c0602..a69b3092 100644 --- a/app/Console/Commands/PublishReleasedProjects.php +++ b/app/Console/Commands/PublishReleasedProjects.php @@ -32,21 +32,28 @@ class PublishReleasedProjects extends Command public function handle(PublishProject $publisher): int { return DB::transaction(function () use ($publisher) { + $publishedCount = 0; $projects = Project::where([ ['is_public', false], ['release_date', 'IS NOT', null], ])->get(); + foreach ($projects as $project) { $release_date = Carbon::parse($project->release_date); if ($release_date->isPast()) { if (! is_null($project->doi) && ! $project->is_archived) { - // echo($project->identifier); - // echo("\r\n"); + echo $project->identifier; + echo "\r\n"; $publisher->publish($project); Notification::send($project->owner, new DraftProcessedNotification($project)); + $publishedCount++; } } } + + $this->info("Published {$publishedCount} projects."); + + return Command::SUCCESS; }); } } diff --git a/app/Console/Commands/SanitizeMolecules.php b/app/Console/Commands/SanitizeMolecules.php index ad6313f9..1ccf4509 100644 --- a/app/Console/Commands/SanitizeMolecules.php +++ b/app/Console/Commands/SanitizeMolecules.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Molecule; +use App\Services\CAS\CASService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; @@ -22,42 +23,62 @@ class SanitizeMolecules extends Command */ protected $description = 'Sanitize molecules'; + public function __construct( + private CASService $casService + ) { + parent::__construct(); + } + /** * Execute the console command. */ public function handle(): void { - $molecules = Molecule::all(); - - foreach ($molecules as $molecule) { - echo $molecule->id; - echo "\r\n"; - $inchi = $molecule->standard_inchi; - if ($inchi) { - $data = $this->fetchPubChemIUPACProperties($inchi); - $molecule->synonyms = $data['synonyms']; - $molecule->iupac_name = (array_key_exists('IUPACName', $data['properties']) ? $data['properties']['IUPACName'] : $molecule->iupac_name); - $molecule->molecular_formula = (array_key_exists('MolecularFormula', $data['properties']) ? $data['properties']['MolecularFormula'] : $molecule->molecular_formula); - $molecule->molecular_weight = (array_key_exists('MolecularWeight', $data['properties']) ? $data['properties']['MolecularWeight'] : $molecule->molecular_weight); - $molecule->Save(); - } - if (! $molecule->canonical_smiles) { - echo $molecule->id; - echo "\r\n"; - $molecule->sdf = ' + $totalMolecules = Molecule::count(); + $this->info("Processing {$totalMolecules} molecules..."); + + if ($totalMolecules === 0) { + $this->info('No molecules found to process.'); + + return; + } + + $progressBar = $this->output->createProgressBar($totalMolecules); + $progressBar->start(); + + Molecule::chunk(100, function ($molecules) use ($progressBar) { + foreach ($molecules as $molecule) { + $inchi = $molecule->standard_inchi; + if ($inchi) { + $data = $this->fetchPubChemIUPACProperties($inchi); + $molecule->synonyms = $data['synonyms']; + $molecule->iupac_name = (array_key_exists('IUPACName', $data['properties']) ? $data['properties']['IUPACName'] : $molecule->iupac_name); + $molecule->molecular_formula = (array_key_exists('MolecularFormula', $data['properties']) ? $data['properties']['MolecularFormula'] : $molecule->molecular_formula); + $molecule->molecular_weight = (array_key_exists('MolecularWeight', $data['properties']) ? $data['properties']['MolecularWeight'] : $molecule->molecular_weight); + $molecule->save(); + } + if (! $molecule->canonical_smiles) { + $molecule->sdf = ' '.$molecule->sdf; - $standardisedMOL = $this->standardizeMolecule($molecule->sdf); - $molecule->canonical_smiles = array_key_exists('canonical_smiles', $standardisedMOL) ? $standardisedMOL['canonical_smiles'] : null; - $molecule->standard_inchi = array_key_exists('inchi', $standardisedMOL) ? $standardisedMOL['inchi'] : null; - $molecule->inchi_key = array_key_exists('canonicalinchikey_smiles', $standardisedMOL) ? $standardisedMOL['inchikey'] : null; - $molecule->save(); - } - if ($molecule->canonical_smiles) { - $cas = $this->fetchCAS($molecule->canonical_smiles); - $molecule->cas = $cas; - $molecule->save(); + $standardisedMOL = $this->standardizeMolecule($molecule->sdf); + $molecule->canonical_smiles = array_key_exists('canonical_smiles', $standardisedMOL) ? $standardisedMOL['canonical_smiles'] : null; + $molecule->standard_inchi = array_key_exists('inchi', $standardisedMOL) ? $standardisedMOL['inchi'] : null; + $molecule->inchi_key = array_key_exists('canonicalinchikey_smiles', $standardisedMOL) ? $standardisedMOL['inchikey'] : null; + $molecule->save(); + } + if ($molecule->canonical_smiles) { + $cas = $this->fetchCAS($molecule->canonical_smiles); + $molecule->cas = $cas; + $molecule->save(); + } + + $progressBar->advance(); } - } + }); + + $progressBar->finish(); + $this->newLine(); + $this->info('Molecule sanitization completed successfully!'); } protected function fetchPubChemIUPACProperties($inchi) @@ -95,24 +116,20 @@ protected function fetchPubChemIUPACProperties($inchi) ]; } - protected function fetchCAS($smiles) + protected function fetchCAS(string $smiles): ?string { + if (! config('services.cas.api_token')) { + $this->error('CAS API token not configured'); + + return null; + } + try { - $ccBase = rtrim(config('services.common_chemistry.base_url'), '/'); - $ccApi = trim(config('services.common_chemistry.api_path'), '/'); - $response = Http::get($ccBase.'/'.$ccApi.'/search', [ - 'q' => $smiles, - ]); - if (! $response->failed()) { - $data = $response->json(); - if ($data['count'] > 0) { - $cas = $data['results'][0]['rn']; - - return $cas; - } - } - } catch (\Illuminate\Http\Client\ConnectionException $e) { - echo 'timed out: '.$smiles; + return $this->casService->searchCASBySmiles($smiles); + } catch (\Exception $e) { + $this->warn("Failed to fetch CAS for SMILES {$smiles}: ".$e->getMessage()); + + return null; } } @@ -124,7 +141,7 @@ protected function standardizeMolecule($mol) return $response->json(); } catch (\Illuminate\Http\Client\ConnectionException $e) { - echo 'timed out: '.$mol; + $this->warn('Chemistry standardize API timeout'); } } } diff --git a/app/Console/Commands/UpdateProjectStatuses.php b/app/Console/Commands/UpdateProjectStatuses.php new file mode 100644 index 00000000..d00badb7 --- /dev/null +++ b/app/Console/Commands/UpdateProjectStatuses.php @@ -0,0 +1,126 @@ +option('dry-run'); + + if ($isDryRun) { + $this->info('🔍 DRY RUN MODE - No changes will be made'); + } else { + $this->info('🚀 LIVE MODE - Project statuses will be updated'); + } + + $this->newLine(); + + return DB::transaction(function () use ($isDryRun) { + $now = Carbon::now(); + $updatedCount = 0; + + // Get all projects with their current data + $projects = Project::all(); + $this->info("📊 Found {$projects->count()} total projects to analyze"); + + // Track updates by status type + $statusCounts = [ + 'deleted' => 0, + 'archived' => 0, + 'embargo' => 0, + 'published' => 0, + 'draft' => 0, + ]; + + foreach ($projects as $project) { + $newStatus = $this->determineProjectStatus($project, $now); + $oldStatus = $project->status; + + if ($newStatus !== $oldStatus) { + $this->info("📝 Project {$project->id} ({$project->name}): '{$oldStatus}' → '{$newStatus}'"); + + if (! $isDryRun) { + $project->update(['status' => $newStatus]); + } + + $updatedCount++; + $statusCounts[$newStatus]++; + } else { + $this->line("✓ Project {$project->id} already has correct status: '{$newStatus}'"); + } + } + + $this->newLine(); + $this->info('📈 Status Distribution:'); + foreach ($statusCounts as $status => $count) { + if ($count > 0) { + $this->line(" • {$status}: {$count} projects"); + } + } + + $this->newLine(); + if ($isDryRun) { + $this->info("🔍 DRY RUN COMPLETE: {$updatedCount} projects would be updated"); + $this->info('💡 Run without --dry-run to apply changes'); + } else { + $this->info("✅ SUCCESS: Updated {$updatedCount} project statuses"); + } + + return 0; + }); + } + + /** + * Determine the correct status for a project based on its flags + */ + private function determineProjectStatus(Project $project, Carbon $now): string + { + // Priority order based on business logic: + + // 1. If deleted, status is 'deleted' + if ($project->is_deleted) { + return 'deleted'; + } + + // 2. If archived, status is 'archived' + if ($project->is_archived) { + return 'archived'; + } + + // 3. If public, status is 'published' + if ($project->is_public) { + return 'published'; + } + + // 4. If has future release_date, status is 'embargo' + if ($project->release_date && Carbon::parse($project->release_date)->isAfter($now)) { + return 'embargo'; + } + + // 5. Default to 'draft' for everything else + return 'draft'; + } +} diff --git a/app/Events/AddingProjectMember.php b/app/Events/AddingProjectMember.php index 580791b0..62dea8fa 100644 --- a/app/Events/AddingProjectMember.php +++ b/app/Events/AddingProjectMember.php @@ -11,14 +11,23 @@ class AddingProjectMember { use Dispatchable, InteractsWithSockets, SerializesModels; + /** + * The project instance. + */ + public $project; + + /** + * The user instance. + */ + public $user; + /** * Create a new event instance. - * - * @return void */ - public function __construct() + public function __construct($project, $user) { - // + $this->project = $project; + $this->user = $user; } /** diff --git a/app/Events/ProjectMemberAdded.php b/app/Events/ProjectMemberAdded.php index 5958d717..54e5e9a2 100644 --- a/app/Events/ProjectMemberAdded.php +++ b/app/Events/ProjectMemberAdded.php @@ -11,14 +11,23 @@ class ProjectMemberAdded { use Dispatchable, InteractsWithSockets, SerializesModels; + /** + * The project instance. + */ + public $project; + + /** + * The user instance. + */ + public $user; + /** * Create a new event instance. - * - * @return void */ - public function __construct() + public function __construct($project, $user) { - // + $this->project = $project; + $this->user = $user; } /** diff --git a/app/Http/Controllers/CASController.php b/app/Http/Controllers/CASController.php new file mode 100644 index 00000000..e60b8197 --- /dev/null +++ b/app/Http/Controllers/CASController.php @@ -0,0 +1,45 @@ +validate([ + 'cas_rn' => 'required|string|max:20', + ]); + + $casNumber = $request->input('cas_rn'); + + // Check if API token is configured + if (! Config::get('services.cas.api_token')) { + return response()->json([ + 'error' => 'CAS Service not configured', + ], 500); + } + + try { + $data = $this->casService->getCASDetails($casNumber); + + return response()->json($data); + + } catch (\Exception $e) { + return response()->json([ + 'error' => 'Unable to retrieve CAS details. Please verify the CAS number and try again.', + ], 400); + } + } +} diff --git a/app/Http/Controllers/DatasetController.php b/app/Http/Controllers/DatasetController.php index 64afcaea..246dcf6c 100644 --- a/app/Http/Controllers/DatasetController.php +++ b/app/Http/Controllers/DatasetController.php @@ -19,11 +19,15 @@ public function publicDatasetView(Request $request, $slug) { $dataset = Dataset::where('slug', $slug)->firstOrFail(); - if ($dataset->is_public) { - return Inertia::render('Public/Dataset', [ - 'dataset' => $dataset, - ]); + if (! $dataset->is_public) { + return response()->json([ + 'message' => 'Unauthorized', + ], 401); } + + return Inertia::render('Public/Dataset', [ + 'dataset' => $dataset, + ]); } public function fetchNMRium(Request $request, Dataset $dataset) @@ -142,12 +146,12 @@ public function snapshot(Request $request, Dataset $dataset) if ($content) { if ($study->project) { $path = '/projects/'.$study->project->uuid.'/'.$study->uuid.'/'.$dataset->slug.'.svg'; - Storage::disk(env('FILESYSTEM_DRIVER_PUBLIC'))->put($path, $content, 'public'); + Storage::disk(config('filesystems.default_public'))->put($path, $content, 'public'); $dataset->dataset_photo_path = $path; $dataset->save(); } else { $path = '/samples/'.$study->uuid.'/'.$dataset->slug.'.svg'; - Storage::disk(env('FILESYSTEM_DRIVER_PUBLIC'))->put($path, $content, 'public'); + Storage::disk(config('filesystems.default_public'))->put($path, $content, 'public'); $dataset->dataset_photo_path = $path; $dataset->save(); } diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 58f1f948..0f93b30b 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -251,7 +251,7 @@ public function toggleArchive(Request $request, StatefulGuard $guard, Project $p ]); } - $creator->toggle($project); + $creator->toggleArchive($project); return redirect()->route('dashboard')->with('success', 'Project archive state updated successfully'); } @@ -324,12 +324,21 @@ public function validationReport(Request $request, Project $project) public function publish(Request $request, Project $project, PublishProject $publisher, UpdateProject $updater) { + if (! Gate::forUser($request->user())->allows('publishProject', $project)) { + return response()->json(['message' => 'Forbidden'], 403); + } + if ($project) { $input = $request->all(); - $release_date = $input['release_date']; + $release_date = $request->get('release_date'); $enableProjectMode = $request->get('enableProjectMode'); if ($enableProjectMode) { $validation = $project->validation; + if (! $validation) { + return response()->json([ + 'errors' => 'Project validation not found. Please ensure the project is properly configured.', + ], 422); + } $validation->process(); $validation = $validation->fresh(); if ($validation['report']['project']['status']) { @@ -351,16 +360,20 @@ public function publish(Request $request, Project $project, PublishProject $publ } } else { $draft = $project->draft; - $draft->project_enabled = false; - $draft->save(); + if ($draft) { + $draft->project_enabled = false; + $draft->save(); + } $project->release_date = $request->get('release_date'); $project->status = 'queued'; $project->save(); $validation = $project->validation; - $validation->process(); - $validation = $validation->fresh(); + if ($validation) { + $validation->process(); + $validation = $validation->fresh(); + } foreach ($project->studies as $study) { $study->license_id = $project->license_id; @@ -373,9 +386,11 @@ public function publish(Request $request, Project $project, PublishProject $publ $status = true; - foreach ($validation['report']['project']['studies'] as $study) { - if (! $study['status']) { - $status = false; + if ($validation && isset($validation['report']['project']['studies'])) { + foreach ($validation['report']['project']['studies'] as $study) { + if (! $study['status']) { + $status = false; + } } } // add license check @@ -398,7 +413,7 @@ public function publish(Request $request, Project $project, PublishProject $publ public function store(Request $request, CreateNewProject $creator) { - if (! Gate::forUser($request->user())->check('createProject', $project)) { + if (! Gate::forUser($request->user())->allows('createProject', Project::class)) { throw new AuthorizationException; } diff --git a/app/Http/Controllers/StudyController.php b/app/Http/Controllers/StudyController.php index eb538299..fa2d27c1 100644 --- a/app/Http/Controllers/StudyController.php +++ b/app/Http/Controllers/StudyController.php @@ -13,7 +13,6 @@ use App\Models\Sample; use App\Models\Study; use App\Models\User; -use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -65,6 +64,8 @@ public function store(Request $request, CreateNewStudy $creator) public function update(Request $request, UpdateStudy $updater, Study $study) { + Gate::authorize('updateStudy', $study); + $updater->update($study, $request->all()); $study = $study->fresh(); @@ -78,9 +79,7 @@ public function update(Request $request, UpdateStudy $updater, Study $study) public function show(Request $request, Study $study, GetLicense $getLicense) { - if (! Gate::forUser($request->user())->check('viewStudy', $study)) { - throw new AuthorizationException; - } + Gate::forUser($request->user())->authorize('viewStudy', $study); $project = $study->project; $team = $project->nonPersonalTeam; @@ -102,9 +101,7 @@ public function protocols(Request $request, Study $study) public function datasets(Request $request, Study $study) { - if (! Gate::forUser($request->user())->check('viewStudy', $study)) { - throw new AuthorizationException; - } + Gate::forUser($request->user())->authorize('viewStudy', $study); $project = $study->project; $team = $project->team; @@ -208,6 +205,8 @@ public function renderTabView($tab, $study, $team, $project, $license, $studyFSO public function moleculeStore(Request $request, Study $study) { + Gate::forUser($request->user())->authorize('updateStudy', $study); + $sample = $study->sample; if (! $sample) { $sample = Sample::create([ @@ -277,6 +276,8 @@ public function nmriumVersions(Request $request, Study $study) public function nmriumInfo(Request $request, Study $study) { + Gate::forUser($request->user())->authorize('updateStudy', $study); + // $version = $request->get('version'); // $spectra = $request->get('spectra'); // $molecules = $nmriumInfo['data']['molecules']; @@ -380,9 +381,7 @@ public function moleculeDetach(Request $request, Study $study, Molecule $molecul public function files(Request $request, Study $study) { - if (! Gate::forUser($request->user())->check('viewStudy', $study)) { - throw new AuthorizationException; - } + Gate::forUser($request->user())->authorize('viewStudy', $study); $project = $study->project; $team = $project->nonPersonalTeam; @@ -393,9 +392,7 @@ public function files(Request $request, Study $study) public function annotations(Request $request, Study $study) { - if (! Gate::forUser($request->user())->check('viewStudy', $study)) { - throw new AuthorizationException; - } + Gate::forUser($request->user())->authorize('viewStudy', $study); $studyFSObject = FileSystemObject::with('children') ->where([ @@ -497,6 +494,8 @@ public function Notifications(Request $request, Study $study) public function settings(Request $request, Study $study) { + Gate::forUser($request->user())->authorize('viewStudy', $study); + return Inertia::render('Study/Settings', [ 'study' => $study, 'project' => $study->project, @@ -508,6 +507,8 @@ public function destroy( StatefulGuard $guard, Study $study ) { + Gate::forUser($request->user())->authorize('deleteStudy', $study); + $confirmed = app(ConfirmPassword::class)( $guard, $request->user(), @@ -545,6 +546,8 @@ public function toggleStarred(Request $request, Study $study) public function snapshot(Request $request, Study $study) { + Gate::forUser($request->user())->authorize('updateStudy', $study); + $content = $request->get('img'); if ($content) { $path = '/projects/'.$study->project->uuid.'/'.$study->slug.'.svg'; diff --git a/app/Http/Controllers/SupportBubbleController.php b/app/Http/Controllers/SupportBubbleController.php new file mode 100644 index 00000000..fcea9ad0 --- /dev/null +++ b/app/Http/Controllers/SupportBubbleController.php @@ -0,0 +1,45 @@ +input('subject'), + $request->input('message'), + $request->input('email'), + $request->input('name'), + $request->input('url'), + $request->ip(), + $request->userAgent(), + $request + )); + + return response()->view('support-bubble::success'); + + } catch (\Exception $e) { + Log::error('Support bubble submission failed', [ + 'error' => $e->getMessage(), + 'ip' => $request->ip(), + 'data' => $request->only(['email', 'subject']), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while processing your request. Please try again.', + ], 500); + } + } +} diff --git a/app/Http/Requests/SupportBubbleRequest.php b/app/Http/Requests/SupportBubbleRequest.php new file mode 100644 index 00000000..85a3e0e1 --- /dev/null +++ b/app/Http/Requests/SupportBubbleRequest.php @@ -0,0 +1,265 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => [ + 'required', + 'email', + 'max:255', + function ($attribute, $value, $fail) { + // Block disposable email domains + $disposableDomains = [ + '10minutemail.com', '10minutemail.net', 'guerrillamail.com', + 'mailinator.com', 'yopmail.com', 'tempmail.org', 'throwaway.email', + 'temp-mail.org', 'getnada.com', 'maildrop.cc', + ]; + + $domain = substr(strrchr($value, '@'), 1); + if (in_array(strtolower($domain), $disposableDomains)) { + $fail('Please use a permanent email address. Temporary email services are not allowed.'); + } + + // Extract username part (before @) + $username = substr($value, 0, strpos($value, '@')); + + // Enhanced gibberish detection for email usernames + if ($this->isGibberishEmail($username)) { + $fail('Please enter a valid email address. Random characters are not allowed.'); + } + + // Block suspicious patterns (original check) + if (preg_match('/^[a-z0-9]{10,20}@gmail\.com$/i', $value)) { + $fail('Please enter a valid email address. Random characters are not allowed.'); + } + }, + ], + 'name' => [ + 'nullable', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + if ($value && $this->containsGibberish($value)) { + $fail('The name contains invalid characters or patterns.'); + } + }, + ], + 'subject' => [ + 'required', + 'string', + 'max:255', + 'min:3', + function ($attribute, $value, $fail) { + if ($this->containsGibberish($value)) { + $fail('The subject must contain meaningful text.'); + } + + // Check for excessive special characters + if (preg_match_all('/[^a-zA-Z0-9\s]/', $value) > strlen($value) * 0.3) { + $fail('The subject contains too many special characters.'); + } + }, + ], + 'message' => [ + 'required', + 'string', + 'max:2000', + 'min:10', + function ($attribute, $value, $fail) { + if ($this->containsGibberish($value)) { + $fail('The message must contain meaningful text.'); + } + + // Check for excessive special characters + if (preg_match_all('/[^a-zA-Z0-9\s\.\,\!\?\-]/', $value) > strlen($value) * 0.2) { + $fail('The message contains too many special characters.'); + } + + // Check for minimum word count + $wordCount = str_word_count($value); + if ($wordCount < 3) { + $fail('The message must contain at least 3 words.'); + } + }, + ], + 'url' => 'nullable|url|max:255', + 'g-recaptcha-response' => [ + function ($attribute, $value, $fail) { + if (config('services.recaptcha.site_key') && empty($value)) { + $fail('Please complete the CAPTCHA verification.'); + + return; + } + if (config('services.recaptcha.site_key') && ! empty($value)) { + $rule = new RecaptchaRule; + $rule->validate($attribute, $value, $fail); + } + }, + ], + ]; + } + + /** + * Get custom messages for validation errors. + */ + public function messages(): array + { + return [ + 'subject.min' => 'The subject must be at least 3 characters long.', + 'message.min' => 'The message must be at least 10 characters long.', + 'email.required' => 'An email address is required.', + 'email.email' => 'Please enter a valid email address format (e.g., name@domain.com).', + 'subject.required' => 'A subject is required.', + 'message.required' => 'A message is required.', + ]; + } + + /** + * Check if text contains gibberish or random character patterns + */ + protected function containsGibberish(string $text): bool + { + // Allow common words and normal text + if (strlen($text) < 15) { + return false; // Don't check short text like "Hello" + } + + // Check for random character patterns (like "αχΥηΤrvnIbuQzoGkkbYqjEr") + // High ratio of consonants without vowels + $consonantRatio = preg_match_all('/[bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ]/', $text); + $vowelRatio = preg_match_all('/[aeiouAEIOU]/', $text); + + if (strlen($text) > 20 && $vowelRatio == 0) { + return true; + } + + if (strlen($text) > 15 && $consonantRatio / max(strlen($text), 1) > 0.85) { + return true; + } + + // Check for mixed scripts (Latin + Greek/Cyrillic like in spam) + $hasLatin = preg_match('/[a-zA-Z]/', $text); + $hasGreek = preg_match('/[\x{0370}-\x{03FF}]/u', $text); + $hasCyrillic = preg_match('/[\x{0400}-\x{04FF}]/u', $text); + + if (($hasLatin && $hasGreek) || ($hasLatin && $hasCyrillic)) { + return true; + } + + // Check for alternating case patterns (like "αχΥηΤrvnIbu") + if (preg_match('/([a-z][A-Z]){4,}|([A-Z][a-z]){4,}/', $text)) { + return true; + } + + // Check for sequences of random characters + if (preg_match('/[a-zA-Z]{20,}/', $text) && ! preg_match('/\s/', $text)) { + return true; + } + + return false; + } + + /** + * Check if email username contains gibberish patterns + */ + protected function isGibberishEmail(string $username): bool + { + // Allow common email patterns first + if (strlen($username) < 6) { + return false; // Allow short usernames like "john", "mary" + } + + // Check for too many consecutive consonants (like "jkdkfjdks") + if (preg_match('/[bcdfghjklmnpqrstvwxyz]{4,}/i', $username)) { + return true; + } + + // Check for alternating consonant patterns without vowels + $consonantCount = preg_match_all('/[bcdfghjklmnpqrstvwxyz]/i', $username); + $vowelCount = preg_match_all('/[aeiou]/i', $username); + + // If username is mostly consonants (>80%) and longer than 6 chars, likely gibberish + if (strlen($username) > 6 && $consonantCount > 0 && ($vowelCount / max($consonantCount, 1)) < 0.25) { + return true; + } + + // Check for patterns like repeated character groups "jkjk", "dkdk" + if (preg_match('/(.{2,3})\1{2,}/', $username)) { + return true; + } + + // Check for keyboard patterns like "qwerty", "asdf", etc. + $keyboardPatterns = [ + 'qwert', 'asdf', 'zxcv', 'uiop', 'hjkl', 'bnm', + 'poiu', 'lkjh', 'mnbv', 'rewq', 'fdsa', 'vcxz', + ]; + + foreach ($keyboardPatterns as $pattern) { + if (stripos($username, $pattern) !== false) { + return true; + } + } + + // Check for completely random character sequences + // If no vowels and more than 8 characters, likely random + if (strlen($username) > 8 && $vowelCount == 0) { + return true; + } + + return false; + } + + /** + * Handle a failed validation attempt. + */ + protected function failedValidation(\Illuminate\Contracts\Validation\Validator $validator): void + { + // Log suspicious attempts for monitoring + if ($this->containsSpamIndicators()) { + Log::warning('Potential spam attempt blocked on support bubble', [ + 'ip' => $this->ip(), + 'user_agent' => $this->userAgent(), + 'data' => $this->only(['email', 'subject', 'message']), + 'errors' => $validator->errors()->toArray(), + ]); + } + + parent::failedValidation($validator); + } + + /** + * Check if the request contains spam indicators + */ + protected function containsSpamIndicators(): bool + { + $email = $this->input('email', ''); + $subject = $this->input('subject', ''); + $message = $this->input('message', ''); + + // Check for the specific patterns seen in the spam + return $this->containsGibberish($subject) || + $this->containsGibberish($message) || + preg_match('/^[a-z0-9]{10,20}@gmail\.com$/i', $email); + } +} diff --git a/app/Http/Resources/StudyResource.php b/app/Http/Resources/StudyResource.php index 4aa2c4b4..4978b4b2 100644 --- a/app/Http/Resources/StudyResource.php +++ b/app/Http/Resources/StudyResource.php @@ -33,7 +33,7 @@ public function toArray($request): array 'name' => $this->name, 'slug' => $this->slug, 'description' => $this->description, - 'molecules' => $this->sample->molecules, + 'molecules' => $this->sample ? $this->sample->molecules : [], 'team' => $this->when(! ($this->team && $this->team->personal_team), $this->team), 'photo_url' => $this->study_photo_url, 'tags' => $this->tags, diff --git a/app/Jobs/ProcessProjectSpectra.php b/app/Jobs/ProcessProjectSpectra.php deleted file mode 100644 index 7c73d8b8..00000000 --- a/app/Jobs/ProcessProjectSpectra.php +++ /dev/null @@ -1,303 +0,0 @@ -projectId); - - if (! $project) { - Log::error("Project not found: {$this->projectId}"); - - return; - } - - try { - Log::info("Starting spectra processing for project: {$project->identifier}"); - - $this->processProjectStudies($project); - - Log::info("Successfully completed spectra processing for project: {$project->identifier}"); - - } catch (Exception $e) { - Log::error("Failed to process spectra for project {$project->identifier}: ".$e->getMessage()); - throw $e; - } - } - - /** - * Process all studies in the project. - */ - private function processProjectStudies(Project $project): void - { - $studies = $project->studies; - - foreach ($studies as $study) { - Log::info("Processing study: {$study->identifier}"); - - try { - // Process study-level NMRium data - $this->processStudySpectra($study); - - // Process dataset-level NMRium data - $this->processStudyDatasets($study->fresh()); - - } catch (Exception $e) { - Log::error("Failed to process study {$study->identifier}: ".$e->getMessage()); - - // Continue with other studies instead of failing the entire job - continue; - } - } - } - - /** - * Process spectra for a single study. - */ - private function processStudySpectra($study): void - { - if ($study->has_nmrium) { - return; // Already processed - } - - DB::transaction(function () use ($study) { - $downloadUrl = $study->download_url; - - if (! $downloadUrl) { - Log::warning("No download URL found for study: {$study->identifier}"); - - return; - } - - $nmriumData = $this->processSpectra($downloadUrl); - - if (! $nmriumData || ! isset($nmriumData['data'])) { - Log::warning("No valid spectra data returned for study: {$study->identifier}"); - - return; - } - - $parsedSpectra = $nmriumData['data']; - - // Clean up spectra data - foreach ($parsedSpectra['spectra'] as &$spectra) { - unset($spectra['data']); - unset($spectra['meta']); - unset($spectra['originalData']); - unset($spectra['originalInfo']); - } - - $version = $parsedSpectra['version'] ?? null; - unset($parsedSpectra['version']); - - $nmriumJSON = [ - 'data' => $parsedSpectra, - 'version' => $version, - ]; - - // Create or update NMRium record - $nmrium = $study->nmrium; - - if ($nmrium) { - $nmrium->nmrium_info = json_encode($nmriumJSON, JSON_UNESCAPED_UNICODE); - $nmrium->save(); - } else { - $nmrium = NMRium::create([ - 'nmrium_info' => json_encode($nmriumJSON, JSON_UNESCAPED_UNICODE), - ]); - $study->nmrium()->save($nmrium); - } - - $study->has_nmrium = true; - $study->save(); - - Log::info("Successfully processed study spectra: {$study->identifier}"); - }); - } - - /** - * Process datasets for a study. - */ - private function processStudyDatasets($study): void - { - if (! $study->has_nmrium) { - Log::warning("Study {$study->identifier} has no NMRium data, skipping datasets"); - - return; - } - - $nmriumInfo = json_decode($study->nmrium->nmrium_info, true); - - if (! isset($nmriumInfo['data']['spectra']) || count($nmriumInfo['data']['spectra']) == 0) { - Log::warning("Study {$study->identifier} has no spectra info, skipping datasets"); - - return; - } - - foreach ($study->datasets as $dataset) { - if ($dataset->has_nmrium) { - continue; // Already processed - } - - try { - $this->processDatasetSpectra($dataset, $study, $nmriumInfo); - } catch (Exception $e) { - Log::error("Failed to process dataset {$dataset->identifier}: ".$e->getMessage()); - - continue; - } - } - } - - /** - * Process spectra for a single dataset. - */ - private function processDatasetSpectra($dataset, $study, $nmriumInfo): void - { - $nmriumJSON = $nmriumInfo; - $fsObject = $dataset->fsObject; - $studyFSObject = $study->fsObject; - $datasetFSObject = $dataset->fsObject; - $draft = $study->draft; - - // Determine path based on ELN type - if ($draft && $draft->eln == 'chemotion') { - $path = '/'.$studyFSObject->name.'/'.$datasetFSObject->parent->name.'/'.$datasetFSObject->name; - } else { - $path = '/'.$studyFSObject->name.'/'.$datasetFSObject->name; - } - - $fType = $studyFSObject->type; - $matchingSpectra = []; - $types = []; - - // Find matching spectra for this dataset - foreach ($nmriumInfo['data']['spectra'] as $spectra) { - if ($this->spectraMatchesDataset($spectra, $path, $fType)) { - $matchingSpectra[] = $spectra; - $types[] = $this->extractSpectraType($spectra); - } - } - - if (count($matchingSpectra) > 0) { - // Create dataset-specific NMRium data - unset($nmriumJSON['data']['spectra']); - $nmriumJSON['data']['spectra'] = $matchingSpectra; - - // Create or update NMRium record for dataset - $nmrium = $dataset->nmrium; - - if ($nmrium) { - $nmrium->nmrium_info = json_encode($nmriumJSON, JSON_UNESCAPED_UNICODE); - $nmrium->save(); - } else { - $nmrium = NMRium::create([ - 'nmrium_info' => json_encode($nmriumJSON, JSON_UNESCAPED_UNICODE), - ]); - $dataset->nmrium()->save($nmrium); - } - - $dataset->has_nmrium = true; - - Log::info("Successfully processed dataset spectra: {$dataset->identifier}"); - } else { - Log::info("No matching spectra found for dataset: {$dataset->identifier}"); - } - - // Always update dataset type if unique (regardless of whether spectra were found) - $uniqueTypes = array_unique(array_filter($types)); - if (count($uniqueTypes) == 1) { - $dataset->type = $uniqueTypes[0]; - } - - $dataset->save(); - } - - /** - * Check if spectra matches the dataset path. - */ - private function spectraMatchesDataset($spectra, string $path, string $fType): bool - { - $files = $spectra['sourceSelector']['files'] ?? []; - - if (! $files) { - return false; - } - - foreach ($files as $file) { - $searchPath = $fType == 'file' ? $path : $path.'/'; - if (str_contains($file, $searchPath)) { - return true; - } - } - - return false; - } - - /** - * Extract spectra type from spectra info. - */ - private function extractSpectraType($spectra): ?string - { - if (! isset($spectra['info']['experiment'])) { - return null; - } - - $experiment = $spectra['info']['experiment']; - $nucleus = $spectra['info']['nucleus'] ?? null; - - if (is_array($nucleus)) { - $nucleus = implode('-', $nucleus); - } - - return $nucleus ? "{$experiment} - {$nucleus}" : $experiment; - } - - /** - * Process spectra using external service. - */ - private function processSpectra(string $url): ?array - { - try { - $encodedUrl = urlencode($url); - - $response = Http::timeout(300)->post('https://nodejs.nmrxiv.org/spectra-parser', [ - 'urls' => [$encodedUrl], - 'snapshot' => false, - ]); - - if (! $response->successful()) { - Log::error('Spectra processing service returned error: '.$response->status()); - - return null; - } - - return $response->json(); - - } catch (Exception $e) { - Log::error("Failed to process spectra from URL {$url}: ".$e->getMessage()); - - return null; - } - } -} diff --git a/app/Jobs/ProcessSubmission.php b/app/Jobs/ProcessSubmission.php index 160b5f89..57122def 100644 --- a/app/Jobs/ProcessSubmission.php +++ b/app/Jobs/ProcessSubmission.php @@ -99,14 +99,17 @@ public function handle(AssignIdentifier $assigner, UpdateDOI $updater, PublishPr $project->draft_id = null; - $project->status = 'complete'; + $release_date = Carbon::parse($project->release_date); + if ($release_date->isFuture()) { + $project->status = 'embargo'; + } else { + $project->status = 'published'; + } $project->save(); $assigner->assign($project->fresh()); - $release_date = Carbon::parse($project->release_date); - if ($release_date->isPast()) { $projectPublisher->publish($project); } diff --git a/app/Models/Dataset.php b/app/Models/Dataset.php index 30b19d9f..34707ac4 100644 --- a/app/Models/Dataset.php +++ b/app/Models/Dataset.php @@ -52,6 +52,17 @@ class Dataset extends Model implements Auditable 'dataset_photo_url', ]; + /** + * The attributes that should be cast. + */ + protected function casts(): array + { + return [ + 'starred' => 'boolean', + 'is_public' => 'boolean', + ]; + } + /** * Get the dataset identifier */ diff --git a/app/Models/NMRium.php b/app/Models/NMRium.php index b402ccb2..9e10f4cd 100644 --- a/app/Models/NMRium.php +++ b/app/Models/NMRium.php @@ -19,6 +19,8 @@ class NMRium extends Model protected $fillable = [ 'nmrium_info', + 'nmriumable_id', + 'nmriumable_type', 'dataset_id', ]; diff --git a/app/Models/Project.php b/app/Models/Project.php index 8cad91ce..66237b76 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -42,6 +42,9 @@ class Project extends Model implements Auditable 'starred', 'location', 'is_public', + 'is_deleted', + 'is_archived', + 'status', 'obfuscationcode', 'description', 'type', diff --git a/app/Models/ProjectInvitation.php b/app/Models/ProjectInvitation.php index 024c8096..c7c84c4d 100644 --- a/app/Models/ProjectInvitation.php +++ b/app/Models/ProjectInvitation.php @@ -2,11 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class ProjectInvitation extends Model { + use HasFactory; + /** * The attributes that are mass assignable. * diff --git a/app/Models/Study.php b/app/Models/Study.php index 8de2b3fb..3418f01e 100644 --- a/app/Models/Study.php +++ b/app/Models/Study.php @@ -38,6 +38,7 @@ class Study extends Model implements Auditable 'starred', 'location', 'is_public', + 'is_archived', 'obfuscationcode', 'description', 'type', @@ -60,6 +61,9 @@ class Study extends Model implements Auditable 'external_url', 'processing_logs', 'tracking_item_name', + 'doi', + 'identifier', + 'validation_id', ]; /** @@ -72,6 +76,9 @@ protected function casts(): array 'citations' => 'array', 'molecules' => 'array', 'processing_logs' => 'array', + 'starred' => 'boolean', + 'is_public' => 'boolean', + 'is_archived' => 'boolean', ]; } @@ -311,9 +318,7 @@ public function removeUser($user) */ public function shouldBeSearchable() { - if ($this->is_public && ! $this->is_archived) { - return true; - } + return $this->is_public && ! $this->is_archived; } /** diff --git a/app/Models/StudyInvitation.php b/app/Models/StudyInvitation.php index b2882e40..24508434 100644 --- a/app/Models/StudyInvitation.php +++ b/app/Models/StudyInvitation.php @@ -2,17 +2,21 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class StudyInvitation extends Model { + use HasFactory; + /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ + 'study_id', 'email', 'role', 'message', diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index 4abc9afd..b27908aa 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -72,6 +72,20 @@ public function updateProject(User $user, Project $project) return $user->canUpdateProject($project); } + /** + * Determine whether the user can publish the model. + * + * @return mixed + */ + public function publishProject(User $user, Project $project) + { + if ($project->is_public || $project->is_archived || $project->is_deleted || $project->is_published) { + return false; + } + + return $user->canUpdateProject($project); + } + /** * Determine whether the user can delete the model. * diff --git a/app/Policies/StudyPolicy.php b/app/Policies/StudyPolicy.php index 079c70b8..87da593c 100644 --- a/app/Policies/StudyPolicy.php +++ b/app/Policies/StudyPolicy.php @@ -35,12 +35,16 @@ public function viewAny(User $user): bool * * @return mixed */ - public function viewStudy(User $user, Study $study) + public function viewStudy(?User $user, Study $study) { - if (is_null($user) && $study->is_public) { + if ($study->is_public) { return true; } + if (is_null($user)) { + return false; + } + return $user->belongsToStudy($study); } diff --git a/app/Providers/CASServiceProvider.php b/app/Providers/CASServiceProvider.php new file mode 100644 index 00000000..572cae57 --- /dev/null +++ b/app/Providers/CASServiceProvider.php @@ -0,0 +1,40 @@ +app->bind(CASService::class, function ($app) { + $provider = config('services.cas.provider'); + if ($provider === 'CAS_CommonChemistry') { + return new CommonChemistry; + } + throw new \Exception('Invalid CAS provider: '.$provider); + }); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index b2b03e02..8ed78205 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -73,5 +73,23 @@ protected function configureRateLimiting(): void RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); + + // Support bubble rate limiting to prevent spam + RateLimiter::for('support-bubble', function (Request $request) { + // Skip rate limiting in testing environment + if (app()->environment('testing')) { + return Limit::none(); + } + + return [ + // Allow 5 submissions per minute per IP + Limit::perMinute(1)->by($request->ip()), + // Allow 20 submissions per hour per IP + Limit::perHour(5)->by($request->ip()), + // Allow 50 submissions per day per IP + Limit::perDay(20)->by($request->ip()), + + ]; + }); } } diff --git a/app/Services/CAS/CASService.php b/app/Services/CAS/CASService.php new file mode 100644 index 00000000..3eced8a6 --- /dev/null +++ b/app/Services/CAS/CASService.php @@ -0,0 +1,16 @@ + Config::get('services.cas.base_url'), + 'api_token' => Config::get('services.cas.api_token'), + ]; + } + + /** + * Fetch detailed information for a given CAS registry number + */ + public function getCASDetails(string $casNumber): array + { + try { + $config = $this->getApiConfig(); + + $response = Http::timeout(self::REQUEST_TIMEOUT) + ->withHeaders([ + 'X-API-KEY' => $config['api_token'], + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]) + ->get("{$config['base_url']}/detail", [ + 'cas_rn' => $casNumber, + ]); + + if ($response->successful()) { + $data = $response->json(); + + // Ensure we have valid array data + if (is_array($data)) { + return $data; + } + + throw new \Exception('Invalid response format from CAS API'); + } + + throw new \Exception('Unable to retrieve CAS details. Please verify the CAS number and try again.'); + } catch (\Exception $e) { + throw new \Exception('Unable to retrieve CAS details. Please verify the CAS number and try again.'); + } + } + + /** + * Search for CAS registry number using SMILES molecular structure notation + */ + public function searchCASBySmiles(string $smiles): ?string + { + try { + $config = $this->getApiConfig(); + + $response = Http::timeout(self::REQUEST_TIMEOUT) + ->withHeaders([ + 'X-API-KEY' => $config['api_token'], + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]) + ->get("{$config['base_url']}/search", [ + 'q' => $smiles, + ]); + + if ($response->successful()) { + $data = $response->json(); + + if (isset($data['count']) && $data['count'] > 0 && isset($data['results'][0]['rn'])) { + return $data['results'][0]['rn']; + } + } + + return null; + + } catch (\Exception $e) { + return null; + } + } +} diff --git a/app/Support/Csp/Policies/NmrxivPolicy.php b/app/Support/Csp/Policies/NmrxivPolicy.php new file mode 100644 index 00000000..9d40eee7 --- /dev/null +++ b/app/Support/Csp/Policies/NmrxivPolicy.php @@ -0,0 +1,136 @@ +add(Directive::BASE, Keyword::SELF) + ->add(Directive::DEFAULT, Keyword::SELF) + ->add(Directive::FORM_ACTION, Keyword::SELF) + ->add(Directive::OBJECT, Keyword::NONE); + + // Basic asset sources + $policy + ->add(Directive::SCRIPT, Keyword::SELF) + ->add(Directive::STYLE, Keyword::SELF) + ->add(Directive::FONT, 'data:') + ->add(Directive::CONNECT, Keyword::SELF); + + // Third-party services + $policy + ->add(Directive::STYLE, 'https://fonts.bunny.net') + ->add(Directive::SCRIPT, 'https://matomo.nfdi4chem.de') + ->add(Directive::CONNECT, 'https://matomo.nfdi4chem.de', 'https://fonts.bunny.net'); + + // Add nmrXiv-specific external sources + $this->addNmrxivSources($policy); + + // Unified rules for all environments + $this->addUnifiedRules($policy); + } + + private function addUnifiedRules(Policy $policy): void + { + // Allow unsafe-inline and unsafe-eval for maximum compatibility + // This is acceptable when you control all inline scripts and have other security layers + $policy + ->add(Directive::SCRIPT, Keyword::UNSAFE_INLINE) + ->add(Directive::SCRIPT, Keyword::UNSAFE_EVAL) + ->add(Directive::STYLE, Keyword::UNSAFE_INLINE); + + // Development server support (for local development with Vite) + $policy + ->add(Directive::SCRIPT, self::LOCAL_HOSTS) + ->add(Directive::STYLE, self::LOCAL_HOSTS) + ->add(Directive::CONNECT, array_merge(self::LOCAL_WS_HOSTS, self::LOCAL_HOSTS)); + } + + /** + * Add nmrXiv-specific external sources. + * For runtime-configurable sources, use config/csp.php with CSP_ADDITIONAL_* env variables. + */ + private function addNmrxivSources(Policy $policy): void + { + // Image sources + $policy + ->add(Directive::IMG, Keyword::SELF) + ->add(Directive::IMG, 'data:') + ->add(Directive::IMG, 'blob:') + ->add(Directive::IMG, 'https://s3.uni-jena.de') + ->add(Directive::IMG, 'https://orcid.org') + ->add(Directive::IMG, 'https://ui-avatars.com') + ->add(Directive::IMG, 'https://www.nfdi4chem.de') + ->add(Directive::IMG, 'https://www.nmrium.org') + ->add(Directive::IMG, 'https://nmriumdev.nmrxiv.org') + ->add(Directive::IMG, 'https://upload.wikimedia.org') + ->add(Directive::IMG, 'https://pbs.twimg.com') + ->add(Directive::IMG, 'https://api.cheminf.studio') + ->add(Directive::IMG, 'https://api.naturalproducts.net') + ->add(Directive::IMG, 'https://dev.api.naturalproducts.net') + ->add(Directive::IMG, 'https://placehold.co'); + + // Connection sources + $policy + ->add(Directive::CONNECT, env('DATACITE_API', 'https://api.datacite.org')) + ->add(Directive::CONNECT, env('CROSSREF_API', 'https://api.crossref.org/works/')) + ->add(Directive::CONNECT, env('DATACITE_ENDPOINT', 'https://api.datacite.org')) + ->add(Directive::CONNECT, env('NMRKIT_URL', 'https://nodejs.nmrxiv.org')) + ->add(Directive::CONNECT, config('services.pubchem.base_url')) + ->add(Directive::CONNECT, config('services.cas.base_url')) + ->add(Directive::CONNECT, config('services.chemistry_standardize.url')) + ->add(Directive::CONNECT, env('EUROPEMC_WS_API', 'https://www.ebi.ac.uk/europepmc/webservices/rest/search')) + ->add(Directive::CONNECT, env('ORCID_ID_SEARCH_API', 'https://pub.orcid.org')) + ->add(Directive::CONNECT, config('services.chemotion_tracker.base_url')) + ->add(Directive::CONNECT, env('CM_API', 'https://api.cheminf.studio')) + ->add(Directive::CONNECT, env('AWS_ENDPOINT', 'https://s3.uni-jena.de')) + ->add(Directive::CONNECT, 'https://nmrium.nmrxiv.org') + ->add(Directive::CONNECT, 'https://nmriumdev.nmrxiv.org') + ->add(Directive::CONNECT, 'https://api.cheminf.studio') + ->add(Directive::CONNECT, 'https://api.naturalproducts.net') + ->add(Directive::CONNECT, 'https://dev.api.naturalproducts.net'); + + // Font sources + $policy + ->add(Directive::FONT, 'https://fonts.googleapis.com') + ->add(Directive::FONT, 'https://fonts.gstatic.com') + ->add(Directive::FONT, 'https://fonts.bunny.net'); + + // Frame sources + $policy + ->add(Directive::FRAME, Keyword::SELF) + ->add(Directive::FRAME, 'https://api.cheminf.studio') + ->add(Directive::FRAME, 'https://api.naturalproducts.net') + ->add(Directive::FRAME, 'https://dev.api.naturalproducts.net') + ->add(Directive::FRAME, 'https://nmrium.nmrxiv.org') + ->add(Directive::FRAME, 'https://nmriumdev.nmrxiv.org'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a2f4fda8..6014e152 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -34,6 +34,7 @@ \Laravel\Jetstream\Http\Middleware\AuthenticateSession::class, \App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\XFrameOptions::class, + \Spatie\Csp\AddCspHeaders::class, ]); $middleware->throttleApi(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 98112431..3783965e 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\CASServiceProvider::class, App\Providers\CephStorageServiceProvider::class, App\Providers\DOIServiceProvider::class, App\Providers\EventServiceProvider::class, diff --git a/composer.json b/composer.json index 2dddb0e8..9d9ab3ef 100644 --- a/composer.json +++ b/composer.json @@ -8,9 +8,9 @@ ], "license": "MIT", "require": { - "php": "^8.2", - "aws/aws-sdk-php": "^3.208", - "darkaonline/l5-swagger": "^8.5", + "php": "^8.4", + "aws/aws-sdk-php": "^3.368", + "darkaonline/l5-swagger": "^9.0", "doctrine/dbal": "^3.5", "guzzlehttp/guzzle": "^7.8", "http-interop/http-factory-guzzle": "^1.2", @@ -33,10 +33,10 @@ "owen-it/laravel-auditing": "^14.0", "phpunit/php-code-coverage": "^11.0.9", "predis/predis": "^2.2", - "socialiteproviders/orcid": "^5.1", "socialiteproviders/manager": "^4.7", "spatie/laravel-backup": "^9.2", "spatie/laravel-cookie-consent": "^3.3", + "spatie/laravel-csp": "^3.18", "spatie/laravel-permission": "^6.12", "spatie/laravel-query-builder": "^6.3", "spatie/laravel-support-bubble": "^1.8", @@ -102,9 +102,6 @@ "allow-plugins": { "php-http/discovery": true }, - "platform": { - "php": "8.3.28" - }, "audit": { "abandoned": "ignore" } diff --git a/composer.lock b/composer.lock index ded10ad6..4c6fec8a 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": "8348270f2f9171c11e79dcf9d7b1a7b7", + "content-hash": "1fac7f6d5d28ad3da652d22c02637666", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.365.0", + "version": "3.369.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ce4b9a5fe8bad81caf40a3cca8069eba750103b1" + "reference": "8678ee11ac680d462fabbecb455112a17aa7728d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ce4b9a5fe8bad81caf40a3cca8069eba750103b1", - "reference": "ce4b9a5fe8bad81caf40a3cca8069eba750103b1", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8678ee11ac680d462fabbecb455112a17aa7728d", + "reference": "8678ee11ac680d462fabbecb455112a17aa7728d", "shasum": "" }, "require": { @@ -84,7 +84,8 @@ "guzzlehttp/psr7": "^2.4.5", "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", - "psr/http-message": "^1.0 || ^2.0" + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -95,13 +96,11 @@ "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", - "ext-pcntl": "*", "ext-sockets": "*", - "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "phpunit/phpunit": "^9.6", "psr/cache": "^2.0 || ^3.0", "psr/simple-cache": "^2.0 || ^3.0", "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", - "symfony/filesystem": "^v6.4.0 || ^v7.1.0", "yoast/phpunit-polyfills": "^2.0" }, "suggest": { @@ -109,6 +108,7 @@ "doctrine/cache": "To use the DoctrineCacheAdapter", "ext-curl": "To send requests using cURL", "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", "ext-sockets": "To use client-side monitoring" }, "type": "library", @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.365.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.7" }, - "time": "2025-12-02T16:06:36+00:00" + "time": "2026-01-05T19:05:14+00:00" }, { "name": "bacon/bacon-qr-code", @@ -343,32 +343,33 @@ }, { "name": "darkaonline/l5-swagger", - "version": "8.6.5", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/DarkaOnLine/L5-Swagger.git", - "reference": "4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85" + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DarkaOnLine/L5-Swagger/zipball/4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85", - "reference": "4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85", + "url": "https://api.github.com/repos/DarkaOnLine/L5-Swagger/zipball/2c26427f8c41db8e72232415e7287313e6b6a2e2", + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2", "shasum": "" }, "require": { "doctrine/annotations": "^1.0 || ^2.0", "ext-json": "*", - "laravel/framework": "^11.0 || ^10.0 || ^9.0 || >=8.40.0 || ^7.0", - "php": "^7.2 || ^8.0", - "swagger-api/swagger-ui": "^3.0 || >=4.1.3", + "laravel/framework": "^12.0 || ^11.0", + "php": "^8.2", + "swagger-api/swagger-ui": ">=5.18.3", "symfony/yaml": "^5.0 || ^6.0 || ^7.0", - "zircote/swagger-php": "^3.2.0 || ^4.0.0" + "zircote/swagger-php": "^5.0.0" }, "require-dev": { "mockery/mockery": "1.*", - "orchestra/testbench": "^9.0 || ^8.0 || 7.* || ^6.15 || 5.*", + "orchestra/testbench": "^10.0 || ^9.0 || ^8.0 || 7.* || ^6.15 || 5.*", "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^11.0 || ^10.0 || ^9.5" + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { @@ -411,7 +412,7 @@ ], "support": { "issues": "https://github.com/DarkaOnLine/L5-Swagger/issues", - "source": "https://github.com/DarkaOnLine/L5-Swagger/tree/8.6.5" + "source": "https://github.com/DarkaOnLine/L5-Swagger/tree/9.0.1" }, "funding": [ { @@ -419,7 +420,7 @@ "type": "github" } ], - "time": "2025-02-06T14:54:32+00:00" + "time": "2025-02-28T06:25:02+00:00" }, { "name": "dasprid/enum", @@ -5191,6 +5192,53 @@ ], "time": "2025-10-06T01:07:24+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "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.3.0" + }, + "time": "2025-08-30T15:50:23+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.11", @@ -6630,56 +6678,6 @@ }, "time": "2025-02-24T19:33:30+00:00" }, - { - "name": "socialiteproviders/orcid", - "version": "5.1.0", - "source": { - "type": "git", - "url": "https://github.com/SocialiteProviders/Orcid.git", - "reference": "9a61194bd23394b851ef6d879991ebc284cd74d8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Orcid/zipball/9a61194bd23394b851ef6d879991ebc284cd74d8", - "reference": "9a61194bd23394b851ef6d879991ebc284cd74d8", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^8.0", - "socialiteproviders/manager": "^4.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "SocialiteProviders\\Orcid\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ben Cornwell", - "email": "ben@bencornwell.com" - } - ], - "description": "ORCID OAuth2 Provider for Laravel Socialite", - "keywords": [ - "laravel", - "oauth", - "orcid", - "provider", - "socialite" - ], - "support": { - "docs": "https://socialiteproviders.com/orcid", - "issues": "https://github.com/socialiteproviders/providers/issues", - "source": "https://github.com/socialiteproviders/providers" - }, - "time": "2024-02-01T18:57:00+00:00" - }, { "name": "spatie/db-dumper", "version": "3.8.1", @@ -6995,6 +6993,91 @@ ], "time": "2025-02-21T13:11:14+00:00" }, + { + "name": "spatie/laravel-csp", + "version": "3.21.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-csp.git", + "reference": "1c5a878dddc66283d80ff3bbe810c9dcda4ee919" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-csp/zipball/1c5a878dddc66283d80ff3bbe810c9dcda4ee919", + "reference": "1c5a878dddc66283d80ff3bbe810c9dcda4ee919", + "shasum": "" + }, + "require": { + "illuminate/http": "^11.36.1|^12.0", + "illuminate/support": "^11.36.1|^12.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.17" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.9|^10.0", + "pestphp/pest": "^3.0", + "roave/security-advisories": "dev-master" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Csp\\CspServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\Csp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Thomas Verhelst", + "email": "tvke91@gmail.com", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Add CSP headers to the responses of a Laravel app", + "homepage": "https://github.com/spatie/laravel-csp", + "keywords": [ + "content-security-policy", + "csp", + "headers", + "laravel", + "laravel-csp", + "security", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-csp/tree/3.21.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2025-10-16T07:20:44+00:00" + }, { "name": "spatie/laravel-honeypot", "version": "4.6.2", @@ -7868,22 +7951,21 @@ }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/clock": "^1.0", - "symfony/polyfill-php83": "^1.28" + "php": ">=8.4", + "psr/clock": "^1.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -7922,7 +8004,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v8.0.0" }, "funding": [ { @@ -7942,7 +8024,7 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2025-11-12T15:46:48+00:00" }, { "name": "symfony/console", @@ -8262,24 +8344,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" + "reference": "573f95783a2ec6e38752979db139f09fec033f03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", - "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/573f95783a2ec6e38752979db139f09fec033f03", + "reference": "573f95783a2ec6e38752979db139f09fec033f03", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -8288,14 +8370,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -8323,7 +8405,7 @@ "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.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.0" }, "funding": [ { @@ -8343,7 +8425,7 @@ "type": "tidelift" } ], - "time": "2025-10-28T09:38:46+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -8421,6 +8503,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.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/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-11-27T13:27:24+00:00" + }, { "name": "symfony/finder", "version": "v7.4.0", @@ -10099,35 +10251,34 @@ }, { "name": "symfony/string", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" + "reference": "f929eccf09531078c243df72398560e32fa4cf4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", - "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", + "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", + "reference": "f929eccf09531078c243df72398560e32fa4cf4f", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -10166,7 +10317,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.0" + "source": "https://github.com/symfony/string/tree/v8.0.0" }, "funding": [ { @@ -10186,38 +10337,31 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-09-11T14:37:55+00:00" }, { "name": "symfony/translation", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" + "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", - "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "url": "https://api.github.com/repos/symfony/translation/zipball/82ab368a6fca6358d995b6dd5c41590fb42c03e6", + "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5.3|^3.3" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" }, "conflict": { "nikic/php-parser": "<5.0", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<6.4", - "symfony/yaml": "<6.4" + "symfony/service-contracts": "<2.5" }, "provide": { "symfony/translation-implementation": "2.3|3.0" @@ -10225,17 +10369,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/routing": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -10266,7 +10410,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.0" + "source": "https://github.com/symfony/translation/tree/v8.0.0" }, "funding": [ { @@ -10286,7 +10430,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-11-27T08:09:45+00:00" }, { "name": "symfony/translation-contracts", @@ -10946,36 +11090,43 @@ }, { "name": "zircote/swagger-php", - "version": "4.11.1", + "version": "5.7.6", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "7df10e8ec47db07c031db317a25bef962b4e5de1" + "reference": "e4727bad28cf426b026421162af384f893c0142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/7df10e8ec47db07c031db317a25bef962b4e5de1", - "reference": "7df10e8ec47db07c031db317a25bef962b4e5de1", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/e4727bad28cf426b026421162af384f893c0142c", + "reference": "e4727bad28cf426b026421162af384f893c0142c", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=7.2", + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=7.4", + "phpstan/phpdoc-parser": "^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/deprecation-contracts": "^2 || ^3", - "symfony/finder": ">=2.2", - "symfony/yaml": ">=3.3" + "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" }, "require-dev": { "composer/package-versions-deprecated": "^1.11", - "doctrine/annotations": "^1.7 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.17 || 3.62.0", - "phpstan/phpstan": "^1.6", - "phpunit/phpunit": ">=8", - "vimeo/psalm": "^4.23" + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^1.6 || ^2.0", + "phpunit/phpunit": "^9.0", + "rector/rector": "^1.0 || ^2.0", + "vimeo/psalm": "^4.30 || ^5.0" }, "suggest": { - "doctrine/annotations": "^1.7 || ^2.0" + "doctrine/annotations": "^2.0", + "radebatz/type-info-extras": "^1.0.2" }, "bin": [ "bin/openapi" @@ -10983,7 +11134,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -11011,8 +11162,8 @@ "homepage": "https://radebatz.net" } ], - "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", - "homepage": "https://github.com/zircote/swagger-php/", + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", "keywords": [ "api", "json", @@ -11021,9 +11172,9 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/4.11.1" + "source": "https://github.com/zircote/swagger-php/tree/5.7.6" }, - "time": "2024-10-15T19:20:02+00:00" + "time": "2025-12-04T01:33:01+00:00" } ], "packages-dev": [ @@ -11774,16 +11925,16 @@ }, { "name": "laravel/sail", - "version": "v1.49.0", + "version": "v1.50.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "070c7f34ca8dbece4350fbfe0bab580047dfacc7" + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/070c7f34ca8dbece4350fbfe0bab580047dfacc7", - "reference": "070c7f34ca8dbece4350fbfe0bab580047dfacc7", + "url": "https://api.github.com/repos/laravel/sail/zipball/9177d5de1c8247166b92ea6049c2b069d2a1802f", + "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f", "shasum": "" }, "require": { @@ -11833,7 +11984,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-11-25T21:15:57+00:00" + "time": "2025-12-03T17:16:36+00:00" }, { "name": "mockery/mockery", @@ -13171,11 +13322,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.4" }, "platform-dev": {}, - "platform-overrides": { - "php": "8.3.28" - }, "plugin-api-version": "2.6.0" } diff --git a/config/csp.php b/config/csp.php new file mode 100644 index 00000000..69592830 --- /dev/null +++ b/config/csp.php @@ -0,0 +1,86 @@ + [ + // Enforcement mode - now using secure CSP policy + App\Support\Csp\Policies\NmrxivPolicy::class, + ], + + /** + * Register additional global CSP directives here. + * These can be configured via environment variables for runtime flexibility. + */ + 'directives' => [ + // Additional connect-src domains (configurable via env) + ...(env('CSP_ADDITIONAL_CONNECT_SRC') ? [ + [Spatie\Csp\Directive::CONNECT, array_filter(explode(',', env('CSP_ADDITIONAL_CONNECT_SRC')))], + ] : []), + + // Additional img-src domains (configurable via env) + ...(env('CSP_ADDITIONAL_IMG_SRC') ? [ + [Spatie\Csp\Directive::IMG, array_filter(explode(',', env('CSP_ADDITIONAL_IMG_SRC')))], + ] : []), + + // Additional script-src domains (configurable via env) + ...(env('CSP_ADDITIONAL_SCRIPT_SRC') ? [ + [Spatie\Csp\Directive::SCRIPT, array_filter(explode(',', env('CSP_ADDITIONAL_SCRIPT_SRC')))], + ] : []), + + // Additional style-src domains (configurable via env) + ...(env('CSP_ADDITIONAL_STYLE_SRC') ? [ + [Spatie\Csp\Directive::STYLE, array_filter(explode(',', env('CSP_ADDITIONAL_STYLE_SRC')))], + ] : []), + ], + + /* + * These presets which will be put in a report-only policy. This is great for testing out + * a new policy or changes to existing CSP policy without breaking anything. + */ + 'report_only_presets' => [ + // Moved to enforcement mode above + ], + + /** + * Register additional global report-only CSP directives here. + */ + 'report_only_directives' => [ + // [Directive::SCRIPT, [Keyword::UNSAFE_EVAL, Keyword::UNSAFE_INLINE]], + ], + + /* + * All violations against a policy will be reported to this url. + * Set to null to disable violation reporting. + */ + 'report_uri' => null, + + /* + * Headers will only be added if this setting is set to true. + */ + 'enabled' => env('CSP_ENABLED', true), + + /** + * Headers will be added when Vite is hot reloading. + */ + 'enabled_while_hot_reloading' => env('CSP_ENABLED_WHILE_HOT_RELOADING', true), + + /* + * The class responsible for generating the nonces used in inline tags and headers. + */ + 'nonce_generator' => Spatie\Csp\Nonce\RandomString::class, + + /* + * Set false to disable automatic nonce generation and handling. + * This is useful when you want to use 'unsafe-inline' for scripts/styles + * and cannot add inline nonces. + * Note that this will make your CSP policy less secure. + */ + 'nonce_enabled' => env('CSP_NONCE_ENABLED', true), +]; diff --git a/config/filesystems.php b/config/filesystems.php index 1e7f28e4..9ad0e21e 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,6 +35,12 @@ 'serve' => true, 'throw' => false, 'report' => false, + // AWS S3 config keys for testing (when StorageSignedUrlService is used) + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'endpoint' => env('AWS_URL'), ], 'ceph' => [ diff --git a/config/services.php b/config/services.php index 88e0bd2e..89a69289 100644 --- a/config/services.php +++ b/config/services.php @@ -48,12 +48,14 @@ // Allow overriding the PUG REST path if needed 'pug_rest_path' => env('PUBCHEM_PUG_PATH', '/rest/pug'), ], - 'common_chemistry' => [ - 'base_url' => env('COMMON_CHEMISTRY_URL', 'https://commonchemistry.cas.org'), - 'api_path' => env('COMMON_CHEMISTRY_API_PATH', '/api'), - ], + 'chemistry_standardize' => [ 'url' => env('CHEMISTRY_STANDARDIZE_URL', 'https://api.cheminf.studio/latest/chem/standardize'), ], + 'cas' => [ + 'provider' => env('CAS_PROVIDER', 'CAS_CommonChemistry'), + 'api_token' => env('CAS_API_TOKEN'), + 'base_url' => env('COMMON_CHEMISTRY_URL', 'https://commonchemistry.cas.org/api'), + ], ]; diff --git a/database/factories/FileSystemObjectFactory.php b/database/factories/FileSystemObjectFactory.php new file mode 100644 index 00000000..22b2637a --- /dev/null +++ b/database/factories/FileSystemObjectFactory.php @@ -0,0 +1,141 @@ + + */ +class FileSystemObjectFactory extends Factory +{ + /** + * Define the model's default state. + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word().'.'.$this->faker->fileExtension(), + 'uuid' => Str::uuid(), + 'slug' => $this->faker->slug(), + 'description' => $this->faker->sentence(), + 'relative_url' => '/'.$this->faker->slug().'/'.$this->faker->word(), + 'path' => $this->faker->filePath(), + 'type' => $this->faker->randomElement(['file', 'directory']), + 'key' => Str::uuid(), + 'is_public' => false, + 'is_deleted' => false, + 'is_archived' => false, + 'is_original' => true, + 'is_verified' => false, + 'is_processed' => false, + 'is_root' => false, + 'sort_order' => $this->faker->numberBetween(1, 100), + 'level' => $this->faker->numberBetween(0, 3), + 'has_children' => false, + 'file_size' => $this->faker->numberBetween(1024, 1048576), // 1KB to 1MB + 'integrity_status' => $this->faker->randomElement(['pending', 'verified', 'failed']), + 'status' => $this->faker->randomElement(['present', 'missing']), + 'checksum_md5' => md5($this->faker->text()), + 'checksum_sha256' => hash('sha256', $this->faker->text()), + 'checksum_algorithm' => 'sha256', + ]; + } + + /** + * Configure the factory for a file type. + */ + public function file(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'file', + 'has_children' => false, + ]); + } + + /** + * Configure the factory for a directory type. + */ + public function directory(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'directory', + 'has_children' => true, + ]); + } + + /** + * Configure the factory for a missing status. + */ + public function missing(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'missing', + ]); + } + + /** + * Configure the factory for a complete status. + */ + public function complete(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'complete', + ]); + } + + /** + * Configure the factory for root level files. + */ + public function rootLevel(): static + { + return $this->state(fn (array $attributes) => [ + 'level' => 0, + 'parent_id' => null, + ]); + } + + /** + * Configure the factory for child files. + */ + public function childLevel(int $level = 1): static + { + return $this->state(fn (array $attributes) => [ + 'level' => $level, + ]); + } + + /** + * Configure the factory to belong to a draft. + */ + public function forDraft(Draft $draft): static + { + return $this->state(fn (array $attributes) => [ + 'draft_id' => $draft->id, + ]); + } + + /** + * Configure the factory to belong to a project. + */ + public function forProject(Project $project): static + { + return $this->state(fn (array $attributes) => [ + 'project_id' => $project->id, + ]); + } + + /** + * Configure the factory to belong to a study. + */ + public function forStudy(Study $study): static + { + return $this->state(fn (array $attributes) => [ + 'study_id' => $study->id, + ]); + } +} diff --git a/database/factories/MoleculeFactory.php b/database/factories/MoleculeFactory.php index b988ef89..e6bcc563 100644 --- a/database/factories/MoleculeFactory.php +++ b/database/factories/MoleculeFactory.php @@ -12,74 +12,55 @@ class MoleculeFactory extends Factory */ public function definition(): array { - $cid = rand(1000, 9999); - echo $cid; - $pubchemRecordLink = 'https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/'.$cid.'/record/JSON'; - $json = file_get_contents($pubchemRecordLink); - $data = json_decode($json, true)['PC_Compounds'][0]['props']; + // Generate fake molecule data instead of making API calls + // This prevents external API dependencies and rate limiting issues - $output = []; - $labels = [ - 'InChI' => 'standard_inchi', - 'InChIKey' => 'inchi_key', - 'Molecular Formula' => 'molecular_formula', - ]; - - foreach ($data as $key => $value) { - $pubchemLabel = $data[$key]['urn']['label']; - - foreach ($labels as $label => $column) { - if ($pubchemLabel == $label) { - $val = $data[$key]['value']['sval']; - $output[$column] = $val; - } - } + // Generate unique molecular formulas using random carbon/hydrogen/oxygen counts + $c = $this->faker->numberBetween(6, 25); + $h = $this->faker->numberBetween(8, 50); + $o = $this->faker->numberBetween(0, 10); + $molecularFormula = "C{$c}H{$h}"; + if ($o > 0) { + $molecularFormula .= "O{$o}"; } - return - [ + // Generate unique InChI and InChI key using random components + $uniqueId = $this->faker->uuid(); + $hashPart = substr(md5($uniqueId), 0, 10); + $standardInchi = "InChI=1S/{$molecularFormula}/c{$hashPart}/h{$this->faker->randomNumber(5)}"; + $inchiKey = strtoupper(substr(md5($standardInchi), 0, 14)).'-'.strtoupper(substr(md5($uniqueId), 0, 10)).'-N'; + + return [ 'cas' => null, - 'molecular_formula' => $output['molecular_formula'], - 'molecular_weight' => null, + 'molecular_formula' => $molecularFormula, + 'molecular_weight' => $this->faker->randomFloat(2, 100, 500), 'smiles' => null, 'absolute_smiles' => null, 'canonical_smiles' => null, 'inchi' => null, - 'standard_inchi' => $output['standard_inchi'], - 'inchi_key' => $output['inchi_key'], + 'standard_inchi' => $standardInchi, + 'inchi_key' => $inchiKey, 'standard_inchi_key' => null, - 'fp0' => null, - 'fp1' => null, - 'fp2' => null, - 'fp3' => null, - 'fp4' => null, - 'fp5' => null, - 'fp6' => null, - 'fp7' => null, - 'fp8' => null, - 'fp9' => null, - 'fp10' => null, - 'fp11' => null, - 'fp12' => null, - 'fp13' => null, - 'fp14' => null, - 'fp15' => null, - 'DBE' => null, - 'SSSR' => null, - 'SAR' => null, - 'COMMENT' => null, 'sdf' => null, - 'MULTIPLICITY_0' => null, - 'MULTIPLICITY_1' => null, - 'MULTIPLICITY_2' => null, - 'MULTIPLICITY_3' => null, - 'VIEWS' => null, 'DOI' => null, 'created_at' => Carbon::now()->timestamp, 'updated_at' => Carbon::now()->timestamp, 'doi' => null, 'datacite_schema' => null, 'identifier' => null, + 'name' => $this->faker->words(2, true), + 'name_trust_level' => 0, + 'annotation_level' => 0, + 'synonyms' => null, + 'iupac_name' => null, + '2d' => null, + '3d' => null, + 'structural_comments' => null, + 'status' => 'APPROVED', + 'active' => true, + 'has_stereo' => false, + 'has_variants' => false, + 'variants_count' => 0, ]; } } diff --git a/database/factories/NMRiumFactory.php b/database/factories/NMRiumFactory.php index 714f3637..1f2fd53b 100644 --- a/database/factories/NMRiumFactory.php +++ b/database/factories/NMRiumFactory.php @@ -16,7 +16,30 @@ public function definition(): array { return [ 'nmrium_info' => '{}', - 'dataset_id' => 1, + 'nmriumable_id' => 1, + 'nmriumable_type' => \App\Models\Dataset::class, ]; } + + /** + * Make this NMRium belong to a Dataset + */ + public function forDataset($dataset = null): static + { + return $this->state(fn (array $attributes) => [ + 'nmriumable_id' => $dataset?->id ?? \App\Models\Dataset::factory(), + 'nmriumable_type' => \App\Models\Dataset::class, + ]); + } + + /** + * Make this NMRium belong to a Study + */ + public function forStudy($study = null): static + { + return $this->state(fn (array $attributes) => [ + 'nmriumable_id' => $study?->id ?? \App\Models\Study::factory(), + 'nmriumable_type' => \App\Models\Study::class, + ]); + } } diff --git a/database/factories/ProjectInvitationFactory.php b/database/factories/ProjectInvitationFactory.php new file mode 100644 index 00000000..fac1d05d --- /dev/null +++ b/database/factories/ProjectInvitationFactory.php @@ -0,0 +1,59 @@ + + */ +class ProjectInvitationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'email' => fake()->unique()->safeEmail(), + 'role' => fake()->randomElement(['viewer', 'collaborator']), + 'message' => fake()->optional()->sentence(), + 'invited_by' => User::factory(), + ]; + } + + /** + * Indicate that the invitation is for a collaborator role. + */ + public function collaborator(): static + { + return $this->state(fn (array $attributes) => [ + 'role' => 'collaborator', + ]); + } + + /** + * Indicate that the invitation is for a viewer role. + */ + public function viewer(): static + { + return $this->state(fn (array $attributes) => [ + 'role' => 'viewer', + ]); + } + + /** + * Indicate that the invitation has expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'created_at' => now()->subDays(8), // Assuming 7-day expiry + ]); + } +} diff --git a/database/factories/StudyFactory.php b/database/factories/StudyFactory.php index d6beca72..ee20b8d5 100644 --- a/database/factories/StudyFactory.php +++ b/database/factories/StudyFactory.php @@ -2,7 +2,6 @@ namespace Database\Factories; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -42,8 +41,6 @@ public function definition(): array 'fs_id' => 1, 'release_date' => null, 'study_photo_path' => null, // todo: Adjust when studies images field is provided in nmrXiv - 'created_at' => Carbon::now()->timestamp, - 'updated_at' => Carbon::now()->timestamp, 'doi' => null, 'datacite_schema' => null, 'identifier' => null, diff --git a/database/factories/StudyInvitationFactory.php b/database/factories/StudyInvitationFactory.php new file mode 100644 index 00000000..2a7be6e7 --- /dev/null +++ b/database/factories/StudyInvitationFactory.php @@ -0,0 +1,77 @@ + Study::factory(), + 'email' => $this->faker->unique()->safeEmail(), + 'role' => $this->faker->randomElement(['viewer', 'collaborator', 'reviewer']), + 'message' => $this->faker->optional()->sentence(), + 'invited_by' => $this->faker->optional()->safeEmail(), + ]; + } + + /** + * Factory state for viewer role + */ + public function viewer(): self + { + return $this->state(fn (array $attributes) => [ + 'role' => 'viewer', + ]); + } + + /** + * Factory state for collaborator role + */ + public function collaborator(): self + { + return $this->state(fn (array $attributes) => [ + 'role' => 'collaborator', + ]); + } + + /** + * Factory state for reviewer role + */ + public function reviewer(): self + { + return $this->state(fn (array $attributes) => [ + 'role' => 'reviewer', + ]); + } + + /** + * Factory state with a specific inviter + */ + public function invitedBy(User $user): self + { + return $this->state(fn (array $attributes) => [ + 'invited_by' => $user->email, + ]); + } + + /** + * Factory state for a specific study + */ + public function forStudy(Study $study): self + { + return $this->state(fn (array $attributes) => [ + 'study_id' => $study->id, + ]); + } +} diff --git a/database/factories/TickerFactory.php b/database/factories/TickerFactory.php new file mode 100644 index 00000000..3676b451 --- /dev/null +++ b/database/factories/TickerFactory.php @@ -0,0 +1,81 @@ + + */ +class TickerFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = Ticker::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'type' => $this->faker->randomElement(['sample', 'molecule', 'dataset']), + 'index' => $this->faker->numberBetween(1, 1000), + 'meta' => null, + ]; + } + + /** + * Create a ticker for samples + */ + public function sample(): Factory + { + return $this->state(function (array $attributes) { + return [ + 'type' => 'sample', + ]; + }); + } + + /** + * Create a ticker for molecules + */ + public function molecule(): Factory + { + return $this->state(function (array $attributes) { + return [ + 'type' => 'molecule', + ]; + }); + } + + /** + * Create a ticker for datasets + */ + public function dataset(): Factory + { + return $this->state(function (array $attributes) { + return [ + 'type' => 'dataset', + ]; + }); + } + + /** + * Set a specific index value + */ + public function withIndex(int $index): Factory + { + return $this->state(function (array $attributes) use ($index) { + return [ + 'index' => $index, + ]; + }); + } +} diff --git a/database/factories/ValidationFactory.php b/database/factories/ValidationFactory.php new file mode 100644 index 00000000..aeefb801 --- /dev/null +++ b/database/factories/ValidationFactory.php @@ -0,0 +1,93 @@ + + */ +class ValidationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'report' => [ + 'project' => [ + 'status' => fake()->boolean(), + 'title' => fake()->boolean(), + 'description' => fake()->boolean(), + 'authors' => fake()->boolean(), + 'affiliation' => fake()->boolean(), + 'license' => fake()->boolean(), + 'keywords' => fake()->boolean(), + 'studies' => [ + ['status' => fake()->boolean()], + ['status' => fake()->boolean()], + ], + ], + 'missing' => [], + 'errors' => [], + 'version' => 1, + ], + ]; + } + + /** + * Indicate that the validation has passed. + */ + public function passed(): static + { + return $this->state(fn (array $attributes) => [ + 'report' => [ + 'project' => [ + 'status' => true, + 'title' => true, + 'description' => true, + 'authors' => true, + 'affiliation' => true, + 'license' => true, + 'keywords' => true, + 'studies' => [ + ['status' => true], + ], + ], + 'missing' => [], + 'errors' => [], + 'version' => 1, + ], + ]); + } + + /** + * Indicate that the validation has failed. + */ + public function failed(): static + { + return $this->state(fn (array $attributes) => [ + 'report' => [ + 'project' => [ + 'status' => false, + 'title' => false, + 'description' => false, + 'authors' => false, + 'affiliation' => false, + 'license' => false, + 'keywords' => false, + 'studies' => [ + ['status' => false], + ['status' => false], + ], + ], + 'missing' => ['title', 'description', 'authors'], + 'errors' => ['Project validation failed'], + 'version' => 1, + ], + ]); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 286dc455..55538546 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ services: laravel.test: build: - context: ./docker/8.3 + context: ./docker/8.4 dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP}' - image: sail-8.3/app + image: sail-8.4/app extra_hosts: - 'host.docker.internal:host-gateway' ports: diff --git a/docker/8.4/Dockerfile b/docker/8.4/Dockerfile new file mode 100644 index 00000000..f647912f --- /dev/null +++ b/docker/8.4/Dockerfile @@ -0,0 +1,64 @@ +FROM ubuntu:22.04 + +LABEL maintainer="Taylor Otwell" + +ARG WWWGROUP +ARG NODE_VERSION=20 +ARG POSTGRES_VERSION=15 + +WORKDIR /var/www/html + +ENV DEBIAN_FRONTEND noninteractive +ENV TZ=UTC +ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update \ + && mkdir -p /etc/apt/keyrings \ + && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python2 dnsutils librsvg2-bin fswatch \ + && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x14aa40ec0831756756d7f66c4f4ea0aae5267a6c' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu jammy main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ + && apt-get update \ + && apt-get install -y php8.4-cli php8.4-dev \ + php8.4-pgsql php8.4-sqlite3 php8.4-gd \ + php8.4-curl \ + php8.4-imap php8.4-mysql php8.4-mbstring \ + php8.4-xml php8.4-zip php8.4-bcmath php8.4-soap \ + php8.4-intl php8.4-readline \ + php8.4-ldap \ + php8.4-msgpack php8.4-igbinary php8.4-redis php8.4-swoole \ + php8.4-memcached php8.4-pcov php8.4-imagick php8.4-xdebug \ + && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && npm install -g npm \ + && npm install -g pnpm \ + && npm install -g bun \ + && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ + && curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ + && apt-get update \ + && apt-get install -y yarn \ + && apt-get install -y mysql-client \ + && apt-get install -y postgresql-client-$POSTGRES_VERSION \ + && apt-get -y autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.4 + +RUN groupadd --force -g $WWWGROUP sail +RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail + +COPY start-container /usr/local/bin/start-container +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY php.ini /etc/php/8.4/cli/conf.d/99-sail.ini +RUN chmod +x /usr/local/bin/start-container + +EXPOSE 8000 + +ENTRYPOINT ["start-container"] diff --git a/docker/8.4/php.ini b/docker/8.4/php.ini new file mode 100644 index 00000000..26dbdf6e --- /dev/null +++ b/docker/8.4/php.ini @@ -0,0 +1,10 @@ +[PHP] +post_max_size = 2G +upload_max_filesize = 2G +memory_limit = 2G +max_execution_time = 259200 +max_input_time = 259200 +variables_order = EGPCS + +[opcache] +opcache.enable_cli=1 diff --git a/docker/8.4/start-container b/docker/8.4/start-container new file mode 100644 index 00000000..b8643990 --- /dev/null +++ b/docker/8.4/start-container @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +if [ ! -z "$WWWUSER" ]; then + usermod -u $WWWUSER sail +fi + +if [ ! -d /.composer ]; then + mkdir /.composer +fi + +chmod -R ugo+rw /.composer + +if [ $# -gt 0 ]; then + exec gosu $WWWUSER "$@" +else + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi diff --git a/docker/8.4/supervisord.conf b/docker/8.4/supervisord.conf new file mode 100644 index 00000000..26ccc0a5 --- /dev/null +++ b/docker/8.4/supervisord.conf @@ -0,0 +1,14 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php] +command=%(ENV_SUPERVISOR_PHP_COMMAND)s +user=sail +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docs/developer-guides/csp-summary.md b/docs/developer-guides/csp-summary.md new file mode 100644 index 00000000..aecc93a4 --- /dev/null +++ b/docs/developer-guides/csp-summary.md @@ -0,0 +1,113 @@ +# nmrXiv Content Security Policy (CSP) summary + +This guide summarizes the CSP implementation in nmrXiv, the key changes we made, and how to work with it in development and production. + +## What changed + +- Introduced a custom CSP preset: `App/Support/Csp/Policies/NmrxivPolicy.php` (Spatie preset). +- Configured CSP in `config/csp.php` with support for both enforcement and report-only modes. +- Added a violation reporting endpoint (POST) and a viewer (GET) at `/csp-violation-report`. +- Updated templates to cooperate with CSP (nonces in production, permissive inline in development). +- Fixed third-party allowances (Matomo, Bunny Fonts) and dev tooling (Vite/HMR, IPv6 `localhost`). + +## Files involved + +- `app/Support/Csp/Policies/NmrxivPolicy.php` + - Central, environment-aware policy. + - Production: nonces are used; no `unsafe-inline`/`unsafe-eval`. + - Development/local: nonces are disabled; `unsafe-inline`/`unsafe-eval` permitted for DX and Vite HMR. + - Explicitly allows required third-party and dev sources (see below). + +- `config/csp.php` + - Switch between enforcement and report-only by moving the policy class between `presets` and `report_only_presets`. + - Controls `enabled`, `report_uri`, nonce generator, and whether to enable during hot reloading. + +- `routes/web.php` + - `POST /csp-violation-report` → receives reports (throttled). + - `GET /csp-violation-report` → view latest parsed violations. + +- `app/Http/Controllers/CspViolationController.php` + - Logs violation payloads and returns recent entries for inspection. + +- `resources/views/app.blade.php` + - Uses `@cspNonce` where needed in production (e.g., analytics). + - In development, policy disables nonces to avoid the browser ignoring `unsafe-inline`. + +## Effective directives (overview) + +Always applied (with environment-specific additions): +- `base-uri 'self'` +- `default-src 'self'` +- `object-src 'none'` +- `frame-src 'self'` +- `form-action 'self'` +- `img-src 'self' data: blob:` (+ localhost allowances in dev) +- `media-src 'self' blob:` +- `font-src data: https://fonts.bunny.net` +- `style-src 'self' data: https://fonts.bunny.net` (behavior differs by env) +- `script-src 'self' https://matomo.nfdi4chem.de` (behavior differs by env) +- `connect-src 'self' https://matomo.nfdi4chem.de https://fonts.bunny.net` (+ dev sockets/hosts) +- `report-uri /csp-violation-report` + +### Production (hardened) +- Nonces enabled (generated by `Spatie\Csp\Nonce\RandomString`). + - `script-src 'self' 'nonce-…' …` + - `style-src 'self' 'nonce-…' …` +- No `unsafe-inline` and no `unsafe-eval`. +- Third‑party: `https://matomo.nfdi4chem.de`, `https://fonts.bunny.net`. + +### Development/local (DX-friendly) +- Nonces disabled to allow inline execution where needed by tooling. +- Allows: + - `script-src … 'unsafe-inline' 'unsafe-eval'` + - `style-src … 'unsafe-inline'` +- Vite/HMR and dev connections (IPv4, named, and IPv6): + - Scripts: `http://localhost:5173`, `http://127.0.0.1:5173`, `http://[::1]:5173` (also 3000, 8000 variants) + - WebSockets: `ws://localhost:5173`, `ws://127.0.0.1:5173`, `ws://[::1]:5173` (also 3000 variants) + - HTTP connects for the same hosts/ports. +- Images additionally allow `http(s)://localhost:*`. + +## Third‑party notes +- Matomo: use explicit `https://matomo.nfdi4chem.de` (protocol‑relative sources like `//…` are invalid in CSP). +- Fonts: `https://fonts.bunny.net` allowed for `font-src` and `style-src`. + +## Violation reporting +- POST reports to: `/csp-violation-report` (throttled; can be relaxed locally if noisy). +- GET: `/csp-violation-report` returns a recent, parsed list for quick inspection. + +## Switching modes +- Enforcement: put `App\Support\Csp\Policies\NmrxivPolicy::class` into `presets` and remove it from `report_only_presets` in `config/csp.php`. +- Report‑only: place it in `report_only_presets` and clear from `presets`. +- Apply changes: + +```bash +php artisan config:clear +``` + +## Verifying the header + +```bash +curl -I http://localhost/ | grep -i "content-security-policy" +``` +- Development: expect `unsafe-inline`/`unsafe-eval` and Vite endpoints (including IPv6 `[::1]`). +- Production: expect `nonce-…` values; no `unsafe-inline`/`unsafe-eval`. + +## Troubleshooting +- “unsafe-inline is ignored when a hash or nonce is present” + - Expected: browsers ignore `unsafe-inline` when nonces/hashes are present. We avoid this in dev by disabling nonces there. +- “Refused to load script http://[::1]:5173/…” + - Ensure IPv6 dev hosts are included in both `script-src` and `connect-src` (policy does this). +- Many 429s to `/csp-violation-report` + - Temporarily relax throttle for development or disable reporting while testing. +- `browser-sync-client.js net::ERR_CONNECTION_REFUSED` + - Not CSP. Start BrowserSync or remove the script include locally. + +## Edit locations +- Policy: `app/Support/Csp/Policies/NmrxivPolicy.php` +- Config: `config/csp.php` +- Routes: `routes/web.php` +- Violation controller: `app/Http/Controllers/CspViolationController.php` +- App shell: `resources/views/app.blade.php` + +— +If you must allow new third‑party origins, add the narrowest possible directive for the exact origin and protocol. Avoid `unsafe-*` in production; prefer nonces and explicit source lists. diff --git a/package-lock.json b/package-lock.json index 5632976a..24a50228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "nmrxiv", "dependencies": { "@headlessui/vue": "^1.6.2", "@heroicons/vue": "^2.0.11", @@ -27,6 +26,7 @@ "openchemlib": "^7.4.3", "pluralize": "^8.0.0", "popper.js": "^1.16.1", + "qs": "^6.14.1", "vue-instantsearch": "^4.3.3", "vue-simple-context-menu": "^4.0.4", "vue3-clipboard": "^1.0.0", @@ -4308,18 +4308,6 @@ "algoliasearch": ">= 3.1 < 6" } }, - "node_modules/instantsearch.js/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5550,9 +5538,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/package.json b/package.json index 448442ef..c5c20aff 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "openchemlib": "^7.4.3", "pluralize": "^8.0.0", "popper.js": "^1.16.1", + "qs": "^6.14.1", "vue-instantsearch": "^4.3.3", "vue-simple-context-menu": "^4.0.4", "vue3-clipboard": "^1.0.0", @@ -62,5 +63,8 @@ "vue3-slider": "^1.7.0", "vue3-tour": "^0.2.0", "vuedraggable": "^4.1.0" + }, + "overrides": { + "qs": "^6.14.1" } } diff --git a/phpunit.xml b/phpunit.xml index 5410ece8..3510064f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,19 +8,39 @@ ./tests/Feature - + + + + + + + - + + + + + + + + + + + ./app + + ./app/Console + ./app/Helper.php + diff --git a/public/build/manifest.json b/public/build/manifest.json index 96555c75..94263510 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -493,6 +493,10 @@ "assets/main-CITaNMI7.css" ] }, + "_main-CITaNMI7.css": { + "file": "assets/main-CITaNMI7.css", + "src": "_main-CITaNMI7.css" + }, "_micro-task-CxIZtCgj.js": { "file": "assets/micro-task-CxIZtCgj.js", "name": "micro-task" @@ -570,6 +574,11 @@ "resources/js/app.js" ] }, + "resources/css/app.css": { + "file": "assets/app-BBYwYq4Q.css", + "src": "resources/css/app.css", + "isEntry": true + }, "resources/js/Pages/API/Index.vue": { "file": "assets/Index-wDDwTVt7.js", "name": "Index", @@ -1706,6 +1715,9 @@ "imports": [ "_SpectraViewer-DAiohBoS.js", "resources/js/app.js" + ], + "css": [ + "assets/app-BBYwYq4Q.css" ] }, "resources/js/Pages/Public/Embed/Sample.vue": { @@ -1716,6 +1728,9 @@ "imports": [ "_SpectraViewer-DAiohBoS.js", "resources/js/app.js" + ], + "css": [ + "assets/app-BBYwYq4Q.css" ] }, "resources/js/Pages/Public/Project/Dataset.vue": { diff --git a/public/img/FSU-Jena-logo.jpg b/public/img/FSU-Jena-logo.jpg new file mode 100644 index 00000000..7f825a57 Binary files /dev/null and b/public/img/FSU-Jena-logo.jpg differ diff --git a/public/img/nmrxiv-logo.png b/public/img/nfdi4chem-logo.png similarity index 100% rename from public/img/nmrxiv-logo.png rename to public/img/nfdi4chem-logo.png diff --git a/public/img/old-logo.svg b/public/img/old-logo.svg deleted file mode 100644 index 8350812b..00000000 --- a/public/img/old-logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/img/ph.jpg b/public/img/ph.jpg deleted file mode 100644 index a987c261..00000000 Binary files a/public/img/ph.jpg and /dev/null differ diff --git a/resources/js/Components/ProjectStatusBadge.vue b/resources/js/Components/ProjectStatusBadge.vue new file mode 100644 index 00000000..04dca965 --- /dev/null +++ b/resources/js/Components/ProjectStatusBadge.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/resources/js/Mixins/Global.js b/resources/js/Mixins/Global.js index 893e3ba0..d6ebe8ba 100644 --- a/resources/js/Mixins/Global.js +++ b/resources/js/Mixins/Global.js @@ -420,6 +420,16 @@ export default { const minutes = String(date.getMinutes()).padStart(2, "0"); return `${day}/${month}/${year}, ${hours}:${minutes}`; }, + + /** + * Removes all HTML tags from a string + * @param {string} str - The string containing HTML tags + * @returns {string} - The string with HTML tags removed + */ + stripHtmlTags(str) { + if (!str) return ""; + return str.replace(/<[^>]*>/g, ""); + }, }, computed: { diff --git a/resources/js/Pages/About.vue b/resources/js/Pages/About.vue index 4b03522b..cc4ab819 100644 --- a/resources/js/Pages/About.vue +++ b/resources/js/Pages/About.vue @@ -789,7 +789,7 @@ FSU NFDI4Chem + + diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index eb0685c9..4acb1be0 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -99,20 +99,255 @@
- +
+
+
+ + Showing + {{ + (currentProjectsPage - 1) * + projectsPerPage + + 1 + }} + to + {{ + Math.min( + currentProjectsPage * projectsPerPage, + filteredProjects.length + ) + }} + of {{ filteredProjects.length }} + {{ + selectedProjectStatus !== "all" + ? selectedProjectStatus + " " + : "" + }}projects + +
+
+ + + + +
+ +
+
+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ Page {{ currentProjectsPage }} of + {{ totalProjectPages }} +
+
+ + +
+
- +
+
+
+ + Showing + {{ + (currentSamplesPage - 1) * samplesPerPage + + 1 + }} + to + {{ + Math.min( + currentSamplesPage * samplesPerPage, + filteredSamples.length + ) + }} + of {{ filteredSamples.length }} + {{ + selectedSampleStatus !== "all" + ? selectedSampleStatus + " " + : "" + }}samples + +
+
+ + + + +
+ +
+
+
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ Page {{ currentSamplesPage }} of {{ totalSamplePages }} +
+
+ + +
+
@@ -362,6 +597,9 @@ import TeamProjects from "@/Pages/Project/Index.vue"; import TeamSamples from "@/Shared/Samples.vue"; import Create from "@/Shared/CreateButton.vue"; import Onboarding from "@/App/Onboarding.vue"; +import SearchInput from "@/Shared/SearchInput.vue"; +import EmptySearchState from "@/Shared/EmptySearchState.vue"; +import StatusFilter from "@/Shared/StatusFilter.vue"; import { useMagicKeys } from "@vueuse/core"; import { getCurrentInstance } from "vue"; import { watchEffect } from "vue"; @@ -376,6 +614,9 @@ export default { TeamSamples, Create, Onboarding, + SearchInput, + EmptySearchState, + StatusFilter, Link, }, props: ["user", "team", "projects", "samples", "teamRole", "filters"], @@ -402,6 +643,15 @@ export default { data() { return { selectedTab: "projects", + searchProjectQuery: "", + currentProjectsPage: 1, + projectsPerPage: 10, + searchSampleQuery: "", + currentSamplesPage: 1, + samplesPerPage: 10, + searchDebounceTimer: null, + selectedProjectStatus: "all", + selectedSampleStatus: "all", }; }, @@ -413,6 +663,136 @@ export default { mailTo() { return "mailto:" + String(this.$page.props.mailFromAddress); }, + + filteredProjects() { + let filtered = this.projects; + + // Apply status filter first + if (this.selectedProjectStatus !== "all") { + filtered = filtered.filter((project) => { + return project.status === this.selectedProjectStatus; + }); + } + + // Apply search filter + if (this.searchProjectQuery) { + const q = this.searchProjectQuery.toLowerCase().trim(); + filtered = filtered.filter((project) => { + const name = (project.name || "").toLowerCase(); + const description = ( + project.description || "" + ).toLowerCase(); + const idText = String(project.id || "").toLowerCase(); + const uuid = String(project.uuid || "").toLowerCase(); + return ( + name.includes(q) || + description.includes(q) || + idText.includes(q) || + uuid.includes(q) + ); + }); + } + + return filtered; + }, + + paginatedProjects() { + const start = (this.currentProjectsPage - 1) * this.projectsPerPage; + return this.filteredProjects.slice( + start, + start + this.projectsPerPage + ); + }, + + totalProjectPages() { + return Math.max( + 1, + Math.ceil(this.filteredProjects.length / this.projectsPerPage) + ); + }, + + editableTeamRole() { + return ( + this.teamRole && + (this.teamRole == "owner" || this.teamRole == "admin") + ); + }, + + filteredSamples() { + let filtered = this.samples; + + // Apply status filter first + if (this.selectedSampleStatus !== "all") { + filtered = filtered.filter((sample) => { + return sample.status === this.selectedSampleStatus; + }); + } + + // Apply search filter + if (this.searchSampleQuery) { + const q = this.searchSampleQuery.toLowerCase().trim(); + filtered = filtered.filter((sample) => { + const name = (sample.name || "").toLowerCase(); + const description = ( + sample.description || "" + ).toLowerCase(); + const idText = String(sample.id || "").toLowerCase(); + const uuid = String(sample.uuid || "").toLowerCase(); + return ( + name.includes(q) || + description.includes(q) || + idText.includes(q) || + uuid.includes(q) + ); + }); + } + + return filtered; + }, + + paginatedSamples() { + const start = (this.currentSamplesPage - 1) * this.samplesPerPage; + return this.filteredSamples.slice( + start, + start + this.samplesPerPage + ); + }, + + totalSamplePages() { + return Math.max( + 1, + Math.ceil(this.filteredSamples.length / this.samplesPerPage) + ); + }, + }, + + watch: { + searchProjectQuery() { + // Debounce the search query to avoid excessive pagination resets + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + this.searchDebounceTimer = setTimeout(() => { + this.currentProjectsPage = 1; + }, 300); // 300ms debounce delay + }, + selectedProjectStatus() { + // Reset to first page when status filter changes + this.currentProjectsPage = 1; + }, + selectedSampleStatus() { + // Reset to first page when status filter changes + this.currentSamplesPage = 1; + }, + searchSampleQuery() { + // Debounce the search query to avoid excessive pagination resets + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + this.searchDebounceTimer = setTimeout(() => { + this.currentSamplesPage = 1; + }, 300); // 300ms debounce delay + }, }, mounted() { @@ -426,5 +806,22 @@ export default { const params = Object.fromEntries(urlSearchParams.entries()); this.selectedTab = params["tab"] ? params["tab"] : "projects"; }, + + beforeUnmount() { + // Clean up the search debounce timer to prevent memory leaks + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + }, + methods: { + clearProjectFilters() { + this.searchProjectQuery = ""; + this.selectedProjectStatus = "all"; + }, + clearSampleFilters() { + this.searchSampleQuery = ""; + this.selectedSampleStatus = "all"; + }, + }, }; diff --git a/resources/js/Pages/Project/Index.vue b/resources/js/Pages/Project/Index.vue index 3b9765b1..3f376197 100644 --- a/resources/js/Pages/Project/Index.vue +++ b/resources/js/Pages/Project/Index.vue @@ -174,55 +174,9 @@ >
{{ project.name }} - - Draft - - - {{ project.status }} - - - DELETED - - - ARCHIVED - - - EMBARGO - +
@@ -375,11 +329,13 @@ import { router } from "@inertiajs/vue3"; import { StarIcon } from "@heroicons/vue/24/solid"; import Tag from "@/Shared/Tag.vue"; import ShowProjectDates from "@/Shared/ShowProjectDates.vue"; +import ProjectStatusBadge from "@/Components/ProjectStatusBadge.vue"; export default { components: { StarIcon, ShowProjectDates, Tag, + ProjectStatusBadge, }, props: ["projects", "mode", "teamRole", "team"], setup() {}, diff --git a/resources/js/Pages/Upload.vue b/resources/js/Pages/Upload.vue index 2ee4007b..46f6945f 100644 --- a/resources/js/Pages/Upload.vue +++ b/resources/js/Pages/Upload.vue @@ -317,201 +317,261 @@

-
- +
+ Showing + {{ + (currentDraftsPage - 1) * + draftsPerPage + + 1 + }} + to + {{ + Math.min( + currentDraftsPage * + draftsPerPage, + filteredDrafts.length + ) + }} + of + {{ filteredDrafts.length }} + drafts
- +
+ +
+ +
-
    -
  • +
    -
    +
    + + +
      +
    • - -
      -

      - {{ - draft.name - }} -

      - + {{ + draft.name + }} +

      + + {{ + draft.external_id + }} + + + {{ + formatStatus( + draft.status + ) + }} + +
      +

      {{ - draft.external_id + draft.description }} - - +

      + ID: + {{ draft.key }} + · Created + at: {{ - formatStatus( - draft.status + formatDateTime( + draft.created_at ) }} - + + · + External ID: + {{ + draft.external_id + }} + +

      -

      - {{ - draft.description - }} -

      -

      - ID: {{ draft.key }} - · Created at: - {{ - formatDateTime( - draft.created_at - ) - }} - + -

      -
      -
      - + + +
    - - -
    - + +
    - -
  • -
+ + + +
@@ -1499,114 +1559,284 @@ Input - + +
+ +
+ +
+
+

+ Paste + SMILES, + MOL, + or + SDF + content + below + or + drag + and + drop + .mol/.sdf + files +

+
+ Auto-detects + and + loads + format + automatically +
+
+ +
+ +
-

- Paste - SMILES, - MOL, - or - SDF - content - below - or - drag - and - drop - .mol/.sdf - files -

+ +
+
- Auto-detects - and - loads - format - automatically + Detected: + {{ + detectedFormat + }}
-
+
+ +
-
+ +
+
+ +
+ +
+ +
- Clear - -
- -
- Detected: - {{ - detectedFormat - }} + +
@@ -2100,7 +2330,8 @@ import { ref } from "vue"; import Primer from "@/Shared/Primer.vue"; import FileSystemBrowser from "./../Shared/FileSystemBrowser.vue"; import Validation from "@/Shared/Validation.vue"; -import DraftSearch from "@/Shared/DraftSearch.vue"; +import SearchInput from "@/Shared/SearchInput.vue"; +import EmptySearchState from "@/Shared/EmptySearchState.vue"; import { TrashIcon, PencilIcon, @@ -2130,7 +2361,8 @@ export default { JetDialogModal, Primer, FileSystemBrowser, - DraftSearch, + SearchInput, + EmptySearchState, TrashIcon, PencilIcon, EyeIcon, @@ -2211,6 +2443,12 @@ export default { percentage: 100, editor: null, + // Chemical input tabs and CAS support + activeInputTab: "structure", // "structure" or "cas" + casInput: "", + casLoading: false, + casError: "", + showPrimer: false, busy: false, @@ -2348,6 +2586,14 @@ export default { this.percentage = newMax; } }, + activeInputTab(newTab, oldTab) { + // Clear errors when switching tabs + if (newTab === "structure" && oldTab === "cas") { + this.casError = ""; + } else if (newTab === "cas" && oldTab === "structure") { + this.errorMessage = ""; + } + }, }, mounted() { const urlSearchParams = new URLSearchParams(window.location.search); @@ -3292,6 +3538,143 @@ export default { } }, + clearCasInput() { + this.casInput = ""; + this.casError = ""; + }, + + async fetchFromCAS(casNumber) { + try { + // Use backend API proxy to avoid CORS issues + const response = await axios.get("/cas/detail", { + params: { + cas_rn: casNumber, + }, + timeout: 30000, // 30 second timeout + }); + + return response.data; + } catch (error) { + // Use error message from backend controller + const errorMessage = + error.response?.data?.error || + error.response?.data?.message || + "CAS API server error - please try again later"; + throw new Error(errorMessage); + } + }, + + async importFromCAS() { + if (!this.casInput.trim()) { + this.casError = "Please enter a CAS Registry Number"; + return; + } + + const casNumber = this.casInput.trim(); + + this.casLoading = true; + this.casError = ""; + + try { + // Fetch data from CAS Common Chemistry API + const casData = await this.fetchFromCAS(casNumber); + + // Validate that we have the required data + if (!casData.smile && !casData.canonicalSmile) { + this.casError = `No structural data (SMILES) available for CAS number ${casNumber}`; + return; + } + + // Process the CAS response + this.processCASResponse(casData); + } catch (error) { + // Use error messages from backend controller + this.casError = error.message; + } finally { + this.casLoading = false; + } + }, + + processCASResponse(casData) { + try { + // Extract SMILES from CAS response + let smiles = casData.smile || casData.canonicalSmile; + + if (!smiles) { + this.casError = + "No SMILES data available for this CAS number"; + return; + } + + // Clear any existing errors + this.errorMessage = ""; + this.casError = ""; + + // Set the chemical input to the SMILES from CAS + this.chemicalInput = smiles; + this.detectedFormat = "SMILES (from CAS)"; + + // Switch to structure tab to show the loaded molecule + this.switchToStructureTab(); + + // Load the structure using existing workflow + if (this.editor) { + this.editor.setSmiles(smiles); + } + + // Use existing standardization workflow + this.processCASMolecule(casData, smiles); + } catch (error) { + this.casError = "Failed to process CAS response data"; + } + }, + + async processCASMolecule(casData, smiles) { + try { + // Create a molecule object from SMILES to standardize + let mol = OCL.Molecule.fromSmiles(smiles); + let molfile = mol.toMolfile(); + + // Use existing standardization workflow + const response = await this.standardizeMolecules(molfile); + + // Add CAS-specific data to the standardized molecule + const standardizedMol = response.data; + standardizedMol.cas_number = casData.rn; + standardizedMol.cas_name = casData.name; + standardizedMol.molecular_formula = + this.stripHtmlTags(casData.molecularFormula) || + standardizedMol.molecular_formula; + + // Add synonyms if available + if (casData.synonyms && casData.synonyms.length > 0) { + standardizedMol.synonyms = casData.synonyms.slice(0, 5); // Limit to first 5 synonyms + } + + // Integrate with existing molecule association workflow + if (this.selectedStudy) { + this.associateMoleculeToStudy( + standardizedMol, + this.selectedStudy + ); + + // Clear CAS input after successful association + this.clearCasInput(); + + // Show success message + this.$emit("show-notification", { + type: "success", + message: `Successfully imported ${casData.name} (CAS: ${casData.rn}) from CAS Registry`, + }); + } else { + this.casError = + "Please select a study before importing molecules"; + } + } catch (error) { + this.casError = `Failed to standardize molecule: ${error.message}`; + } + }, + handlePaste() { // Allow default paste behavior, then auto-load structure this.$nextTick(() => { @@ -3352,6 +3735,19 @@ export default { } }, + // Tab switching methods with error clearing + switchToStructureTab() { + this.activeInputTab = "structure"; + // Clear CAS-related errors when switching away from CAS tab + this.casError = ""; + }, + + switchToCasTab() { + this.activeInputTab = "cas"; + // Clear structure-related errors when switching away from structure tab + this.errorMessage = ""; + }, + // Legacy method name for backward compatibility loadSmiles() { this.loadStructure(); diff --git a/resources/js/Pages/Welcome.vue b/resources/js/Pages/Welcome.vue index d2c95620..f4c684d4 100644 --- a/resources/js/Pages/Welcome.vue +++ b/resources/js/Pages/Welcome.vue @@ -743,7 +743,7 @@
FSU Jena @@ -754,7 +754,7 @@ NFDI4Chem diff --git a/resources/js/Shared/Blocks.vue b/resources/js/Shared/Blocks.vue index 299369bf..1bf47775 100644 --- a/resources/js/Shared/Blocks.vue +++ b/resources/js/Shared/Blocks.vue @@ -10,12 +10,17 @@ +

- + {{ heading }} @@ -65,6 +70,10 @@ export default { description: String, name: String, path: String, + openInNewTab: { + type: Boolean, + default: false, + }, }, }; diff --git a/resources/js/Shared/EmptySearchState.vue b/resources/js/Shared/EmptySearchState.vue new file mode 100644 index 00000000..df272ee2 --- /dev/null +++ b/resources/js/Shared/EmptySearchState.vue @@ -0,0 +1,80 @@ + + + diff --git a/resources/js/Shared/Icon.vue b/resources/js/Shared/Icon.vue index 18e830b6..dcf815a0 100644 --- a/resources/js/Shared/Icon.vue +++ b/resources/js/Shared/Icon.vue @@ -183,6 +183,21 @@ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + + + diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 8e71318f..a7723600 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -18,13 +18,14 @@ @env ('production') -