diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99e45752..a3f14cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,9 @@ jobs: # token is required by quant_search. purge is required by quant_purger, # whose kernel tests install it, so the suite cannot run without it. + # webform is required by quant_webform and its tests. - name: Install module dependencies - run: composer --no-interaction --no-progress require drupal/token drupal/purge + run: composer --no-interaction --no-progress require drupal/token drupal/purge drupal/webform working-directory: /var/www/drupal # The image ships phpunit globally, but its bootstrap needs the test @@ -65,12 +66,25 @@ jobs: run: mkdir -p /var/www/drupal/web/sites/simpletest/browser_output working-directory: /var/www/drupal - # Runs every unit and kernel test in the module and its submodules. - # Previously this job installed the module and stopped, so a broken - # test file went unnoticed. + # Functional tests drive the site over HTTP. Several workers, because a + # test page can call back into the same site (the fake dashboard). + - name: Start the web server + run: | + PHP_CLI_SERVER_WORKERS=4 nohup php -S 127.0.0.1:8080 .ht.router.php > /tmp/php-server.log 2>&1 & + for i in $(seq 1 30); do curl -s -o /dev/null http://127.0.0.1:8080/core/install.php && exit 0; sleep 1; done + cat /tmp/php-server.log; exit 1 + working-directory: /var/www/drupal/web + + # Runs every unit, kernel and functional test in the module and its + # submodules. - name: Run tests env: SIMPLETEST_DB: mysql://root:drupal@mariadb:3306/drupal11 + SIMPLETEST_BASE_URL: http://127.0.0.1:8080 BROWSERTEST_OUTPUT_DIRECTORY: /var/www/drupal/web/sites/simpletest/browser_output run: vendor/bin/phpunit -c web/core --display-deprecations web/modules/custom/quant working-directory: /var/www/drupal + + - name: Web server log + if: failure() + run: tail -n 100 /tmp/php-server.log diff --git a/config/install/quant.settings.yml b/config/install/quant.settings.yml index e9790071..1ade6829 100644 --- a/config/install/quant.settings.yml +++ b/config/install/quant.settings.yml @@ -1,10 +1,10 @@ -quant_enabled: 1 -quant_enabled_nodes: 1 -quant_enabled_taxonomy: 1 -quant_enabled_views: 1 -quant_enabled_redirects: 1 +quant_enabled: true +quant_enabled_nodes: true +quant_enabled_taxonomy: true +quant_enabled_views: true +quant_enabled_redirects: true quant_routes_export: -ssl_cert_verify: 1 -disable_content_drafts: 1 -quant_show_page_info_block: 1 +ssl_cert_verify: true +disable_content_drafts: true +quant_show_page_info_block: true xpath_selectors: "//li[contains(@class,\"pager__item--next\")]/a[contains(@href,\"page=\")]\r\n//li[contains(@class,\"pager__item--first\")]/a[starts-with(@href, \"/\")]\r\n//li[contains(@class,\"page-item\")]/a[contains(@href,\"page=\")]" diff --git a/config/install/quant.token_settings.yml b/config/install/quant.token_settings.yml index 18104e1c..0128fe80 100644 --- a/config/install/quant.token_settings.yml +++ b/config/install/quant.token_settings.yml @@ -1,4 +1,4 @@ timeout: '+1 minute' -disable: 0 +disable: false secret: null -strict: 0 +strict: false diff --git a/config/install/views.view.quant_metadata_file.yml b/config/install/views.view.quant_metadata_file.yml index 482b6944..f43589fc 100644 --- a/config/install/views.view.quant_metadata_file.yml +++ b/config/install/views.view.quant_metadata_file.yml @@ -75,62 +75,7 @@ display: hide_alter_empty: true click_sort_column: value type: file_link - settings: - relationship: none - fieldsets: - - more - - admin_label - custom_label: 0 - label: '' - element_label_colon: 1 - exclude: 0 - element_type_enable: 0 - element_type: '' - element_class_enable: 0 - element_class: '' - element_label_type_enable: 0 - element_label_type: '' - element_label_class_enable: 0 - element_label_class: '' - element_wrapper_type_enable: 0 - element_wrapper_type: '' - element_wrapper_class_enable: 0 - element_wrapper_class: '' - element_default_classes: 1 - alter: - alter_text: 0 - text: '' - make_link: 0 - path: '' - absolute: 0 - replace_spaces: 0 - external: 0 - path_case: none - link_class: '' - alt: '' - rel: '' - prefix: '' - suffix: '' - target: '' - trim: 0 - max_length: '0' - word_boundary: 0 - ellipsis: 0 - more_link: 0 - more_link_text: '' - more_link_path: '' - html: 0 - strip_tags: 0 - preserve_tags: '' - trim_whitespace: 0 - nl2br: 0 - empty: '' - empty_zero: 0 - hide_empty: 0 - hide_alter_empty: 1 - click_sort_column: value - type: file_link - field_api_classes: 0 + settings: { } group_column: value group_columns: { } group_rows: true @@ -396,15 +341,6 @@ display: published: published revision_count: revision_count url: url - byte_length: 0 - highest_revision_number: 0 - md5: 0 - published_revision: 0 - redirect_http_code: 0 - redirect_url: 0 - revision_number: 0 - seq_num: 0 - type: 0 pager: type: mini options: diff --git a/config/install/views.view.quant_metadata_node.yml b/config/install/views.view.quant_metadata_node.yml index d50fdec6..495bec97 100644 --- a/config/install/views.view.quant_metadata_node.yml +++ b/config/install/views.view.quant_metadata_node.yml @@ -274,16 +274,6 @@ display: date_timestamp: date_timestamp published: published url: url - byte_length: 0 - highest_revision_number: 0 - md5: 0 - published_revision: 0 - redirect_http_code: 0 - redirect_url: 0 - revision_count: 0 - revision_number: 0 - seq_num: 0 - type: 0 pager: type: full options: diff --git a/config/install/views.view.quant_metadata_taxonomy.yml b/config/install/views.view.quant_metadata_taxonomy.yml index f9b5b233..fdb5d20f 100644 --- a/config/install/views.view.quant_metadata_taxonomy.yml +++ b/config/install/views.view.quant_metadata_taxonomy.yml @@ -408,16 +408,6 @@ display: date_timestamp: date_timestamp published: published url: url - byte_length: 0 - highest_revision_number: 0 - md5: 0 - published_revision: 0 - redirect_http_code: 0 - redirect_url: 0 - revision_count: 0 - revision_number: 0 - seq_num: 0 - type: 0 pager: type: mini options: diff --git a/config/optional/views.view.quant_metadata_redirect.yml b/config/optional/views.view.quant_metadata_redirect.yml index e67a7051..0780df70 100644 --- a/config/optional/views.view.quant_metadata_redirect.yml +++ b/config/optional/views.view.quant_metadata_redirect.yml @@ -412,14 +412,6 @@ display: redirect_http_code: redirect_http_code redirect_url: redirect_url url: url - byte_length: 0 - highest_revision_number: 0 - md5: 0 - published_revision: 0 - revision_count: 0 - revision_number: 0 - seq_num: 0 - type: 0 pager: type: mini options: diff --git a/config/schema/quant.schema.yml b/config/schema/quant.schema.yml index e4a53c40..26294aba 100644 --- a/config/schema/quant.schema.yml +++ b/config/schema/quant.schema.yml @@ -114,3 +114,14 @@ quant.token_settings: strict: type: boolean label: 'Reject requests whose token does not match the route' + +views.field.quant_metadata: + type: views_field + label: 'Quant metadata' + mapping: + quant_metadata: + type: sequence + label: 'Metadata to show' + sequence: + type: string + label: 'Metadata key' diff --git a/docs/2026-11-08-drupal-to-secured-design.md b/docs/2026-11-08-drupal-to-secured-design.md new file mode 100644 index 00000000..496c3c91 --- /dev/null +++ b/docs/2026-11-08-drupal-to-secured-design.md @@ -0,0 +1,152 @@ +# Drupal site to secured on Quant: design + +Date: 2026-11-08. Scope: module `quant` 2.x (Drupal 11) and the matching portal changes. +Base: `origin/2.x` at e7a1b95 (contains 2.2.0 Typesense search). 1.x is out of scope. + +## 1. OAuth connect ("Log in to Quant") + +### Flow + +1. The editor opens Quant > API and selects the platform (QuantCDN or QuantGov). The + platform sets the API endpoint (`api.quantcdn.io` / `api.quantgov.cloud`) and the dashboard + (`dashboard.quantcdn.io` / `dash.quantgov.cloud`). A `$settings` override stays possible. +2. The editor clicks "Log in to Quant". The module sends the browser to + `{dashboard}/oauth/authorize` with `client_id=drupal-quant`, `scope=full_access`, `state`, + and an S256 PKCE challenge. The state and the verifier are in the Drupal session and are + bound to the user id. +3. The editor approves in the dashboard (2FA, organisation choice: existing consent screen). +4. The callback (`/admin/config/development/quant/api/oauth/callback`) checks the state and + exchanges the code at `{dashboard}/oauth/token`. +5. The module calls `GET /api/oauth/user` and lists the projects of each granted organisation + (`GET /api/v2/organizations/{org}/projects`). The editor picks one project. +6. The module limits the OAuth token to that project (`POST /api/oauth/token/project`), then + reads the project write token (`GET .../projects/{project}?with_token=1`). +7. The module stores the connection. All existing v1 content calls continue to use the + `Quant-Customer` / `Quant-Project` / `Quant-Token` headers. The v1 content API does not + accept the OAuth bearer, so this matches the WordPress plugin 2.0.x. + +To pick another project, the editor disconnects and logs in again. A limited token cannot +widen (the portal returns 409), and this is the intended security property. + +### Storage + +- Tokens are never in exportable config. The OAuth access token, refresh token, expiry, and the + project write token from step 6 go into the KeyValue collection `quant_api.oauth`. +- `quant_api.settings` keeps `auth_method` (`token` | `oauth`), the organisation and project + machine names, the platform and the endpoint. Add a config schema for `quant_api.settings` + (there is none today). +- A new service `quant_api.credentials` resolves the credentials in this order: `$settings` + override, OAuth store (if `auth_method=oauth`), config `api_token`. `QuantClient`, + `quant_search` and `SeedForm` read the token through this service, not from config. +- A site fingerprint (as in `ai_provider_quant_cloud`) blocks refresh on a copied database. + +### Refresh + +Port `AuthService` from `ai_provider_quant_cloud`: a lock for one refresh in flight, the 30 s / +300 s rotation-recovery rules of the portal, `invalid_grant` recorded as "reconnect needed", +cron refresh at half the lifetime, and a 60 s margin on use. The write token does not expire, +so a failed refresh never stops publishing. It only stops project listing and token re-sync. +The settings page shows the connection state and a "Reconnect" button. + +### Fallback and limits + +- The token form stays as "Connect with a project token" (CI, existing sites, settings.php). +- Multi-domain sites (`domain_config` overrides of `quant_api.settings`) stay on token + connect in this release. The OAuth form shows a notice when overrides exist. +- Disconnect deletes the local tokens. The portal has no revoke endpoint (open question). +- Drush: `drush quant:auth-status` prints the state with no token values. + +### Portal change (security-sensitive, review before use) + +New client `drupal-quant` in `src/config/oauth.php`, modelled on `wordpress-quant` and +`scriptattest`: public, trusted, `require_pkce`, `allow_project_limit`, scoped tokens with +`projects:read`, 90d / 365d, `scoped_token_role_checked_at_use`. Redirect URIs: +`http(s)://*/admin/config/development/quant/api/oauth/callback` (the `*/` wildcard allows a +port). A Drupal site in a subdirectory does not match and uses token connect. +Tests: a client-flip feature test, the consent Authz test (PKCE refused without a challenge), +and the project-limit test (a limited token can read only its own project write token). + +## 2. Quant Forms auto-registration + +### How Quant Forms works (facts) + +Quant Forms is a per-page switch, not a form object. A published route gets `form_enabled` +and `form_config`. The CDN takes a POST to that URL, stores the submission, sends the +notifications and serves the page again with a message inside `id="quant-form-result"`. +`POST /v1/form` sets the switch, and a content push (`POST /v1`) accepts the same two keys +inline. A later push without the keys keeps the published values. + +### Design + +- `quant_webform` adds `form_enabled` and `form_config` inline to the content push of every + page that renders a Webform (the Webform canonical page and nodes or blocks that embed one). + The inline push removes the "content must exist first" ordering problem. +- On Webform save, the module queues a re-push of the pages that show that Webform. +- "Register all" (a form action and `drush quant:webform-register`) queues every such page. +- Disable or delete of a Webform sends `form_enabled: false` (no delete endpoint exists). +- The module adds the `quant-form-result` container to each rendered Webform if the form does + not already have the Quant result element. +- The mapper (`WebformQuantConfigMapper`, unit-tested) builds `form_config`: + - `mandatory_fields`: required elements that have no conditional `#states`. + - `remove_fields`: `form_build_id`, `form_token`, `form_id`, `op`, and the antibot / honeypot + timestamp fields. + - `honeypot_fields`: the Honeypot module field, if that module protects the form. + - `success_message`: the Webform confirmation message (type "message" or "inline"). + - `notifications.email`: from the first enabled Webform email handler (to, cc, from, + subject). Webform tokens are converted to Quant `{{field}}` tokens where a 1:1 mapping + exists. Other tokens are removed, and the admin UI lists them. + - `notifications.slack`: a per-site webhook from the module settings, kept in KeyValue, + never in config. + - `spam_threshold` / `spam_detect_action`: module settings (default 0, off). + - `recaptcha_secret_key`: from the `recaptcha` module config, if that module protects the form. + +### Support matrix + +Supported: single-page forms; text, email, textarea, number, tel, url, date, select, radios, +checkboxes, hidden, likert; composite elements (address, name, contact) as flat keys such as +`address[city]`; file and managed_file elements (multipart POST, files kept by Quant); +client-side conditions (`#states`) because the static page keeps the Drupal JavaScript; +email and Slack notifications; honeypot; reCAPTCHA v2. + +Partial: conditionally required fields (enforced in the browser only); email format, min, max +and pattern checks (browser only); confirmation "inline" (shown as the Quant message). + +Not supported (the module shows a warning on the Webform and does not register the form): +multi-page wizards; AJAX submit; save draft; submission limits; confirmation types "page", +"url" and "modal"; handlers other than email (remote post, scripts); computed and server-side +elements; Turnstile and hCaptcha; per-submission tokens in messages. These need a server. + +## 3. Quant search page from Drupal + +Facts: the portal has no API to create dashboard search pages. `quant_search` already has a +Drupal search page entity. Drupal renders the page and pushes it to Quant. The page works on +Algolia and on Typesense (QuantGov). + +Design (proposed reading of "creates the Quant search page"): +- A guided "Create a search page" form in `quant_search`: pick content types; the module + proposes facets from the vocabularies attached to those types plus content type and + language; default route `search`; enabled; publishes at once; optional "place the + autocomplete block". It saves a `quant_search_page` entity and registers the facets. +- Fix the entity schema (only `id` and `label` are declared), the dead + `search_page_repository` service, the `core/jquery.once` dependency, and the docs links. +- No new portal API, so no new security surface. + +Portal UX pass on the dashboard search page wizard (separate branch): +- One screen with optional sections; default URL `/search`, slash normalised. +- Facet picker from `attributesForFaceting` instead of free text; filter help per backend. +- Fix the bug: the wizard saves design options to `search_options`, but the render reads + `template_options`, so step 3 has no effect today. +- Add delete (with `authorize('edit_projects')`) and a link to the live page. +- Save requires `edit_projects` (the edit route allows `read_projects` today). + +## 4. Stretch: Views to search page + +Read a View's base table, bundle filter, exposed filters, sorts and pager. Propose a search page +draft (content types, facets from exposed taxonomy filters, results per page). The editor +reviews the draft in the form from item 3. Only after items 1 to 3. + +## 5. Docs and skills + +Update `quant-cms-integrations` (OAuth connect, forms, search page) and the Drupal search +docs page (Typesense on QuantGov, facet keys `{vocab}_{langcode}`). Also document +`POST /v1/form` in `content-api.json`. Done last, against the shipped behaviour. diff --git a/docs/2026-11-08-drupal-to-secured-ledger.md b/docs/2026-11-08-drupal-to-secured-ledger.md new file mode 100644 index 00000000..720746f1 --- /dev/null +++ b/docs/2026-11-08-drupal-to-secured-ledger.md @@ -0,0 +1,38 @@ +# Drupal site to secured: rulings ledger + +Each ruling is reversible unless marked. Format: id, date, ruling, reason. + +- R1 (2026-11-08): Base all module work on `origin/2.x` (e7a1b95). The local `2.x` is stale. + `fix/composer-project-prefix` is not merged yet and stays out. +- R2: Keep the v1 content calls on the project write token. The v1 API does not accept the + OAuth bearer. Same model as the WordPress plugin 2.0.x. +- R3: Limit the OAuth token to the chosen project (`allow_project_limit`). Reason: an org-wide + `projects:read` token with `with_token=1` can read the write token of every project in the + organisation. A leaked Drupal database must expose only one project. Approved by the controller + 2026-11-08. A "Change project" button restarts the login flow. +- R4: Tokens live in KeyValue `quant_api.oauth`, never in exportable config. +- R5: Multi-domain sites stay on token connect in this release. Approved. The UI says so next to + the login button when multi-domain overrides exist. +- R6: Forms config goes inline in the content push, not through a separate `POST /v1/form` + call, except to disable a form. +- R7 (superseded by R9): Multi-page Webforms are not registered; the module warns instead. +- R8: Item 3 builds on the Drupal-rendered `quant_search_page`; no new portal API. Approved. + The portal wizard UX pass stays. The `search_options` / `template_options` bug is its own + fix commit with a test. +- R9 (controller, 2026-11-08): Investigate client-side wizard pages in the static output (all + pages in one form, JavaScript steps, client validation per page, one final submit). Support + it if it fits the item; otherwise report why, with a size estimate, before dropping it. +- R10 (controller): AJAX submit, drafts, limits, non-email handlers, page and modal + confirmations, Turnstile and hCaptcha stay unsupported with a clear warning. Add the + "redirect to URL" confirmation if it is cheap. +- R11 (controller): Disconnect deletes the local tokens only in this release. +- R12 (controller): Document `POST /v1/form` in item 5. +- R13 (controller): Use the scratchpad d11 site until the demo site at ~/apps/quant-demo-drupal + reports ready. +- R14: The `drupal-quant` redirect patterns cover the module callback at the web root on any + host and port. A Drupal site in a subdirectory uses token connect. Same rule as `wordpress-quant`. + +## Follow-ups + +- F1: Portal OAuth revoke endpoint (RFC 7009), so that disconnect also revokes the tokens. + For the controller to schedule. diff --git a/docs/2026-11-08-drupal-to-secured-plan.md b/docs/2026-11-08-drupal-to-secured-plan.md new file mode 100644 index 00000000..6c3c9cad --- /dev/null +++ b/docs/2026-11-08-drupal-to-secured-plan.md @@ -0,0 +1,69 @@ +# Drupal site to secured: implementation plan + +Design: `docs/2026-11-08-drupal-to-secured-design.md`. Rulings: `docs/2026-11-08-drupal-to-secured-ledger.md`. +Test site: Drupal 11.4 on SQLite in the session scratchpad (until the shared demo site exists). + +## Branches + +| Item | Repo | Branch | Base | +|---|---|---|---| +| Design, plan, ledger | quant-drupal | `docs/drupal-to-secured` | origin/2.x | +| 1 OAuth (module) | quant-drupal | `feat/oauth-connect` | origin/2.x | +| 1 OAuth (portal client) | portal | `sec/drupal-quant-oauth-client` | origin/develop | +| 2 Forms | quant-drupal | `feat/webform-quant-forms` | feat/oauth-connect (credentials service) | +| 3 Search (module) | quant-drupal | `feat/search-page-setup` | feat/oauth-connect | +| 3 Search (portal UX) | portal | `feat/search-page-wizard-ux` | origin/develop | +| 4 Views converter | quant-drupal | `feat/views-search-converter` | feat/search-page-setup | +| 5 Docs | quant-agent-skills, quant-docs | `docs/drupal-oauth-forms-search` | their default branches | + +Worktrees: `~/apps/.quant-drupal-wt/`, `~/apps/.portal-wt/`. + +## Item 1: OAuth connect + +1. Portal: add the `drupal-quant` client; tests (client flip, consent Authz, project limit). + **Stop. Send the diff summary to the controller for review.** +2. Module: config schema for `quant_api.settings`; `auth_method`, `platform` keys; update hook. +3. Module: `quant_api.credentials` service; move every direct `api_token` reader to it + (QuantClient, quant_search Search.php / forms / module, SeedForm). Kernel test for the order. +4. Module: `OAuthTokenStore` (KeyValue), `SiteFingerprint`, `PortalUrl` (per platform). +5. Module: `AuthService` (authorize URL, PKCE, code exchange, refresh with lock and rotation + recovery, refusal state). Unit tests ported from `ai_provider_quant_cloud`. +6. Module: `OAuthController` (connect, callback, disconnect with CSRF) and the project + picker form (list, limit, fetch write token). Kernel and functional tests with a mocked + HTTP client. +7. Module: settings form with two paths ("Log in to Quant", "Connect with a project token"), + the multi-domain notice, status display; `hook_cron` refresh; `drush quant:auth-status`. +8. Manual end to end against the local portal (stage Cloud API) with a local test org only. + +## Item 2: Quant Forms + +1. `WebformQuantConfigMapper` + support classifier; unit tests for every row of the matrix. +2. Inline `form_enabled` / `form_config` in the content push (QuantApi `onOutput` event or a + new event); kernel test of the payload. +3. Webform save / disable / delete hooks; queue re-push of the pages that show the form. +4. "Register all" form action and `drush quant:webform-register`. +5. Settings: Slack webhook (KeyValue), spam threshold; result container injection. +6. Admin warnings on unsupported Webforms; functional test. + +## Item 3: Search page + +1. Fix `quant_search_page` schema, dead service, `core/jquery.once`, docs links; kernel test + for config schema validity. +2. Guided "Create a search page" form with facet proposal; functional test. +3. Portal: wizard UX pass (one screen, defaults, facet picker, template_options bug, delete, + live link, edit permission); Livewire Authz test per CLAUDE.md; next-gen views via the + portal skills. + +## Item 4 (stretch): Views converter + +1. `ViewToSearchPage` analyser (unit tests with fixture views), "Convert to search page" link. + +## Item 5: Docs and skills + +1. quant-cms-integrations skill + references/drupal.md; leak-check and lint-skills. +2. quant-docs: Drupal search page, a Drupal forms page, `POST /v1/form` in content-api.json. + +## Checks for each commit + +`phpcs --standard=Drupal` on changed files; module phpunit (unit + kernel + functional) via +the scratch site; for the portal, `wt-phpunit.sh --filter X` only. diff --git a/modules/quant_api/config/install/quant_api.settings.yml b/modules/quant_api/config/install/quant_api.settings.yml index 618cef03..1b1f14fa 100644 --- a/modules/quant_api/config/install/quant_api.settings.yml +++ b/modules/quant_api/config/install/quant_api.settings.yml @@ -1 +1,3 @@ api_endpoint: https://api.quantcdn.io +platform: quantcdn +auth_method: token diff --git a/modules/quant_api/config/schema/quant_api.schema.yml b/modules/quant_api/config/schema/quant_api.schema.yml index 80b8c5e3..79b922cd 100644 --- a/modules/quant_api/config/schema/quant_api.schema.yml +++ b/modules/quant_api/config/schema/quant_api.schema.yml @@ -21,3 +21,9 @@ quant_api.settings: api_tls_disabled: type: boolean label: 'Disable TLS verification' + platform: + type: string + label: 'Platform (quantcdn or quantgov)' + auth_method: + type: string + label: 'Connection method (token or oauth)' diff --git a/modules/quant_api/drush.services.yml b/modules/quant_api/drush.services.yml new file mode 100644 index 00000000..f37d4a95 --- /dev/null +++ b/modules/quant_api/drush.services.yml @@ -0,0 +1,10 @@ +services: + quant_api.commands: + class: \Drupal\quant_api\Commands\QuantApiAuthCommands + arguments: + - '@config.factory' + - '@quant_api.credentials' + - '@quant_api.oauth' + - '@quant_api.oauth_store' + tags: + - { name: drush.command } diff --git a/modules/quant_api/quant_api.install b/modules/quant_api/quant_api.install new file mode 100644 index 00000000..737ed102 --- /dev/null +++ b/modules/quant_api/quant_api.install @@ -0,0 +1,105 @@ +usesOauth()) { + return $requirements; + } + if (!$credentials->isCopiedSite()) { + $reason = \Drupal::service('quant_api.oauth')->getReconnectReason(); + if ($reason === NULL) { + return $requirements; + } + return $requirements + [ + 'quant_api_login' => [ + 'title' => t('Quant'), + 'value' => t('The Quant login needs renewing'), + 'description' => t('Content still publishes. Log in to Quant again on the Quant API settings page to keep the connection current.', [ + ':url' => Url::fromRoute('quant_api.settings_form')->toString(), + ]), + 'severity' => REQUIREMENT_WARNING, + ], + ]; + } + return $requirements + [ + 'quant_api_copied_site' => [ + 'title' => t('Quant'), + 'value' => t('Not publishing: this site looks like a copy of the connected site'), + 'description' => t('Different: @components. Log in to Quant again, or confirm that it is the same site on the Quant API settings page.', [ + '@components' => implode(', ', $credentials->copyDifferences()), + ':url' => Url::fromRoute('quant_api.settings_form')->toString(), + ]), + 'severity' => REQUIREMENT_ERROR, + ], + ]; +} + +/** + * The status report entry for pushes that did not reach Quant. + */ +function _quant_api_push_failure_requirements(): array { + $failures = \Drupal::service('quant_api.push_failures')->all(); + if ($failures === []) { + return []; + } + $items = []; + foreach (array_slice(array_reverse($failures, TRUE), 0, 10, TRUE) as $url => $failure) { + $items[] = t('@url (@operation, @reason, @date)', [ + '@url' => $url, + '@operation' => $failure['operation'], + '@reason' => $failure['reason'], + '@date' => \Drupal::service('date.formatter')->format($failure['at'], 'custom', 'Y-m-d H:i'), + ]); + } + $list = ['#theme' => 'item_list', '#items' => $items]; + return [ + 'quant_api_push_failures' => [ + 'title' => t('Quant'), + 'value' => \Drupal::translation()->formatPlural(count($failures), '1 change did not reach Quant', '@count changes did not reach Quant'), + 'description' => [ + 'list' => $list, + 'help' => ['#markup' => t('These pages are not up to date on the static site. After Quant is back, save each page again or run a seed. An entry goes away when its page is pushed.')], + ], + 'severity' => REQUIREMENT_WARNING, + ], + ]; +} + +/** + * Implements hook_uninstall(). + */ +function quant_api_uninstall() { + // The OAuth connection is outside config, so config removal leaves it. + $store = \Drupal::service('quant_api.oauth_store'); + $store->clear(); + $store->deleteAllPendingLogins(); +} + +/** + * Add the platform and the connection method to the Quant API settings. + */ +function quant_api_update_11001() { + $config = \Drupal::configFactory()->getEditable('quant_api.settings'); + if (!Platform::isKnown($config->get('platform'))) { + $config->set('platform', Platform::fromEndpoint($config->get('api_endpoint'))); + } + if (!in_array($config->get('auth_method'), ['token', 'oauth'], TRUE)) { + $config->set('auth_method', 'token'); + } + $config->save(); +} diff --git a/modules/quant_api/quant_api.module b/modules/quant_api/quant_api.module new file mode 100644 index 00000000..e780bcd6 --- /dev/null +++ b/modules/quant_api/quant_api.module @@ -0,0 +1,50 @@ +usesOauth()) { + $refreshed = \Drupal::service('quant_api.oauth')->refreshFromCron(); + // A write token rotated in the dashboard recovers without a new login. + \Drupal::service('quant_api.write_token_sync')->syncIfDue($refreshed); + } +} + +/** + * Implements hook_page_top(). + * + * Pushes run after the response is sent, so an editor never saw one fail. + * The failures are kept for the site, and every editor sees them on the next + * page view until the pages are pushed again. + */ +function quant_api_page_top(array &$page_top) { + $account = \Drupal::currentUser(); + if (!$account->hasPermission('configure quant') && !$account->hasPermission('access content overview')) { + return; + } + // Cached pages are invalidated when the list changes (PushFailures). + $page_top['#cache']['tags'][] = PushFailures::CACHE_TAG; + $page_top['#cache']['contexts'][] = 'user.permissions'; + $failures = \Drupal::service('quant_api.push_failures')->all(); + if ($failures === []) { + return; + } + $last = array_key_last($failures); + \Drupal::messenger()->addWarning(\Drupal::translation()->formatPlural( + count($failures), + '1 change did not reach Quant (@url: @reason). It is not on the static site yet. After Quant is back, save the page again or run a seed.', + '@count changes did not reach Quant (the last: @url, @reason). They are not on the static site yet. After Quant is back, save the pages again or run a seed.', + ['@url' => $last, '@reason' => $failures[$last]['reason']] + )); + $page_top['#cache']['max-age'] = 0; +} diff --git a/modules/quant_api/quant_api.routing.yml b/modules/quant_api/quant_api.routing.yml index 94dcb8bd..fbb071f2 100644 --- a/modules/quant_api/quant_api.routing.yml +++ b/modules/quant_api/quant_api.routing.yml @@ -7,3 +7,49 @@ quant_api.settings_form: _permission: 'configure quant' options: _admin_route: TRUE + +quant_api.oauth_connect: + path: '/admin/config/development/quant/api/oauth/connect' + defaults: + _controller: '\Drupal\quant_api\Controller\OAuthController::connect' + _title: 'Log in to Quant' + requirements: + _permission: 'configure quant' + _csrf_token: 'TRUE' + options: + _admin_route: TRUE + no_cache: TRUE + +quant_api.oauth_callback: + path: '/admin/config/development/quant/api/oauth/callback' + defaults: + _controller: '\Drupal\quant_api\Controller\OAuthController::callback' + _title: 'Log in to Quant' + requirements: + _permission: 'configure quant' + options: + _admin_route: TRUE + no_cache: TRUE + +quant_api.oauth_project: + path: '/admin/config/development/quant/api/oauth/project' + defaults: + _form: '\Drupal\quant_api\Form\ProjectSelectForm' + _title: 'Choose a Quant project' + requirements: + _permission: 'configure quant' + options: + _admin_route: TRUE + no_cache: TRUE + +quant_api.oauth_disconnect: + path: '/admin/config/development/quant/api/oauth/disconnect' + defaults: + _controller: '\Drupal\quant_api\Controller\OAuthController::disconnect' + _title: 'Log out of Quant' + requirements: + _permission: 'configure quant' + _csrf_token: 'TRUE' + options: + _admin_route: TRUE + no_cache: TRUE diff --git a/modules/quant_api/quant_api.services.yml b/modules/quant_api/quant_api.services.yml index ad13d43d..86d3f728 100644 --- a/modules/quant_api/quant_api.services.yml +++ b/modules/quant_api/quant_api.services.yml @@ -5,6 +5,44 @@ services: - '@http_client' - '@config.factory' - '@logger.factory' + - '@quant_api.credentials' + + quant_api.credentials: + class: Drupal\quant_api\Credentials + arguments: + - '@config.factory' + - '@quant_api.oauth_store' + - '@config.storage' + - '@quant_api.site_fingerprint' + + quant_api.oauth_store: + class: Drupal\quant_api\OAuth\OAuthTokenStore + arguments: + - '@keyvalue' + - '@keyvalue.expirable' + + quant_api.site_fingerprint: + class: Drupal\quant_api\OAuth\SiteFingerprint + arguments: + - '@config.factory' + + quant_api.oauth: + class: Drupal\quant_api\OAuth\AuthService + arguments: + - '@http_client' + - '@config.factory' + - '@quant_api.oauth_store' + - '@quant_api.site_fingerprint' + - '@lock' + - '@database' + - '@logger.factory' + + quant_api.portal: + class: Drupal\quant_api\OAuth\PortalClient + arguments: + - '@http_client' + - '@quant_api.oauth' + - '@logger.factory' quant.output.api: class: Drupal\quant_api\EventSubscriber\QuantApi @@ -12,5 +50,34 @@ services: - '@quant_api.client' - '@logger.factory' - '@event_dispatcher' + - '@quant_api.push_failures' tags: - { name: 'event_subscriber' } + + quant_api.write_token_sync: + class: Drupal\quant_api\OAuth\WriteTokenSync + arguments: + - '@quant_api.oauth' + - '@quant_api.portal' + - '@quant_api.oauth_store' + - '@quant_api.credentials' + - '@config.factory' + - '@logger.factory' + + quant_api.unauthorized_resync_middleware: + class: Drupal\quant_api\Http\UnauthorizedResyncMiddleware + tags: + - { name: http_client_middleware } + + quant_api.push_failures: + class: Drupal\quant_api\PushFailures + arguments: + - '@state' + - '@datetime.time' + - '@cache_tags.invalidator' + - '@lock' + + quant_api.endpoint_userinfo_middleware: + class: Drupal\quant_api\Http\EndpointUserInfoMiddleware + tags: + - { name: http_client_middleware } diff --git a/modules/quant_api/src/Client/QuantClient.php b/modules/quant_api/src/Client/QuantClient.php index 424023f9..bbf6ce14 100644 --- a/modules/quant_api/src/Client/QuantClient.php +++ b/modules/quant_api/src/Client/QuantClient.php @@ -5,9 +5,10 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\quant\PublishGuard; +use Drupal\quant_api\Credentials; use Drupal\quant_api\Exception\InvalidPayload; use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\MultipartStream; use GuzzleHttp\Psr7\Utils; @@ -18,6 +19,13 @@ */ class QuantClient implements QuantClientInterface { + /** + * Whether the last ping() failed because Quant could not be reached. + * + * @var bool + */ + protected bool $unreachable = FALSE; + /** * The configuration object for Quant API. * @@ -81,17 +89,48 @@ class QuantClient implements QuantClientInterface { */ protected $configFactory; + /** + * Resolves the write token (settings.php, OAuth connection or config). + * + * @var \Drupal\quant_api\Credentials|null + */ + protected $credentials; + /** * {@inheritdoc} */ - public function __construct(Client $client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) { + public function __construct(Client $client, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ?Credentials $credentials = NULL) { $this->configFactory = $config_factory; $this->client = $client; $this->logger = $logger_factory->get('quant_api'); + $this->credentials = $credentials; $this->refreshCredentials(); } + /** + * {@inheritdoc} + */ + public function lastCallUnreachable() : bool { + return $this->unreachable; + } + + /** + * Tells the editor that Quant could not be reached, and logs the cause. + * + * The exception text names the request URL, which can carry credentials, + * so the editor sees a fixed message and the log gets the class and the + * text without any user information in URLs. + */ + protected function reportUnreachable(GuzzleException $e) : void { + $this->unreachable = TRUE; + $this->logger->error('Quant API request failed (@class): @message', [ + '@class' => get_class($e), + '@message' => preg_replace('#(https?://)[^/\s@]+@#i', '$1', $e->getMessage()), + ]); + \Drupal::messenger()->addError(t('This site could not reach Quant. Check the API endpoint on the Quant API settings page, or try again in a few minutes. After Quant is back, save the changed content again or run a seed.')); + } + /** * Re-reads the API credentials from configuration. * @@ -108,12 +147,48 @@ protected function refreshCredentials() : void { $this->config = $this->configFactory->get('quant_api.settings'); $this->username = $this->config->get('api_account'); - $this->token = $this->config->get('api_token'); + // An OAuth connection keeps its token outside config. + $this->token = $this->credentials ? $this->credentials->getToken($this->config) : $this->config->get('api_token'); $this->project = $this->config->get('api_project'); - $this->endpoint = $this->config->get('api_endpoint') . '/v1'; + $this->endpoint = ($this->credentials ? $this->credentials->getEndpoint($this->config) : $this->config->get('api_endpoint')) . '/v1'; $this->tlsDisabled = $this->config->get('api_tls_disabled'); } + /** + * Refuses any call when no project token is set: no request leaves Drupal. + * + * An unconnected site used to send requests with empty credentials (for + * example an unpublish when a search page was deleted). Fail closed and say + * why, once per process. + * + * @param string $operation + * What is being attempted, for the log. + * + * @return bool + * TRUE when the caller must not proceed. + */ + protected function refusesWithoutToken(string $operation) : bool { + if (!empty($this->token)) { + return FALSE; + } + static $logged = FALSE; + if (!$logged) { + $logged = TRUE; + $copy = $this->credentials && $this->credentials->usesOauth($this->config) ? $this->credentials->copyDifferences() : []; + if ($copy !== []) { + $this->logger->error('Quant: this site looks like a copy of the connected site (different: @components), so nothing is published (@operation). Log in to Quant again, or confirm it is the same site on the Quant API settings page.', [ + '@components' => implode(', ', $copy), + '@operation' => $operation, + ]); + return TRUE; + } + $this->logger->warning('Quant API: no project token is set, so the module did not call the API (@operation). Connect the site on the Quant API settings page.', [ + '@operation' => $operation, + ]); + } + return TRUE; + } + /** * Refuses a write the current context cannot be trusted to address. * @@ -129,6 +204,9 @@ protected function refreshCredentials() : void { * TRUE when the caller must not proceed. */ protected function refusesWrite(string $operation) : bool { + if ($this->refusesWithoutToken($operation)) { + return TRUE; + } if (!PublishGuard::refuses($host)) { return FALSE; } @@ -179,7 +257,11 @@ public function getOverrides() { * {@inheritdoc} */ public function ping() { + $this->unreachable = FALSE; $this->refreshCredentials(); + if ($this->refusesWithoutToken('to check the connection')) { + return FALSE; + } try { // @todo Switch from 'Quant-Customer' to 'Quant-Organization'. @@ -194,8 +276,8 @@ public function ping() { 'verify' => $this->tlsDisabled ? FALSE : TRUE, ]); } - catch (RequestException $e) { - \Drupal::messenger()->addError($e->getMessage()); + catch (GuzzleException $e) { + $this->reportUnreachable($e); return FALSE; } @@ -221,6 +303,9 @@ public function ping() { */ public function project() { $this->refreshCredentials(); + if ($this->refusesWithoutToken('to read the project')) { + return FALSE; + } try { // @todo Switch from 'Quant-Customer' to 'Quant-Organization'. @@ -234,8 +319,8 @@ public function project() { 'exceptions' => FALSE, ]); } - catch (RequestException $e) { - \Drupal::messenger()->addError($e->getMessage()); + catch (GuzzleException $e) { + $this->reportUnreachable($e); return FALSE; } @@ -261,6 +346,9 @@ public function project() { */ public function search() { $this->refreshCredentials(); + if ($this->refusesWithoutToken('to read the search index')) { + return FALSE; + } try { // @todo Switch from 'Quant-Customer' to 'Quant-Organization'. @@ -274,8 +362,8 @@ public function search() { 'exceptions' => FALSE, ]); } - catch (RequestException $e) { - \Drupal::messenger()->addError($e->getMessage()); + catch (GuzzleException $e) { + $this->reportUnreachable($e); return FALSE; } @@ -432,6 +520,9 @@ public function unpublish(string $url) : array { */ public function getUrlMeta(array $urls) : array { $this->refreshCredentials(); + if ($this->refusesWithoutToken('to read page metadata')) { + return []; + } // Format array if it's not already. if (!array_key_exists('Quant-Url', $urls)) { $urls = [ diff --git a/modules/quant_api/src/Client/QuantClientInterface.php b/modules/quant_api/src/Client/QuantClientInterface.php index ae518487..90582523 100644 --- a/modules/quant_api/src/Client/QuantClientInterface.php +++ b/modules/quant_api/src/Client/QuantClientInterface.php @@ -111,6 +111,16 @@ public function unpublish(string $url) : array; */ public function sendSearchRecords(array $records) : array; + /** + * Whether the last ping() failed because Quant could not be reached. + * + * The client has then already shown the editor why. + * + * @return bool + * TRUE when the endpoint could not be reached. + */ + public function lastCallUnreachable() : bool; + /** * Clear a search index. * diff --git a/modules/quant_api/src/Commands/QuantApiAuthCommands.php b/modules/quant_api/src/Commands/QuantApiAuthCommands.php new file mode 100644 index 00000000..b445ecdc --- /dev/null +++ b/modules/quant_api/src/Commands/QuantApiAuthCommands.php @@ -0,0 +1,53 @@ +configFactory->get('quant_api.settings'); + $oauth = $this->credentials->usesOauth($config) && !$config->hasOverrides('api_token'); + $rows = [ + 'Platform' => (string) $config->get('platform'), + 'API endpoint' => Credentials::redact($this->credentials->getEndpoint($config)) . (Credentials::endpointOverride() !== '' ? ' (settings.php)' : ''), + 'Organization' => (string) $config->get('api_account'), + 'Project' => (string) $config->get('api_project'), + 'Method' => $oauth ? 'Log in to Quant' : ($config->hasOverrides('api_token') ? 'Project token (settings.php)' : 'Project token'), + 'Token available' => $this->credentials->hasToken($config) ? 'yes' : 'no', + ]; + if ($oauth) { + $expires = (int) $this->tokenStore->get(OAuthTokenStore::EXPIRES_AT); + $rows['Login expires'] = $expires ? date('Y-m-d H:i', $expires) : '-'; + $rows['Needs a new login'] = $this->auth->getReconnectReason() ?? 'no'; + } + foreach ($rows as $label => $value) { + $this->io()->writeln(sprintf('%-18s %s', $label . ':', $value)); + } + } + +} diff --git a/modules/quant_api/src/Controller/OAuthController.php b/modules/quant_api/src/Controller/OAuthController.php new file mode 100644 index 00000000..65c5f247 --- /dev/null +++ b/modules/quant_api/src/Controller/OAuthController.php @@ -0,0 +1,181 @@ +configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('quant_api.oauth'), + $container->get('quant_api.portal'), + $container->get('quant_api.oauth_store'), + $container->get('quant_api.credentials'), + $container->get('config.factory'), + ); + } + + /** + * Sends the editor to the dashboard to approve this site. + */ + public function connect(Request $request) { + if ($this->credentials->hasDomainOverrides()) { + $this->messenger()->addError($this->t('This site publishes several domains to several projects. Log in to Quant supports one project per site, so connect each domain with a project token.')); + return $this->redirectToSettings(); + } + $state = bin2hex(random_bytes(16)); + $verifier = $this->auth->generatePkceVerifier(); + $redirect_uri = self::callbackUrl(); + // The platform chosen on the settings form, saved only when the login + // completes; "Change project" keeps the current one. + $platform = (string) $request->getSession()->remove(self::PLATFORM_KEY); + $this->auth->usePlatform($platform ?: NULL); + $request->getSession()->set(self::SESSION_KEY, [ + 'state' => $state, + 'verifier' => $verifier, + 'uid' => (int) $this->currentUser()->id(), + 'created' => time(), + 'platform' => $this->auth->getPlatform(), + ]); + $url = $this->auth->getAuthorizationUrl($state, $redirect_uri, $this->auth->computePkceChallenge($verifier)); + $response = new TrustedRedirectResponse($url); + $response->getCacheableMetadata()->setCacheMaxAge(0); + return $response; + } + + /** + * Takes the dashboard's answer and asks for the project. + */ + public function callback(Request $request) { + $session = $request->getSession(); + $login = $session->get(self::SESSION_KEY); + // One use only, whatever the outcome. + $session->remove(self::SESSION_KEY); + + $error = $this->validateCallback($request, $login); + if ($error !== NULL) { + $this->messenger()->addError($error); + return $this->redirectToSettings(); + } + + $this->auth->usePlatform($login['platform'] ?? NULL); + $tokens = $this->auth->exchangeCode((string) $request->query->get('code'), self::callbackUrl(), $login['verifier']); + if ($tokens === NULL) { + $this->messenger()->addError($this->t('Quant did not accept the login. Try again. If it fails again, connect with a project token.')); + return $this->redirectToSettings(); + } + + // The token response names the user but not the organisations; only + // GET /api/oauth/user lists the organisations the editor approved. + $token_user = is_array($tokens['user'] ?? NULL) ? $tokens['user'] : []; + unset($tokens['user']); + $user = $this->portal->getUser($tokens['access_token']) ?? $token_user; + $this->tokenStore->savePendingLogin((int) $this->currentUser()->id(), [ + 'tokens' => $tokens, + 'user' => ['name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? '')], + 'organizations' => self::organizations($user['organizations'] ?? []), + 'platform' => $this->auth->getPlatform(), + ]); + return new RedirectResponse(Url::fromRoute('quant_api.oauth_project')->toString()); + } + + /** + * Deletes the local connection and goes back to token connect. + */ + public function disconnect() { + $this->auth->disconnect(); + $this->configFactory->getEditable('quant_api.settings') + ->set('auth_method', Credentials::METHOD_TOKEN) + ->save(); + $this->messenger()->addStatus($this->t('This site is logged out of Quant. Content is not published until you log in again or connect with a project token.')); + return $this->redirectToSettings(); + } + + /** + * The error message of a bad callback, or NULL when it is valid. + */ + protected function validateCallback(Request $request, mixed $login): ?string { + if ($request->query->has('error')) { + return (string) $this->t('The login was cancelled in the Quant dashboard. Nothing changed.'); + } + if (!is_array($login) || empty($login['state']) || !is_string($request->query->get('state'))) { + return (string) $this->t('The login expired or was started in another browser. Start it again.'); + } + $valid = hash_equals($login['state'], $request->query->get('state')) + && (int) $login['uid'] === (int) $this->currentUser()->id() + && (int) $login['created'] >= time() - self::STATE_TTL + && is_string($request->query->get('code')) && $request->query->get('code') !== ''; + return $valid ? NULL : (string) $this->t('The login expired or was started in another browser. Start it again.'); + } + + /** + * The granted organisations, machine name => name, safe names only. + */ + public static function organizations(mixed $organizations): array { + $result = []; + foreach (is_array($organizations) ? $organizations : [] as $org) { + $machine = is_array($org) ? (string) ($org['machine_name'] ?? '') : ''; + if (PortalClient::isMachineName($machine)) { + $result[$machine] = (string) ($org['name'] ?? $machine); + } + } + return $result; + } + + /** + * The absolute callback URL that the dashboard redirects to. + */ + public static function callbackUrl(): string { + return Url::fromRoute('quant_api.oauth_callback', [], ['absolute' => TRUE])->toString(TRUE)->getGeneratedUrl(); + } + + /** + * A redirect to the Quant API settings page. + */ + protected function redirectToSettings(): RedirectResponse { + return new RedirectResponse(Url::fromRoute('quant_api.settings_form')->toString()); + } + +} diff --git a/modules/quant_api/src/Credentials.php b/modules/quant_api/src/Credentials.php new file mode 100644 index 00000000..922e9c36 --- /dev/null +++ b/modules/quant_api/src/Credentials.php @@ -0,0 +1,155 @@ +copyDifferences() !== []; + } + + /** + * The fingerprint components that differ from the connected site. + * + * @return string[] + * Component names (database, prefix, hash_salt, uuid, env:...), never + * values; empty when this is the connected site. + */ + public function copyDifferences(): array { + $stored = $this->tokenStore->get(OAuthTokenStore::FINGERPRINT); + return is_array($stored) ? $this->fingerprint->differences($stored) : []; + } + + /** + * Confirms that this is the connected site: stores its fingerprint again. + */ + public function confirmSameSite(): void { + $this->tokenStore->set(OAuthTokenStore::FINGERPRINT, $this->fingerprint->compute()); + } + + /** + * The endpoint from $settings['quant_api_endpoint_url'], or an empty string. + * + * Set only in settings.php, never in the UI. It may carry basic-auth + * userinfo (https://user:password@host); the HTTP client moves that into a + * header, and redact() keeps it off every page. + */ + public static function endpointOverride(): string { + return rtrim(trim((string) Settings::get('quant_api_endpoint_url', '')), '/'); + } + + /** + * An endpoint without its userinfo, for display. + */ + public static function redact(string $endpoint): string { + $parts = parse_url($endpoint); + if (!is_array($parts) || empty($parts['host'])) { + return ''; + } + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + return ($parts['scheme'] ?? 'https') . '://' . $parts['host'] . $port . ($parts['path'] ?? ''); + } + + /** + * The content API endpoint, without /v1. + * + * With "Log in to Quant" the endpoint is the chosen platform's official + * host, so the hidden write token never goes elsewhere. A settings.php + * override still wins. + */ + public function getEndpoint(?Config $config = NULL): string { + $override = self::endpointOverride(); + if ($override !== '') { + return $override; + } + $config = $config ?? $this->configFactory->get('quant_api.settings'); + if ($this->usesOauth($config) && !$config->hasOverrides('api_endpoint')) { + return Platform::apiEndpoint((string) $config->get('platform')); + } + return (string) $config->get('api_endpoint'); + } + + /** + * Whether a domain overrides the Quant API settings (a multi-domain site). + * + * Such a site publishes each domain to its own project. "Log in to Quant" + * stores one connection, so these sites use token connect for now. + */ + public function hasDomainOverrides(): bool { + foreach ($this->configStorage->listAll('domain.config.') as $name) { + if (str_ends_with($name, '.quant_api.settings')) { + return TRUE; + } + } + return FALSE; + } + + /** + * The write token for the given (possibly domain-overridden) settings. + */ + public function getToken(?Config $config = NULL): ?string { + $config = $config ?? $this->configFactory->get('quant_api.settings'); + if ($config->hasOverrides('api_token')) { + return ((string) $config->get('api_token')) ?: NULL; + } + if ($this->usesOauth($config)) { + if ($this->isCopiedSite()) { + return NULL; + } + return ((string) $this->tokenStore->get(OAuthTokenStore::WRITE_TOKEN)) ?: NULL; + } + return ((string) $config->get('api_token')) ?: NULL; + } + + /** + * Whether a token is available, without returning it. + */ + public function hasToken(?Config $config = NULL): bool { + return $this->getToken($config) !== NULL; + } + + /** + * Whether the site connects with "Log in to Quant". + */ + public function usesOauth(?Config $config = NULL): bool { + $config = $config ?? $this->configFactory->get('quant_api.settings'); + return $config->get('auth_method') === self::METHOD_OAUTH; + } + +} diff --git a/modules/quant_api/src/EventSubscriber/QuantApi.php b/modules/quant_api/src/EventSubscriber/QuantApi.php index 6afb934c..fab62880 100644 --- a/modules/quant_api/src/EventSubscriber/QuantApi.php +++ b/modules/quant_api/src/EventSubscriber/QuantApi.php @@ -15,6 +15,7 @@ use Drupal\quant\Utility; use Drupal\quant_api\Client\QuantClientInterface; use Drupal\quant_api\Exception\InvalidPayload; +use Drupal\quant_api\PushFailures; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -44,6 +45,13 @@ class QuantApi implements EventSubscriberInterface { */ protected $eventDispatcher; + /** + * The pages whose last push failed. + * + * @var \Drupal\quant_api\PushFailures + */ + protected $failures; + /** * QuantAPI event subscriber. * @@ -56,9 +64,12 @@ class QuantApi implements EventSubscriberInterface { * The logger channel factory. * @param \Symfony\Component\EventDispatcher\EventDispatcher $event_dispatcher * The event dispatcher. + * @param \Drupal\quant_api\PushFailures $failures + * The pages whose last push failed. */ - public function __construct(QuantClientInterface $client, LoggerChannelFactoryInterface $logger_factory, EventDispatcher $event_dispatcher) { + public function __construct(QuantClientInterface $client, LoggerChannelFactoryInterface $logger_factory, EventDispatcher $event_dispatcher, PushFailures $failures) { $this->client = $client; + $this->failures = $failures; $this->logger = $logger_factory->get('quant_api'); $this->eventDispatcher = $event_dispatcher; } @@ -104,8 +115,10 @@ public function onRedirect(QuantRedirectEvent $event) { } catch (\Exception $error) { $this->logger->error($error->getMessage()); + $this->failures->record((string) $source, 'redirect', $error); return; } + $this->failures->clear((string) $source); return $res; } @@ -155,8 +168,10 @@ public function onOutput(QuantEvent $event) { } catch (\Exception $error) { $this->logger->error($error->getMessage()); + $this->failures->record($path, 'publish', $error); return FALSE; } + $this->failures->clear($path); $queue_factory = QuantQueueFactory::getInstance(); $queue = $queue_factory->get('quant_seed_worker'); @@ -238,9 +253,14 @@ protected function pushAttachments(array $res, QueueInterface $queue) : array { protected function queuePaginationLinks(\DOMXPath $xpath, string $path, QueueInterface $queue) : void { $links_config = \Drupal::config('quant.settings')->get('xpath_selectors'); - foreach (explode(PHP_EOL, (string) $links_config) as $links_line) { + foreach (preg_split('/\R/', (string) $links_config) as $links_line) { + // A blank line or an invalid expression selects nothing. + $nodes = trim($links_line) === '' ? FALSE : @$xpath->query(trim($links_line)); + if ($nodes === FALSE) { + continue; + } /** @var \DOMElement $node */ - foreach ($xpath->query(trim($links_line)) as $node) { + foreach ($nodes as $node) { $original_href = $new_href = $node->getAttribute('href'); if ($original_href[0] === '?') { $new_href = strtok($path, '?') . $original_href; @@ -534,9 +554,14 @@ public function onMedia(QuantFileEvent $event) { // or the file silently never reaches Quant. if (!str_contains($error->getMessage(), 'MD5 already matches')) { $this->logger->error($error->getMessage()); + $this->failures->record($url, 'file', $error); + return; } + // Quant already has this file. + $this->failures->clear($url); return; } + $this->failures->clear($url); return $res; } @@ -553,11 +578,13 @@ public function onUnpublish(QuantEvent $event) { // Don't log it if it's a 404, since the content is unpublished. if (!str_contains($error->getMessage(), '404 Not Found') && !str_contains($error->getMessage(), 'Resource is already unpublished')) { $this->logger->error($error->getMessage()); + $this->failures->record($url, 'unpublish', $error); + return; } - return; } + $this->failures->clear($url); - return $res; + return $res ?? NULL; } } diff --git a/modules/quant_api/src/Form/ProjectSelectForm.php b/modules/quant_api/src/Form/ProjectSelectForm.php new file mode 100644 index 00000000..5bc72afe --- /dev/null +++ b/modules/quant_api/src/Form/ProjectSelectForm.php @@ -0,0 +1,179 @@ +get('quant_api.oauth'), + $container->get('quant_api.portal'), + $container->get('quant_api.oauth_store'), + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'quant_api_oauth_project'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $login = $this->tokenStore->getPendingLogin((int) $this->currentUser()->id()); + if ($login === NULL) { + return $this->expired($form); + } + $this->auth->usePlatform($login['platform'] ?? NULL); + $options = $this->projectOptions($login); + if ($options === []) { + $form['empty'] = [ + '#markup' => '

' . $this->t('Your Quant account has no projects in the organisations you approved. Create a project in the Quant dashboard, then log in again.') . '

', + ]; + return $form + $this->backLink(); + } + + $form['intro'] = [ + '#markup' => '

' . $this->t('Logged in as %user. Choose the Quant project that this site publishes to. This site can then reach only that project.', [ + '%user' => $login['user']['email'] ?: $login['user']['name'], + ]) . '

', + ]; + $choices = array_merge(...array_values(array_map('array_keys', $options))); + $form['project'] = [ + '#type' => 'select', + '#title' => $this->t('Project'), + '#options' => $options, + '#required' => TRUE, + '#default_value' => count($choices) === 1 ? $choices[0] : NULL, + ]; + $form['actions'] = ['#type' => 'actions']; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Use this project'), + '#button_type' => 'primary', + ]; + return $form + $this->backLink(); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $login = $this->tokenStore->getPendingLogin((int) $this->currentUser()->id()); + [$org, $project] = array_pad(explode(':', (string) $form_state->getValue('project'), 2), 2, ''); + if ($login === NULL || !isset($login['organizations'][$org]) || !PortalClient::isMachineName($project)) { + $form_state->setErrorByName('project', $this->t('The login expired. Log in to Quant again.')); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $uid = (int) $this->currentUser()->id(); + $login = $this->tokenStore->getPendingLogin($uid); + $this->auth->usePlatform($login['platform'] ?? NULL); + $platform = $this->auth->getPlatform(); + [$org, $project] = explode(':', (string) $form_state->getValue('project'), 2); + $access = (string) $login['tokens']['access_token']; + + $write_token = $this->portal->limitToProject($access, $org, $project) + ? $this->portal->fetchWriteToken($access, $org, $project) + : NULL; + // A limited login cannot choose another project, so it is never reused. + $this->tokenStore->deletePendingLogin($uid); + if ($write_token === NULL) { + // The new login is not kept, so it is ended at the dashboard too. + $this->auth->revokeToken($access); + $this->messenger()->addError($this->t('Quant did not give this site access to the project. Log in again, or connect with a project token.')); + $form_state->setRedirect('quant_api.settings_form'); + return; + } + + $this->auth->saveConnection($login['tokens'], $write_token, $login['user']); + $this->configFactory()->getEditable('quant_api.settings') + ->set('auth_method', Credentials::METHOD_OAUTH) + ->set('platform', $platform) + ->set('api_endpoint', Platform::apiEndpoint($platform)) + ->set('api_account', $org) + ->set('api_project', $project) + ->set('api_token', NULL) + ->save(); + \Drupal::service('router.builder')->setRebuildNeeded(); + + $this->messenger()->addStatus($this->t('This site now publishes to the Quant project %project.', ['%project' => $project])); + $this->getRequest()->getSession()->set(SettingsForm::REPORTED, TRUE); + $form_state->setRedirect('quant_api.settings_form'); + } + + /** + * The projects of each approved organisation, grouped by organisation. + * + * @return array + * Organisation name => ["org:project" => project name]. + */ + protected function projectOptions(array $login): array { + $options = []; + foreach ($login['organizations'] as $org => $org_name) { + foreach ($this->portal->listProjects((string) $login['tokens']['access_token'], (string) $org) ?? [] as $project) { + $options[$org_name][$org . ':' . $project['machine_name']] = $project['name']; + } + } + return $options; + } + + /** + * The form when no login waits for a project choice. + */ + protected function expired(array $form): array { + $form['expired'] = [ + '#markup' => '

' . $this->t('No login waits for a project choice. A login waits @minutes minutes. Log in to Quant again.', [ + '@minutes' => OAuthTokenStore::PENDING_TTL / 60, + ]) . '

', + ]; + return $form + $this->backLink(); + } + + /** + * A link back to the settings page. + */ + protected function backLink(): array { + return [ + 'back' => [ + '#type' => 'link', + '#title' => $this->t('Back to the Quant API settings'), + '#url' => Url::fromRoute('quant_api.settings_form'), + ], + ]; + } + +} diff --git a/modules/quant_api/src/Form/SettingsForm.php b/modules/quant_api/src/Form/SettingsForm.php index 674db799..409dfe81 100644 --- a/modules/quant_api/src/Form/SettingsForm.php +++ b/modules/quant_api/src/Form/SettingsForm.php @@ -4,17 +4,32 @@ use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\quant_api\Client\QuantClientInterface; +use Drupal\Core\Site\Settings; +use Drupal\Core\Url; +use Drupal\quant_api\Controller\OAuthController; +use Drupal\quant_api\Credentials; use Drupal\quant_api\EndpointValidator; +use Drupal\quant_api\OAuth\AuthService; +use Drupal\quant_api\OAuth\OAuthTokenStore; +use Drupal\quant_api\OAuth\Platform; use Symfony\Component\DependencyInjection\ContainerInterface; /** - * Quant configuration form. + * Quant API settings: the platform and the connection. * - * @see Drupal\Core\Form\ConfigFormBase + * Two ways to connect: + * - "Log in to Quant": the editor approves the site in the Quant dashboard + * and picks a project. The module stores and refreshes the tokens. + * - "Connect with a project token": the organisation, project and token + * from the dashboard, typed in or set in settings.php (CI, multi-domain). */ class SettingsForm extends ConfigFormBase { + /** + * Session flag: the connection was just reported, so no second message. + */ + public const REPORTED = 'quant_api.connection_reported'; + const SETTINGS = 'quant_api.settings'; /** @@ -25,19 +40,36 @@ class SettingsForm extends ConfigFormBase { protected $client; /** - * Build the form. + * The credentials resolver. + * + * @var \Drupal\quant_api\Credentials */ - public function __construct(QuantClientInterface $client) { - $this->client = $client; - } + protected $credentials; + + /** + * The OAuth service. + * + * @var \Drupal\quant_api\OAuth\AuthService + */ + protected $auth; + + /** + * The OAuth token store. + * + * @var \Drupal\quant_api\OAuth\OAuthTokenStore + */ + protected $tokenStore; /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { - return new static( - $container->get('quant_api.client') - ); + $instance = parent::create($container); + $instance->client = $container->get('quant_api.client'); + $instance->credentials = $container->get('quant_api.credentials'); + $instance->auth = $container->get('quant_api.oauth'); + $instance->tokenStore = $container->get('quant_api.oauth_store'); + return $instance; } /** @@ -51,9 +83,7 @@ public function getFormId() { * {@inheritdoc} */ public function getEditableConfigNames() { - return [ - self::SETTINGS, - ]; + return [self::SETTINGS]; } /** @@ -61,115 +91,352 @@ public function getEditableConfigNames() { */ public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config(self::SETTINGS); + // Only on a page view: a build during a submit runs before the submit + // handler, so its message would show stale after the redirect. + if ($this->getRequest()->isMethod('GET')) { + $this->showStatus(); + } - if ($config->get('api_token')) { - if ($project = $this->client->ping()) { - $message = $this->t('QuantAPI status: Successfully connected to project @project', ['@project' => $config->get('api_project')]); - \Drupal::messenger()->addMessage($message); - } - else { - \Drupal::messenger()->addError($this->t('QuantAPI error: Unable to connect to the Quant API please check the endpoint on the Integrations page in the Quant dashboard.')); - } + $form['platform'] = [ + '#type' => 'radios', + '#title' => $this->t('Platform'), + '#options' => [ + Platform::QUANTCDN => $this->t('QuantCDN'), + Platform::QUANTGOV => $this->t('QuantGov (Australian government)'), + ], + '#default_value' => $this->defaultPlatform($config->get('platform'), (string) $config->get('api_endpoint')), + '#description' => $this->t('Your Quant account is on one platform. QuantGov accounts sign in at dash.quantgov.cloud.'), + ]; + + $form['login'] = $this->buildLogin($config); + $form['token'] = $this->buildTokenConnect($config); + $form['advanced'] = $this->buildAdvanced($config); + $this->showOverrides($form); + + return parent::buildForm($form, $form_state); + } + + /** + * The "Log in to Quant" section. + */ + protected function buildLogin($config): array { + $section = [ + '#type' => 'fieldset', + '#title' => $this->t('Log in to Quant'), + ]; + if ($this->credentials->usesOauth($config) && $this->tokenStore->isConnected()) { + return $section + $this->buildConnected($config); } + $section['help'] = [ + '#markup' => '

' . $this->t('Approve this site in the Quant dashboard and choose its project. You do not copy a token.') . '

', + ]; + $multi_domain = $this->credentials->hasDomainOverrides(); + $section['login'] = [ + '#type' => 'submit', + '#value' => $this->t('Log in to Quant'), + '#submit' => ['::submitLogin'], + '#limit_validation_errors' => [['platform'], ['api_endpoint']], + '#disabled' => $multi_domain, + '#button_type' => 'primary', + ]; + if ($multi_domain) { + $section['multi_domain'] = [ + '#markup' => '

' . $this->t('This site publishes several domains to several projects. Log in to Quant supports one project per site for now, so connect each domain with a project token.') . '

', + ]; + } + return $section; + } - $form['api_endpoint'] = [ - '#type' => 'textfield', - '#title' => $this->t('API Endpoint'), - '#description' => $this->t('The fully-qualified domain name for the API endpoint, e.g. https://api.quantcdn.io or the QuantGov endpoint from the Integrations page. Use https and do not add /v1; the module adds it.'), - '#default_value' => $config->get('api_endpoint') ?: 'https://api.quantcdn.io', - '#required' => TRUE, + /** + * The status and the actions of a working OAuth connection. + */ + protected function buildConnected($config): array { + $user = (array) $this->tokenStore->get(OAuthTokenStore::USER); + $items['status'] = [ + '#markup' => '

' . $this->t('Logged in as %user. This site publishes to the project %project in the organisation %org, and it can reach only that project.', [ + '%user' => ($user['email'] ?? '') ?: ($user['name'] ?? '-'), + '%project' => $config->get('api_project'), + '%org' => $config->get('api_account'), + ]) . '

', + ]; + // A connection that needs a new login is shown as the page message. + $reason = $this->auth->getReconnectReason(); + if ($reason === AuthService::RECONNECT_OTHER_SITE) { + $items['same_site'] = [ + '#type' => 'submit', + '#value' => $this->t('This is the same site'), + '#submit' => ['::submitSameSite'], + '#limit_validation_errors' => [], + ]; + } + $items['change'] = [ + '#type' => 'link', + '#title' => $reason === NULL ? $this->t('Change project') : $this->t('Log in to Quant again'), + '#url' => Url::fromRoute('quant_api.oauth_connect'), + '#attributes' => ['class' => ['button', 'button--primary']], + ]; + $items['logout'] = [ + '#type' => 'link', + '#title' => $this->t('Log out of Quant'), + '#url' => Url::fromRoute('quant_api.oauth_disconnect'), + '#attributes' => ['class' => ['button', 'button--danger']], ]; + $items['change_help'] = [ + '#markup' => '

' . $this->t('Change project starts a new login. The current project stays connected until you choose the new one.') . '

', + ]; + return $items; + } + /** + * The message for a connection that needs a new login. + */ + protected function reconnectMessage(string $reason) { + return match ($reason) { + AuthService::RECONNECT_OTHER_SITE => $this->t('This site looks like a copy of the connected site (for example a database copied from production), so it does not publish or refresh with the stored login. Log in to Quant on this copy. If it is the same site, confirm it below.'), + AuthService::RECONNECT_REFUSED => $this->t('Quant refused to refresh the login. Content still publishes. Log in to Quant again to keep the connection current.'), + default => $this->t('The login expired. Content still publishes. Log in to Quant again to keep the connection current.'), + }; + } + + /** + * The "Connect with a project token" section. + */ + protected function buildTokenConnect($config): array { + $oauth = $this->credentials->usesOauth($config); + $section = [ + '#type' => 'details', + '#title' => $this->t('Connect with a project token'), + '#open' => !$oauth, + '#description' => $oauth + ? $this->t('Saving a token here logs this site out of Quant and uses the token instead.') + : $this->t('For CI, multi-domain sites and existing sites. Copy the values from the Integrations page in the Quant dashboard.'), + ]; // @todo Switch from 'api_account' to 'api_organization'. - $form['api_account'] = [ + $section['api_account'] = [ '#type' => 'textfield', '#title' => $this->t('API Organization'), '#description' => $this->t('The API organization. This is shown on the Integrations page and with the account information.'), '#default_value' => $config->get('api_account'), - '#required' => TRUE, + '#parents' => ['api_account'], ]; - - $form['api_project'] = [ + $section['api_project'] = [ '#type' => 'textfield', '#title' => $this->t('API Project'), '#description' => $this->t('The API project. This is the "API name" shown on the Projects page and the "Project" shown on the Integrations page. Note, this value may be different than the human-readable project name.'), '#default_value' => $config->get('api_project'), - '#required' => TRUE, + '#parents' => ['api_project'], ]; - - $form['api_token'] = [ + $section['api_token'] = [ '#type' => 'password', '#title' => $this->t('API Token'), - '#description' => $this->t('The API token. Use the clipboard icon in the dashboard to copy the token from the Projects or Integrations page. Be careful with this information. It should be treated like any system password.'), - '#default_value' => $config->get('api_token'), - '#required' => TRUE, + '#description' => $this->t('The API token. Use the clipboard icon in the dashboard to copy the token from the Projects or Integrations page. Treat it like a password. Leave it empty to keep the saved token.'), + '#parents' => ['api_token'], ]; + return $section; + } - $form['api_tls_disabled'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Disable TLS verification'), - '#description' => $this->t('You can optionally disable TLS verification for all Quant API requests. This is not recommended, but may be necessary in some configurations. For example, old web servers may have issues validating modern TSL/SSL certificates.'), - '#default_value' => $config->get('api_tls_disabled') ?? FALSE, + /** + * The advanced section: a custom endpoint and TLS. + */ + protected function buildAdvanced($config): array { + $oauth = $this->credentials->usesOauth($config); + $override = Credentials::endpointOverride() !== ''; + return [ + '#type' => 'details', + '#title' => $this->t('Advanced'), + 'api_endpoint' => [ + '#type' => 'textfield', + '#title' => $this->t('API Endpoint'), + '#description' => $this->t('Leave the platform default unless Quant support gives you another endpoint. Use https and do not add /v1; the module adds it.'), + '#default_value' => Credentials::redact($this->credentials->getEndpoint($config)) ?: Platform::apiEndpoint(Platform::QUANTCDN), + '#parents' => ['api_endpoint'], + // A login publishes only to the platform's own host, unless + // settings.php names another endpoint. + '#disabled' => $oauth || $override, + '#field_suffix' => $override + ? $this->t("Set in settings.php (\$settings['quant_api_endpoint_url']).") + : ($oauth ? $this->t('Fixed to the platform while this site is logged in to Quant.') : NULL), + ], + 'api_tls_disabled' => [ + '#type' => 'checkbox', + '#title' => $this->t('Disable TLS verification'), + '#description' => $this->t('You can optionally disable TLS verification for all Quant API requests. This is not recommended, but may be necessary in some configurations. For example, old web servers may have issues validating modern TSL/SSL certificates.'), + '#default_value' => $config->get('api_tls_disabled') ?? FALSE, + '#parents' => ['api_tls_disabled'], + ], ]; + } - // API values might be overridden in the settings file. + /** + * Shows the connection status as a message. + */ + protected function showStatus(): void { + if ($this->credentials->usesOauth() && $this->credentials->isCopiedSite()) { + $this->messenger()->addError($this->t('This site looks like a copy of the connected site (different: @components), so nothing is published. Log in to Quant again, or, if this is the same site (for example after a database move), click "This is the same site".', [ + '@components' => implode(', ', $this->credentials->copyDifferences()), + ])); + return; + } + if (!$this->credentials->hasToken()) { + return; + } + $reason = $this->credentials->usesOauth() ? $this->auth->getReconnectReason() : NULL; + if ($reason !== NULL) { + $this->messenger()->addWarning($this->reconnectMessage($reason)); + return; + } + if ($this->client->ping()) { + // After a project pick or a confirm, that message already said so. + if ($this->getRequest()->getSession()->remove(self::REPORTED)) { + return; + } + $this->messenger()->addStatus($this->t('Connected to the Quant project %project.', ['%project' => $this->client->getProject()])); + return; + } + // The client has already said why when Quant could not be reached. + if (!$this->client->lastCallUnreachable()) { + $this->messenger()->addError($this->t('The Quant API did not accept the connection. Check the platform and the project, or log in to Quant again.')); + } + } + + /** + * Marks the values that settings.php overrides. + */ + protected function showOverrides(array &$form): void { $overrides = $this->client->getOverrides(); foreach ($overrides as $key => $value) { - if ($key === 'api_token') { - // Don't show the token in the UI. - $message = $this->t('QuantAPI override: api_token has been overridden in the settings file.'); + if ($key === 'api_endpoint') { + $value = Credentials::redact((string) $value); } - else { - $message = $this->t('QuantAPI override: @key has been overridden in the settings file with @value.', - [ - '@key' => $key, - '@value' => $value, - ]); - } - - // Show warning and add to description. - \Drupal::messenger()->addWarning($message); - $form[$key]['#description'] = $form[$key]['#description'] . ' ' . $message . ''; + $message = $key === 'api_token' + ? $this->t('QuantAPI override: api_token has been overridden in the settings file.') + : $this->t('QuantAPI override: @key has been overridden in the settings file with @value.', [ + '@key' => $key, + '@value' => $value, + ]); + $this->messenger()->addWarning($message); } - - // Show error if not using TSL verification. - if ((isset($overrides['api_tls_disabled']) && $overrides['api_tls_disabled']) || $config->get('api_tls_disabled')) { - \Drupal::messenger()->addError($this->t('DANGER ZONE: TLS verification is disabled for Quant API connections. It is highly recommended that you update your server configuration to handle TLS rather than disabling TLS verification.')); + if (!empty($overrides['api_tls_disabled']) || $this->config(self::SETTINGS)->get('api_tls_disabled')) { + $this->messenger()->addError($this->t('DANGER ZONE: TLS verification is disabled for Quant API connections. It is highly recommended that you update your server configuration to handle TLS rather than disabling TLS verification.')); } - - return parent::buildForm($form, $form_state); } /** * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state) { + $this->validateEndpoint($form_state); + if (in_array('::submitLogin', $form_state->getTriggeringElement()['#submit'] ?? [], TRUE)) { + // The login button validates only the platform and the endpoint. + return; + } + $this->validateTokenConnect($form_state); + parent::validateForm($form, $form_state); + } + + /** + * Validates the endpoint (empty means the platform default). + */ + protected function validateEndpoint(FormStateInterface $form_state): void { + $endpoint = $this->resolveEndpoint($form_state); $messages = [ 'not_https' => $this->t('The API endpoint must be an https URL, for example https://api.quantcdn.io.'), 'not_origin' => $this->t('The API endpoint must contain only the scheme and host, for example https://api.quantcdn.io.'), 'has_v1' => $this->t('Remove /v1 from the API endpoint. The module adds it.'), ]; - $error = EndpointValidator::validate($form_state->getValue('api_endpoint')); + $error = EndpointValidator::validate($endpoint); if ($error !== NULL) { $form_state->setErrorByName('api_endpoint', $messages[$error]); } - parent::validateForm($form, $form_state); + } + + /** + * Requires the organisation, project and token for token connect. + */ + protected function validateTokenConnect(FormStateInterface $form_state): void { + $config = $this->config(self::SETTINGS); + $new_token = (string) $form_state->getValue('api_token') !== ''; + $token_connect = !$this->credentials->usesOauth($config) || $new_token; + if (!$token_connect) { + return; + } + foreach (['api_account' => $this->t('API Organization'), 'api_project' => $this->t('API Project')] as $key => $label) { + if (trim((string) $form_state->getValue($key)) === '') { + $form_state->setErrorByName($key, $this->t('@field is required to connect with a project token.', ['@field' => $label])); + } + } + $has_saved = (string) $config->get('api_token') !== '' && !$this->credentials->usesOauth($config); + if (!$new_token && !$has_saved && !$config->hasOverrides('api_token')) { + $form_state->setErrorByName('api_token', $this->t('API Token is required to connect with a project token.')); + } + } + + /** + * The endpoint to save: the platform default unless a custom one is set. + * + * A value that equals the default of any platform follows the chosen + * platform, so switching the platform switches the endpoint. + */ + protected function resolveEndpoint(FormStateInterface $form_state): string { + if (Credentials::endpointOverride() !== '') { + // settings.php decides; the stored value stays as it is. + return (string) ($this->config(self::SETTINGS)->get('api_endpoint') ?: Platform::apiEndpoint(Platform::QUANTCDN)); + } + $platform = (string) $form_state->getValue('platform'); + $platform = Platform::isKnown($platform) ? $platform : Platform::QUANTCDN; + $endpoint = EndpointValidator::normalise($form_state->getValue('api_endpoint')); + $defaults = array_map([Platform::class, 'apiEndpoint'], Platform::ids()); + return ($endpoint === '' || in_array($endpoint, $defaults, TRUE)) ? Platform::apiEndpoint($platform) : $endpoint; + } + + /** + * Confirms that this is the connected site, so it publishes again. + */ + public function submitSameSite(array &$form, FormStateInterface $form_state) { + $differences = $this->credentials->copyDifferences(); + $this->credentials->confirmSameSite(); + $this->logger('quant_api')->notice('Quant: @user confirmed that this is the connected site (was different: @components).', [ + '@user' => $this->currentUser()->getAccountName(), + '@components' => implode(', ', $differences), + ]); + $this->messenger()->addStatus($this->t('Confirmed. This site publishes with its Quant login again.')); + $this->getRequest()->getSession()->set(self::REPORTED, TRUE); + } + + /** + * Saves the platform and starts the login. + */ + public function submitLogin(array &$form, FormStateInterface $form_state) { + // Saved only when the login completes (ProjectSelectForm), so a cancelled + // login never re-points a working connection. + $platform = (string) $form_state->getValue('platform'); + $this->getRequest()->getSession()->set(OAuthController::PLATFORM_KEY, Platform::isKnown($platform) ? $platform : Platform::QUANTCDN); + // A redirect is not rendered, so the CSRF placeholder of a generated URL + // is never replaced. Add the real token for the route's path. + $url = Url::fromRoute('quant_api.oauth_connect'); + $url->setOption('query', ['token' => \Drupal::csrfToken()->get($url->getInternalPath())]); + $form_state->setRedirectUrl($url); } /** * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + $this->logOutOnPlatformChange($form_state); + $settings = $this->savePlatform($form_state) + ->set('api_tls_disabled', (bool) $form_state->getValue('api_tls_disabled')); - // Retrieve the configuration. - // @todo Switch from 'api_account' to 'api_organization'. - $this->configFactory->getEditable(self::SETTINGS) - ->set('api_endpoint', EndpointValidator::normalise($form_state->getValue('api_endpoint'))) - ->set('api_token', $form_state->getValue('api_token')) - ->set('api_project', $form_state->getValue('api_project')) - ->set('api_account', $form_state->getValue('api_account')) - ->set('api_tls_disabled', $form_state->getValue('api_tls_disabled')) - ->save(); + $new_token = (string) $form_state->getValue('api_token'); + if ($new_token !== '' || !$this->credentials->usesOauth($settings)) { + $settings->set('api_project', trim((string) $form_state->getValue('api_project'))) + ->set('api_account', trim((string) $form_state->getValue('api_account'))); + } + if ($new_token !== '') { + // A typed token replaces a Quant login. + $this->auth->disconnect(); + $settings->set('auth_method', Credentials::METHOD_TOKEN)->set('api_token', $new_token); + } + $settings->save(); // Clear router cache in case search has been enabled or disabled. // @todo Need to clear any search autocomplete blocks in case of config change. @@ -178,4 +445,51 @@ public function submitForm(array &$form, FormStateInterface $form_state) { parent::submitForm($form, $form_state); } + /** + * Logs out when the platform changes: the login belongs to the old one. + */ + protected function logOutOnPlatformChange(FormStateInterface $form_state): void { + $config = $this->config(self::SETTINGS); + if (!$this->credentials->usesOauth($config) || $config->get('platform') === $form_state->getValue('platform')) { + return; + } + $this->auth->disconnect(); + $this->configFactory()->getEditable(self::SETTINGS)->set('auth_method', Credentials::METHOD_TOKEN)->save(); + $this->messenger()->addWarning($this->t('The platform changed, so this site is logged out of Quant. Log in to Quant on the new platform.')); + } + + /** + * The editable settings with the platform and endpoint set. + * + * @return \Drupal\Core\Config\Config + * The settings, not yet saved. + */ + protected function savePlatform(FormStateInterface $form_state) { + $platform = (string) $form_state->getValue('platform'); + $platform = Platform::isKnown($platform) ? $platform : Platform::QUANTCDN; + $oauth = $this->credentials->usesOauth($this->config(self::SETTINGS)); + return $this->configFactory()->getEditable(self::SETTINGS) + ->set('platform', $platform) + ->set('api_endpoint', $oauth ? Platform::apiEndpoint($platform) : $this->resolveEndpoint($form_state)); + } + + /** + * The platform the form starts on. + * + * A dashboard fixed in settings.php decides it, because logins go there + * whatever the editor picks. Otherwise the saved platform, then the endpoint. + */ + protected function defaultPlatform(?string $saved, string $endpoint): string { + // A site that is logged in shows the platform of its login, so a save for + // another reason never reads as a platform change (and a log out). + if (Platform::isKnown($saved) && $this->credentials->usesOauth()) { + return $saved; + } + $dashboard = (string) Settings::get('quant_api_dashboard_url', ''); + if ($dashboard !== '') { + return Platform::fromEndpoint($dashboard); + } + return Platform::isKnown($saved) ? $saved : Platform::fromEndpoint($endpoint); + } + } diff --git a/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php b/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php new file mode 100644 index 00000000..52f82d88 --- /dev/null +++ b/modules/quant_api/src/Http/EndpointUserInfoMiddleware.php @@ -0,0 +1,33 @@ +getUri()->getUserInfo(); + if ($userinfo !== '') { + $request = $request + ->withUri($request->getUri()->withUserInfo('')) + ->withHeader('Authorization', 'Basic ' . base64_encode(rawurldecode($userinfo))); + } + return $handler($request, $options); + }; + }; + } + +} diff --git a/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php b/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php new file mode 100644 index 00000000..996c74aa --- /dev/null +++ b/modules/quant_api/src/Http/UnauthorizedResyncMiddleware.php @@ -0,0 +1,35 @@ +then(function (ResponseInterface $response) use ($request) { + if ($response->getStatusCode() === 401 && $request->hasHeader('Quant-Token')) { + // Lazy: the sync service itself uses the HTTP client. + \Drupal::service('quant_api.write_token_sync')->syncAfterUnauthorized(); + } + return $response; + }); + }; + }; + } + +} diff --git a/modules/quant_api/src/OAuth/AuthService.php b/modules/quant_api/src/OAuth/AuthService.php new file mode 100644 index 00000000..1f7bb972 --- /dev/null +++ b/modules/quant_api/src/OAuth/AuthService.php @@ -0,0 +1,617 @@ +logger = $logger_factory->get('quant_api'); + } + + /** + * The platform the site is configured for. + */ + public function getPlatform(): string { + $platform = $this->platformOverride ?? (string) $this->configFactory->get('quant_api.settings')->get('platform'); + return Platform::isKnown($platform) ? $platform : Platform::QUANTCDN; + } + + /** + * Uses a platform for this request only: a login in progress. + * + * The site's platform and endpoint change only when the login completes, + * so a cancelled login never re-points a working connection. + */ + public function usePlatform(?string $platform): void { + $this->platformOverride = Platform::isKnown($platform) ? $platform : NULL; + } + + /** + * The dashboard (OAuth server) of the configured platform. + */ + public function getDashboardUrl(): string { + return Platform::dashboardUrl($this->getPlatform()); + } + + /** + * The dashboard for server-to-server calls. + */ + public function getInternalDashboardUrl(): string { + return Platform::internalDashboardUrl($this->getPlatform()); + } + + /** + * A new PKCE code verifier: 32 random bytes, base64url. + */ + public function generatePkceVerifier(): string { + return self::base64Url(random_bytes(32)); + } + + /** + * The S256 code challenge of a verifier. + */ + public function computePkceChallenge(string $verifier): string { + return self::base64Url(hash('sha256', $verifier, TRUE)); + } + + /** + * The dashboard URL that starts the login. + */ + public function getAuthorizationUrl(string $state, string $redirect_uri, string $code_challenge): string { + return $this->getDashboardUrl() . '/oauth/authorize?' . http_build_query([ + 'client_id' => Platform::CLIENT_ID, + 'response_type' => 'code', + 'redirect_uri' => $redirect_uri, + 'state' => $state, + 'scope' => 'full_access', + 'code_challenge' => $code_challenge, + 'code_challenge_method' => 'S256', + ], '', '&', PHP_QUERY_RFC3986); + } + + /** + * Exchanges an authorization code for tokens. + * + * @return array|null + * The token response, or NULL on any failure (logged without values). + */ + public function exchangeCode(string $code, string $redirect_uri, string $code_verifier): ?array { + try { + $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/oauth/token', [ + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'client_id' => Platform::CLIENT_ID, + 'code' => $code, + 'redirect_uri' => $redirect_uri, + 'code_verifier' => $code_verifier, + ], + 'headers' => ['Accept' => 'application/json'], + 'timeout' => self::REFRESH_HTTP_TIMEOUT, + 'http_errors' => FALSE, + ]); + } + catch (\Exception $e) { + $this->logger->error('The Quant login could not exchange the code (@class).', ['@class' => get_class($e)]); + return NULL; + } + $data = json_decode((string) $response->getBody(), TRUE); + if ($response->getStatusCode() !== 200 || empty($data['access_token'])) { + $this->logger->error('The Quant dashboard refused the login code with status @status (@error).', [ + '@status' => $response->getStatusCode(), + '@error' => is_array($data) ? (string) ($data['error'] ?? 'none') : 'none', + ]); + return NULL; + } + return $data; + } + + /** + * Makes a login limited to one project the site's connection. + * + * @param array $tokens + * The token response of the login. + * @param string $write_token + * The project write token. + * @param array $user + * The user name and email, for display only. + */ + public function saveConnection(array $tokens, string $write_token, array $user): void { + // The login this one replaces (Change project) is ended, not orphaned. + $this->revokeStoredLogin(); + $this->tokenStore->clear(); + $this->tokenStore->saveTokens($tokens); + $this->tokenStore->setMany([ + OAuthTokenStore::WRITE_TOKEN => $write_token, + OAuthTokenStore::USER => array_intersect_key($user, array_flip(['name', 'email'])), + OAuthTokenStore::FINGERPRINT => $this->fingerprint->compute(), + ]); + } + + /** + * Ends the login in the dashboard, then deletes the local connection. + * + * The dashboard call is best effort: an older dashboard without the revoke + * endpoint, or one that cannot be reached, leaves the token to expire on + * its own. The local connection is deleted in every case. + */ + public function disconnect(): void { + $this->revokeStoredLogin(); + $this->tokenStore->clear(); + } + + /** + * Ends the stored login at the dashboard, if it belongs to this site. + * + * A copy of another site's database (for example staging from production) + * holds the original site's login. Revoking it would log the original site + * out, so a copy only forgets it locally. + */ + protected function revokeStoredLogin(): void { + $record = $this->tokenStore->getAll(); + $access = $record[OAuthTokenStore::ACCESS_TOKEN] ?? NULL; + if (!is_string($access) || $access === '' || $this->isRecordFromAnotherSite($record)) { + return; + } + $this->revokeToken($access); + } + + /** + * Asks the dashboard to delete one token (POST /api/oauth/revoke). + * + * For a token this site holds or has just received. Best effort. + */ + public function revokeToken(string $access_token): void { + try { + $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/api/oauth/revoke', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Accept' => 'application/json', + ], + 'timeout' => 10, + 'http_errors' => FALSE, + ]); + $status = $response->getStatusCode(); + } + catch (\Exception $e) { + $status = get_class($e); + } + if ($status !== 200) { + $this->logger->notice('The Quant dashboard did not end the login (@status). The token stops working when it expires.', ['@status' => $status]); + } + } + + /** + * The access token, refreshed when it is about to expire. + * + * @return string|null + * The access token, or NULL when no usable token can be obtained. + */ + public function getValidAccessToken(): ?string { + $record = $this->tokenStore->getAll(); + if (empty($record[OAuthTokenStore::ACCESS_TOKEN])) { + return NULL; + } + $decision = $this->getRefreshDecision($record, self::EXPIRY_MARGIN, FALSE); + if ($decision === 'none') { + return $record[OAuthTokenStore::ACCESS_TOKEN]; + } + if ($decision === 'defer') { + return $this->getUsableAccessToken($record); + } + return $this->refreshIfAllowed($record, self::EXPIRY_MARGIN); + } + + /** + * Refreshes from cron 30 days before expiry, or at half a short lifetime. + * + * @return bool + * TRUE when the tokens were rotated. + */ + public function refreshFromCron(): bool { + $record = $this->tokenStore->getAll(); + if (empty($record[OAuthTokenStore::REFRESH_TOKEN])) { + return FALSE; + } + $margin = $this->getCronMargin($record); + if ($this->getRefreshDecision($record, $margin, TRUE) !== 'refresh') { + return FALSE; + } + $this->refreshIfAllowed($record, $margin); + return $this->tokenStore->get(OAuthTokenStore::ACCESS_TOKEN) !== $record[OAuthTokenStore::ACCESS_TOKEN]; + } + + /** + * Why the editor must log in again, or NULL when the connection works. + * + * @return string|null + * One of the RECONNECT_* constants, or NULL. + */ + public function getReconnectReason(): ?string { + $record = $this->tokenStore->getAll(); + if (empty($record[OAuthTokenStore::ACCESS_TOKEN])) { + return NULL; + } + if ($this->isRecordFromAnotherSite($record)) { + return self::RECONNECT_OTHER_SITE; + } + if (!empty($record[OAuthTokenStore::REFUSAL])) { + return self::RECONNECT_REFUSED; + } + // An expired access token is renewed on the next request while a + // refresh token is stored; only without one is a new login needed. + return empty($record[OAuthTokenStore::REFRESH_TOKEN]) && $this->isExpiring($record, 0) ? self::RECONNECT_EXPIRED : NULL; + } + + /** + * Decides whether to refresh now: 'refresh', 'none' or 'defer'. + */ + protected function getRefreshDecision(array $record, int $margin, bool $cron): string { + $pending = $this->getPendingState($record); + if ($pending !== 'none') { + return $pending === 'retry' ? 'refresh' : 'defer'; + } + if (!$this->isExpiring($record, $margin)) { + return 'none'; + } + $name = $cron ? OAuthTokenStore::CRON_RETRY_AFTER : OAuthTokenStore::RETRY_AFTER; + return (int) ($record[$name] ?? 0) > time() ? 'defer' : 'refresh'; + } + + /** + * The state of a refresh with no answer: 'none', 'wait' or 'retry'. + */ + protected function getPendingState(array $record): string { + $pending = $this->getCurrentPending($record); + if ($pending === NULL) { + return 'none'; + } + $age = time() - (int) ($pending['since'] ?? 0); + if ($age < self::PENDING_MIN_AGE || (int) ($pending['attempts'] ?? 1) > self::PENDING_MAX_RETRIES) { + return 'wait'; + } + return 'retry'; + } + + /** + * The pending marker, when it is for the stored refresh token and recent. + */ + protected function getCurrentPending(array $record): ?array { + $pending = $record[OAuthTokenStore::PENDING] ?? NULL; + $refresh = (string) ($record[OAuthTokenStore::REFRESH_TOKEN] ?? ''); + if (!is_array($pending) || $refresh === '' || (int) ($pending['since'] ?? 0) < time() - self::PENDING_WINDOW) { + return NULL; + } + return hash_equals((string) ($pending['token'] ?? ''), hash('sha256', $refresh)) ? $pending : NULL; + } + + /** + * The cron margin: 30 days, or half the lifetime of a shorter token. + */ + protected function getCronMargin(array $record): int { + $lifetime = (int) ($record[OAuthTokenStore::LIFETIME] ?? 0); + return $lifetime > 0 ? min(self::CRON_MARGIN, intdiv($lifetime, 2)) : self::CRON_MARGIN; + } + + /** + * Refreshes under the lock unless a blocker or a transaction prevents it. + */ + protected function refreshIfAllowed(array $record, int $margin): ?string { + if ($this->getRefreshBlocker($record) !== NULL || $this->database->inTransaction()) { + // In a transaction the lock row is not visible to other processes and + // a rollback would discard the rotated pair. Cron refreshes later. + return $this->getUsableAccessToken($record); + } + $seen_refresh = (string) $record[OAuthTokenStore::REFRESH_TOKEN]; + if (!$this->acquireRefreshLock($margin)) { + return $this->getUsableAccessToken($this->tokenStore->getAll()); + } + try { + return $this->refreshUnderLock($seen_refresh, $margin); + } + finally { + $this->lock->release(self::REFRESH_LOCK_ID); + } + } + + /** + * Why a refresh must not be sent, or NULL. + */ + protected function getRefreshBlocker(array $record): ?string { + if (empty($record[OAuthTokenStore::REFRESH_TOKEN])) { + return self::RECONNECT_EXPIRED; + } + if ($this->isRecordFromAnotherSite($record)) { + return self::RECONNECT_OTHER_SITE; + } + return empty($record[OAuthTokenStore::REFUSAL]) ? NULL : self::RECONNECT_REFUSED; + } + + /** + * Takes the refresh lock, or waits for a peer that has it. + */ + protected function acquireRefreshLock(int $margin): bool { + if ($this->lock->acquire(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_TIMEOUT)) { + return TRUE; + } + $this->lock->wait(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_WAIT); + if (!$this->isExpiring($this->tokenStore->getAll(), $margin)) { + return FALSE; + } + return $this->lock->acquire(self::REFRESH_LOCK_ID, self::REFRESH_LOCK_TIMEOUT); + } + + /** + * Re-reads the store under the lock and refreshes if still needed. + */ + protected function refreshUnderLock(string $seen_refresh, int $margin): ?string { + $record = $this->tokenStore->getAll(); + if (!$this->isRefreshStillNeeded($seen_refresh, $record, $margin)) { + return $this->getUsableAccessToken($record); + } + $result = $this->sendRefresh((string) $record[OAuthTokenStore::REFRESH_TOKEN]); + if ($result['tokens']) { + return $this->saveRotatedTokens($result['tokens'], $record); + } + if ($result['outcome'] === 'refused') { + $this->recordRefusal($result['error']); + } + return $this->getUsableAccessToken($this->tokenStore->getAll()); + } + + /** + * Whether the refresh is still needed after the lock was taken. + */ + protected function isRefreshStillNeeded(string $seen_refresh, array $record, int $margin): bool { + $refresh = (string) ($record[OAuthTokenStore::REFRESH_TOKEN] ?? ''); + if ($refresh === '' || !hash_equals($seen_refresh, $refresh) || $this->getRefreshBlocker($record) !== NULL) { + return FALSE; + } + $pending = $this->getPendingState($record); + if ($pending !== 'none') { + return $pending === 'retry'; + } + return $this->isExpiring($record, $margin); + } + + /** + * Sends the refresh grant once, with the pending marker and back-off. + * + * @return array + * tokens (or NULL), outcome ('ok', 'rotated', 'refused', 'unknown' or + * 'failed') and error. + */ + protected function sendRefresh(string $refresh_token): array { + $this->keepRunning(); + $previous = $this->getCurrentPending([ + OAuthTokenStore::PENDING => $this->tokenStore->get(OAuthTokenStore::PENDING), + OAuthTokenStore::REFRESH_TOKEN => $refresh_token, + ]); + $this->tokenStore->set(OAuthTokenStore::PENDING, [ + 'since' => $previous ? (int) $previous['since'] : time(), + 'token' => hash('sha256', $refresh_token), + 'attempts' => $previous ? (int) ($previous['attempts'] ?? 1) + 1 : 1, + ]); + $result = $this->requestRefresh($refresh_token); + if (in_array($result['outcome'], ['refused', 'failed'], TRUE)) { + $this->tokenStore->delete([OAuthTokenStore::PENDING]); + } + if (in_array($result['outcome'], ['unknown', 'rotated'], TRUE)) { + $this->tokenStore->setMany([ + OAuthTokenStore::RETRY_AFTER => time() + self::REQUEST_BACKOFF, + OAuthTokenStore::CRON_RETRY_AFTER => time() + self::CRON_BACKOFF, + ]); + } + return $result; + } + + /** + * Posts the refresh grant and classifies the answer. + */ + protected function requestRefresh(string $refresh_token): array { + try { + $response = $this->httpClient->request('POST', $this->getInternalDashboardUrl() . '/oauth/token', [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refresh_token, + 'client_id' => Platform::CLIENT_ID, + ], + 'headers' => ['Accept' => 'application/json'], + 'timeout' => self::REFRESH_HTTP_TIMEOUT, + 'http_errors' => FALSE, + ]); + } + catch (\Exception $e) { + // A timeout can come after the portal committed the rotation. + $this->logger->error('The Quant token refresh got no answer (@class). The stored tokens are kept.', ['@class' => get_class($e)]); + return ['tokens' => NULL, 'outcome' => 'unknown', 'error' => '']; + } + return $this->parseRefreshResponse($response); + } + + /** + * Turns a refresh response into a result array. + */ + protected function parseRefreshResponse(ResponseInterface $response): array { + $status = $response->getStatusCode(); + $data = json_decode((string) $response->getBody(), TRUE); + $data = is_array($data) ? $data : []; + if ($status === 200 && !empty($data['access_token'])) { + return ['tokens' => $data, 'outcome' => 'ok', 'error' => '']; + } + $error = (string) ($data['error'] ?? ''); + $description = mb_substr((string) ($data['error_description'] ?? ''), 0, 200); + $outcome = self::classifyRefreshError($status, $error, $description); + if ($outcome !== 'refused') { + $this->logger->log($outcome === 'rotated' ? LogLevel::NOTICE : LogLevel::ERROR, 'The Quant token refresh was rejected with status @status (@error). The stored tokens are kept.', [ + '@status' => $status, + '@error' => $error ?: 'none', + ]); + } + return ['tokens' => NULL, 'outcome' => $outcome, 'error' => $error]; + } + + /** + * Classifies a refresh error: 'rotated', 'refused', 'unknown' or 'failed'. + */ + public static function classifyRefreshError(int $status, string $error, string $description): string { + if ($error === 'invalid_grant' && stripos($description, 'just rotated') !== FALSE) { + return 'rotated'; + } + if (in_array($error, ['invalid_grant', 'invalid_client'], TRUE)) { + return 'refused'; + } + return ($status >= 500 || $status === 200) ? 'unknown' : 'failed'; + } + + /** + * Keeps the process alive so a rotated pair is always saved. + */ + protected function keepRunning(): void { + if (function_exists('ignore_user_abort')) { + ignore_user_abort(TRUE); + } + Environment::setTimeLimit(0); + } + + /** + * Saves a rotated pair and clears the refresh markers. + */ + protected function saveRotatedTokens(array $token_data, array $previous): ?string { + try { + $this->tokenStore->saveTokens($token_data); + $this->tokenStore->delete([ + OAuthTokenStore::PENDING, + OAuthTokenStore::RETRY_AFTER, + OAuthTokenStore::CRON_RETRY_AFTER, + ]); + } + catch (\Throwable $e) { + $this->logger->critical('The refreshed Quant tokens could not be saved (@class). The refresh is retried later.', ['@class' => get_class($e)]); + return $this->getUsableAccessToken($previous); + } + return $token_data['access_token']; + } + + /** + * Records a final refusal: no more refresh until the next login. + */ + protected function recordRefusal(string $error): void { + $this->tokenStore->set(OAuthTokenStore::REFUSAL, ['error' => $error, 'at' => time()]); + $this->logger->error('Quant refused the token refresh (@error). Log in to Quant again on the Quant API settings page.', ['@error' => $error]); + } + + /** + * The stored access token while it has not expired. + */ + protected function getUsableAccessToken(array $record): ?string { + $access = $record[OAuthTokenStore::ACCESS_TOKEN] ?? NULL; + if (!$access || $this->isExpiring($record, 0)) { + return NULL; + } + return $access; + } + + /** + * Whether the stored tokens were issued to another site. + */ + protected function isRecordFromAnotherSite(array $record): bool { + $stored = $record[OAuthTokenStore::FINGERPRINT] ?? NULL; + return is_array($stored) && $this->fingerprint->differences($stored) !== []; + } + + /** + * Whether the access token expires within the margin. + */ + protected function isExpiring(array $record, int $margin): bool { + $expires_at = (int) ($record[OAuthTokenStore::EXPIRES_AT] ?? 0); + return $expires_at > 0 && $expires_at - $margin <= time(); + } + + /** + * Base64url without padding. + */ + protected static function base64Url(string $bytes): string { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + +} diff --git a/modules/quant_api/src/OAuth/OAuthTokenStore.php b/modules/quant_api/src/OAuth/OAuthTokenStore.php new file mode 100644 index 00000000..a2e3d7be --- /dev/null +++ b/modules/quant_api/src/OAuth/OAuthTokenStore.php @@ -0,0 +1,197 @@ +store = $key_value_factory->get(self::COLLECTION); + $this->pending = $expirable_factory->get(self::PENDING_COLLECTION); + } + + /** + * All stored connection values, keyed by name. + */ + public function getAll(): array { + return $this->store->getMultiple(self::NAMES); + } + + /** + * One stored connection value. + */ + public function get(string $name): mixed { + return $this->store->get($name); + } + + /** + * Sets one connection value. + */ + public function set(string $name, mixed $value): void { + $this->store->set($name, $value); + } + + /** + * Sets several connection values. + */ + public function setMany(array $values): void { + $this->store->setMultiple($values); + } + + /** + * Deletes the named connection values. + */ + public function delete(array $names): void { + $this->store->deleteMultiple($names); + } + + /** + * Whether a connection (a limited token and a write token) is stored. + */ + public function isConnected(): bool { + return (string) $this->store->get(self::WRITE_TOKEN) !== ''; + } + + /** + * Saves an access and refresh token from a token response. + * + * @param array $token_data + * The decoded token response: access_token, refresh_token, expires_in. + */ + public function saveTokens(array $token_data): void { + $values = [self::ACCESS_TOKEN => (string) $token_data['access_token']]; + if (!empty($token_data['refresh_token'])) { + $values[self::REFRESH_TOKEN] = (string) $token_data['refresh_token']; + } + $expires_in = (int) ($token_data['expires_in'] ?? 0); + if ($expires_in > 0) { + $values[self::EXPIRES_AT] = time() + $expires_in; + $values[self::LIFETIME] = $expires_in; + } + $this->store->setMultiple($values); + if ($expires_in <= 0) { + $this->store->deleteMultiple([self::EXPIRES_AT, self::LIFETIME]); + } + } + + /** + * Deletes the whole connection. + */ + public function clear(): void { + $this->store->deleteMultiple(self::NAMES); + } + + /** + * Keeps a login that waits for the project choice. + * + * @param int $uid + * The user who logged in. + * @param array $login + * The token response, the user and the granted organisations. + */ + public function savePendingLogin(int $uid, array $login): void { + $this->pending->setWithExpire((string) $uid, $login, self::PENDING_TTL); + } + + /** + * The login that waits for this user's project choice, if any. + */ + public function getPendingLogin(int $uid): ?array { + $login = $this->pending->get((string) $uid); + return is_array($login) ? $login : NULL; + } + + /** + * Forgets a pending login. + */ + public function deletePendingLogin(int $uid): void { + $this->pending->delete((string) $uid); + } + + /** + * Forgets every pending login (uninstall). + */ + public function deleteAllPendingLogins(): void { + $this->pending->deleteAll(); + } + +} diff --git a/modules/quant_api/src/OAuth/Platform.php b/modules/quant_api/src/OAuth/Platform.php new file mode 100644 index 00000000..ba7695f1 --- /dev/null +++ b/modules/quant_api/src/OAuth/Platform.php @@ -0,0 +1,101 @@ + [ + 'api' => 'https://api.quantcdn.io', + 'dashboard' => 'https://dashboard.quantcdn.io', + ], + self::QUANTGOV => [ + 'api' => 'https://api.quantgov.cloud', + 'dashboard' => 'https://dash.quantgov.cloud', + ], + ]; + + /** + * The OAuth client id of this module on every platform. + */ + public const CLIENT_ID = 'drupal-quant'; + + /** + * The known platform ids. + * + * @return string[] + * The ids. + */ + public static function ids(): array { + return array_keys(self::PLATFORMS); + } + + /** + * Whether the id names a known platform. + */ + public static function isKnown(?string $platform): bool { + return isset(self::PLATFORMS[(string) $platform]); + } + + /** + * The content API endpoint of a platform, without /v1. + */ + public static function apiEndpoint(string $platform): string { + return self::PLATFORMS[$platform]['api'] ?? self::PLATFORMS[self::QUANTCDN]['api']; + } + + /** + * The dashboard (OAuth server) of a platform. + * + * A $settings['quant_api_dashboard_url'] override wins, for local and + * staging dashboards. + */ + public static function dashboardUrl(string $platform): string { + $override = (string) Settings::get('quant_api_dashboard_url', ''); + if ($override !== '') { + return rtrim($override, '/'); + } + return self::PLATFORMS[$platform]['dashboard'] ?? self::PLATFORMS[self::QUANTCDN]['dashboard']; + } + + /** + * The dashboard for server-to-server calls (token, refresh, projects). + * + * $settings['quant_api_dashboard_internal_url'] wins, for a site whose + * server reaches the dashboard at another address than the browser does + * (for example a container that reaches the host at host.docker.internal). + * The browser redirect always uses dashboardUrl(). + */ + public static function internalDashboardUrl(string $platform): string { + $internal = (string) Settings::get('quant_api_dashboard_internal_url', ''); + return $internal !== '' ? rtrim($internal, '/') : self::dashboardUrl($platform); + } + + /** + * The platform of an API endpoint, for sites configured before 2.3. + * + * @return string + * The platform id. An endpoint on quantgov.cloud is QuantGov; any other + * endpoint is QuantCDN. + */ + public static function fromEndpoint(?string $endpoint): string { + $host = (string) parse_url((string) $endpoint, PHP_URL_HOST); + return ($host === 'quantgov.cloud' || str_ends_with($host, '.quantgov.cloud')) ? self::QUANTGOV : self::QUANTCDN; + } + +} diff --git a/modules/quant_api/src/OAuth/PortalClient.php b/modules/quant_api/src/OAuth/PortalClient.php new file mode 100644 index 00000000..d6577aa1 --- /dev/null +++ b/modules/quant_api/src/OAuth/PortalClient.php @@ -0,0 +1,146 @@ +logger = $logger_factory->get('quant_api'); + } + + /** + * The user and the organisations the login granted. + * + * @return array|null + * The user (name, email, organizations), or NULL on failure. + */ + public function getUser(string $access_token): ?array { + $data = $this->send('GET', '/api/oauth/user', $access_token); + return is_array($data) ? ($data['user'] ?? $data) : NULL; + } + + /** + * The projects of one organisation. + * + * @return array|null + * A list of ['machine_name' => ..., 'name' => ...], or NULL on failure. + */ + public function listProjects(string $access_token, string $organization): ?array { + if (!self::isMachineName($organization)) { + return NULL; + } + $data = $this->send('GET', '/api/v2/organizations/' . $organization . '/projects', $access_token); + if (!is_array($data)) { + return NULL; + } + $projects = []; + foreach ($data as $project) { + if (is_array($project) && self::isMachineName((string) ($project['machine_name'] ?? ''))) { + $projects[] = [ + 'machine_name' => (string) $project['machine_name'], + 'name' => (string) ($project['name'] ?? $project['machine_name']), + ]; + } + } + return $projects; + } + + /** + * Limits the token to one project. The limit cannot be widened later. + */ + public function limitToProject(string $access_token, string $organization, string $project): bool { + if (!self::isMachineName($organization) || !self::isMachineName($project)) { + return FALSE; + } + $data = $this->send('POST', '/api/oauth/token/project', $access_token, [ + 'organization' => $organization, + 'project' => $project, + ]); + return is_array($data) && ($data['project'] ?? NULL) === $project; + } + + /** + * The write token of one project, for the content API. + */ + public function fetchWriteToken(string $access_token, string $organization, string $project): ?string { + if (!self::isMachineName($organization) || !self::isMachineName($project)) { + return NULL; + } + $data = $this->send('GET', '/api/v2/organizations/' . $organization . '/projects/' . $project . '?with_token=1', $access_token); + $token = is_array($data) ? (string) ($data['write_token'] ?? '') : ''; + return $token !== '' ? $token : NULL; + } + + /** + * Whether a value is a safe organisation or project machine name. + */ + public static function isMachineName(string $value): bool { + return preg_match(self::MACHINE_NAME, $value) === 1; + } + + /** + * Sends one call and returns the decoded data, or NULL on any failure. + */ + protected function send(string $method, string $path, string $access_token, ?array $json = NULL): ?array { + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Accept' => 'application/json', + ], + 'timeout' => self::TIMEOUT, + 'http_errors' => FALSE, + ]; + if ($json !== NULL) { + $options['json'] = $json; + } + try { + $response = $this->httpClient->request($method, $this->auth->getInternalDashboardUrl() . $path, $options); + } + catch (\Exception $e) { + $this->logger->error('The Quant dashboard call @method @path failed (@class).', [ + '@method' => $method, + '@path' => strtok($path, '?'), + '@class' => get_class($e), + ]); + return NULL; + } + $data = json_decode((string) $response->getBody(), TRUE); + if ($response->getStatusCode() !== 200 || !is_array($data)) { + $this->logger->warning('The Quant dashboard call @method @path returned status @status.', [ + '@method' => $method, + '@path' => strtok($path, '?'), + '@status' => $response->getStatusCode(), + ]); + return NULL; + } + return isset($data['data']) && is_array($data['data']) ? $data['data'] : $data; + } + +} diff --git a/modules/quant_api/src/OAuth/SiteFingerprint.php b/modules/quant_api/src/OAuth/SiteFingerprint.php new file mode 100644 index 00000000..970898ff --- /dev/null +++ b/modules/quant_api/src/OAuth/SiteFingerprint.php @@ -0,0 +1,107 @@ + $site_id] : $this->automaticValues(); + $values['uuid'] = (string) $this->configFactory->get('system.site')->get('uuid'); + + $hashes = []; + foreach ($values as $name => $value) { + $hashes[$name] = hash('sha256', $name . "\0" . $value); + } + return $hashes; + } + + /** + * The components that differ between a stored fingerprint and this site. + * + * @return string[] + * The names of the differing components; empty when it is this site. + */ + public function differences(array $stored): array { + $current = $this->compute(); + $different = []; + foreach (array_keys($stored + $current) as $name) { + if ($this->differs($name, $stored, $current)) { + $different[] = $name; + } + } + return $different; + } + + /** + * Whether one component differs. + * + * A hosting variable counts only when both sides have it, so a variable + * set for one context only (web, not CLI) does not split one site in two. + */ + protected function differs(string $name, array $stored, array $current): bool { + if (str_starts_with($name, 'env:') && (!isset($stored[$name]) || !isset($current[$name]))) { + return FALSE; + } + return !hash_equals((string) ($stored[$name] ?? ''), (string) ($current[$name] ?? '')); + } + + /** + * The automatic components: salt, database, prefix and hosting variables. + */ + protected function automaticValues(): array { + $db = Database::getConnectionInfo()['default'] ?? []; + $prefix = $db['prefix'] ?? ''; + $values = [ + 'hash_salt' => (string) Settings::get('hash_salt', ''), + 'database' => (string) ($db['database'] ?? ''), + 'prefix' => is_array($prefix) ? (string) ($prefix['default'] ?? '') : (string) $prefix, + ]; + foreach (self::ENVIRONMENT_VARIABLES as $variable) { + $value = getenv($variable); + if (is_string($value) && $value !== '') { + $values['env:' . $variable] = $value; + } + } + return $values; + } + +} diff --git a/modules/quant_api/src/OAuth/WriteTokenSync.php b/modules/quant_api/src/OAuth/WriteTokenSync.php new file mode 100644 index 00000000..4339adf9 --- /dev/null +++ b/modules/quant_api/src/OAuth/WriteTokenSync.php @@ -0,0 +1,100 @@ +logger = $logger_factory->get('quant_api'); + } + + /** + * Syncs after a refresh, or when the last sync is a day old. + */ + public function syncIfDue(bool $refreshed): ?bool { + $last = (int) $this->tokenStore->get(OAuthTokenStore::SYNCED_AT); + if (!$refreshed && $last > time() - self::INTERVAL) { + return NULL; + } + return $this->sync(); + } + + /** + * Syncs after the content API refused the write token (a 401). + * + * At most once every 15 minutes, so a token that stays refused does not + * call the dashboard on every request. + */ + public function syncAfterUnauthorized(): ?bool { + $last = (int) $this->tokenStore->get(OAuthTokenStore::UNAUTHORIZED_AT); + if ($last > time() - self::UNAUTHORIZED_INTERVAL || !$this->credentials->usesOauth()) { + return NULL; + } + $this->tokenStore->set(OAuthTokenStore::UNAUTHORIZED_AT, time()); + return $this->sync(); + } + + /** + * Re-reads the write token. + * + * @return bool|null + * TRUE when the token changed, FALSE when it did not, NULL when the sync + * could not run (no login, a copied site, or no answer). + */ + public function sync(): ?bool { + $config = $this->configFactory->get('quant_api.settings'); + if (!$this->credentials->usesOauth($config) || $this->credentials->isCopiedSite()) { + return NULL; + } + $access = $this->auth->getValidAccessToken(); + if ($access === NULL) { + return NULL; + } + $this->tokenStore->set(OAuthTokenStore::SYNCED_AT, time()); + $token = $this->portal->fetchWriteToken($access, (string) $config->get('api_account'), (string) $config->get('api_project')); + if ($token === NULL) { + return NULL; + } + if (hash_equals((string) $this->tokenStore->get(OAuthTokenStore::WRITE_TOKEN), $token)) { + return FALSE; + } + $this->tokenStore->set(OAuthTokenStore::WRITE_TOKEN, $token); + $this->logger->notice('Quant: the project write token changed in the dashboard and was updated from the login.'); + return TRUE; + } + +} diff --git a/modules/quant_api/src/PushFailures.php b/modules/quant_api/src/PushFailures.php new file mode 100644 index 00000000..940e0a06 --- /dev/null +++ b/modules/quant_api/src/PushFailures.php @@ -0,0 +1,128 @@ +update(function (array $all) use ($url, $operation, $error) { + unset($all[$url]); + $all[$url] = [ + 'operation' => $operation, + 'reason' => self::reason($error), + 'at' => $this->time->getRequestTime(), + ]; + return array_slice($all, -self::MAX, NULL, TRUE); + }); + } + + /** + * Forgets a page after a push of it succeeded. + */ + public function clear(string $url): void { + // Most pushes succeed: read without the lock first. + if (!isset($this->all()[$url])) { + return; + } + $this->update(function (array $all) use ($url) { + unset($all[$url]); + return $all; + }); + } + + /** + * Changes the list under a lock, so queue workers do not lose entries. + * + * @param callable $change + * Gets the list and returns the new list. + */ + protected function update(callable $change): void { + $locked = $this->lock->acquire(self::KEY, 5.0) || (!$this->lock->wait(self::KEY, 5) && $this->lock->acquire(self::KEY, 5.0)); + try { + $this->state->resetCache(); + $before = $this->all(); + $after = $change($before); + if ($after !== $before) { + $this->state->set(self::KEY, $after); + $this->invalidator->invalidateTags([self::CACHE_TAG]); + } + } + finally { + if ($locked) { + $this->lock->release(self::KEY); + } + } + } + + /** + * The failed pages, oldest first, keyed by path. + * + * @return array + * The failures. + */ + public function all(): array { + return (array) $this->state->get(self::KEY, []); + } + + /** + * A short reason that is safe to show to an editor. + */ + protected static function reason(\Throwable $error): string { + if ($error instanceof ConnectException) { + return 'Quant could not be reached'; + } + if ($error instanceof RequestException && $error->getResponse()) { + return 'Quant answered ' . $error->getResponse()->getStatusCode(); + } + return 'the request failed'; + } + +} diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml new file mode 100644 index 00000000..c01ac885 --- /dev/null +++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.info.yml @@ -0,0 +1,8 @@ +name: 'Quant API OAuth test' +description: 'A fake Quant dashboard and content API for the Log in to Quant tests.' +type: module +package: Testing +hidden: true +core_version_requirement: ^11 +dependencies: + - quantcdn:quant_api diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml new file mode 100644 index 00000000..42b38672 --- /dev/null +++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.routing.yml @@ -0,0 +1,8 @@ +quant_api_oauth_test.authorize: + path: '/quant-test-dashboard/oauth/authorize' + defaults: + _controller: '\Drupal\quant_api_oauth_test\Controller\FakeDashboardController::authorize' + requirements: + _access: 'TRUE' + options: + no_cache: TRUE diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml new file mode 100644 index 00000000..b4f93392 --- /dev/null +++ b/modules/quant_api/tests/modules/quant_api_oauth_test/quant_api_oauth_test.services.yml @@ -0,0 +1,6 @@ +services: + quant_api_oauth_test.middleware: + class: Drupal\quant_api_oauth_test\FakeQuantMiddleware + arguments: ['@state'] + tags: + - { name: http_client_middleware } diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php new file mode 100644 index 00000000..8ad4311c --- /dev/null +++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/Controller/FakeDashboardController.php @@ -0,0 +1,29 @@ +set('quant_api_oauth_test.authorize', $request->query->all()); + if (\Drupal::state()->get('quant_api_oauth_test.deny')) { + $target = $request->query->get('redirect_uri') . '?error=access_denied&state=' . rawurlencode((string) $request->query->get('state')); + } + else { + $state = (string) (\Drupal::state()->get('quant_api_oauth_test.forge_state') ?: $request->query->get('state')); + $target = $request->query->get('redirect_uri') . '?code=test-code&state=' . rawurlencode($state); + } + return new TrustedRedirectResponse($target); + } + +} diff --git a/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php new file mode 100644 index 00000000..3c7ad0f8 --- /dev/null +++ b/modules/quant_api/tests/modules/quant_api_oauth_test/src/FakeQuantMiddleware.php @@ -0,0 +1,168 @@ +answer($request); + return $response ? Create::promiseFor($response) : $handler($request, $options); + }; + }; + } + + /** + * The canned answer, or NULL to let the request through. + */ + protected function answer(RequestInterface $request): ?Response { + $path = $request->getUri()->getPath(); + if (in_array($request->getUri()->getHost(), ['api.quantcdn.io', 'api.quantgov.cloud'], TRUE) && $path === '/v1/ping') { + return $this->ping($request); + } + $at = strpos($path, '/quant-test-dashboard/'); + if ($at === FALSE) { + return NULL; + } + $sub = substr($path, $at + strlen('/quant-test-dashboard')); + if ($sub === '/oauth/authorize') { + // The browser's approval page: served by the test site itself. + return NULL; + } + $this->record($request); + return $this->dashboard($request, $sub); + } + + /** + * The dashboard API. + */ + protected function dashboard(RequestInterface $request, string $path): Response { + $method = $request->getMethod(); + return match (TRUE) { + $method === 'POST' && $path === '/oauth/token' => $this->token($request), + $method === 'GET' && $path === '/api/v2/organizations/test-org/projects' => $this->bearer($request, $this->limited() ? self::json(403, ['code' => 'PROJECT_LEVEL_ONLY']) : self::json(200, [ + ['machine_name' => 'site-a', 'name' => 'Site A'], + ['machine_name' => 'site-b', 'name' => 'Site B'], + ])), + $method === 'GET' && $path === '/api/oauth/user' => $this->bearer($request, self::json(200, [ + 'id' => 7, + 'name' => 'Test Editor', + 'email' => 'editor@example.com', + 'organizations' => [['id' => 3, 'machine_name' => 'test-org', 'name' => 'Test Org', 'roles' => []]], + ])), + $method === 'POST' && $path === '/api/oauth/revoke' => $this->bearer($request, self::json(200, ['revoked' => TRUE])), + $method === 'POST' && $path === '/api/oauth/token/project' => $this->bearer($request, $this->limit($request)), + $method === 'GET' && preg_match('#^/api/v2/organizations/test-org/projects/([a-z-]+)$#', $path, $m) === 1 => $this->bearer($request, $this->project($m[1])), + default => self::json(404, ['message' => 'Not found']), + }; + } + + /** + * The token endpoint: checks the PKCE verifier against the challenge. + */ + protected function token(RequestInterface $request): Response { + parse_str((string) $request->getBody(), $form); + $challenge = (string) ($this->state->get('quant_api_oauth_test.authorize')['code_challenge'] ?? ''); + $expected = rtrim(strtr(base64_encode(hash('sha256', (string) ($form['code_verifier'] ?? ''), TRUE)), '+/', '-_'), '='); + if (($form['code'] ?? '') !== 'test-code' || $challenge === '' || !hash_equals($challenge, $expected) || ($form['client_id'] ?? '') !== 'drupal-quant') { + return self::json(400, ['error' => 'invalid_grant']); + } + $this->state->delete('quant_api_oauth_test.limited'); + return self::json(200, [ + 'access_token' => 'qca_fake_access', + 'refresh_token' => 'qcr_fake_refresh', + 'token_type' => 'Bearer', + 'expires_in' => 7776000, + 'scope' => 'projects:read', + // The real dashboard sends only id, email and name here. The + // organisations come from GET /api/oauth/user. + 'user' => [ + 'id' => 7, + 'name' => 'Test Editor', + 'email' => 'editor@example.com', + ], + ]); + } + + /** + * The project limit: narrows once, refuses to widen. + */ + protected function limit(RequestInterface $request): Response { + $body = json_decode((string) $request->getBody(), TRUE) ?: []; + $limited = $this->limited(); + if ($limited !== NULL && $limited !== ($body['project'] ?? NULL)) { + return self::json(409, ['code' => 'TOKEN_ALREADY_LIMITED']); + } + $this->state->set('quant_api_oauth_test.limited', (string) ($body['project'] ?? '')); + return self::json(200, ['organization' => $body['organization'] ?? '', 'project' => $body['project'] ?? '']); + } + + /** + * One project with its write token, for the limited project only. + */ + protected function project(string $project): Response { + if ($this->limited() !== $project) { + return self::json(403, ['code' => 'PROJECT_LEVEL_ONLY']); + } + return self::json(200, ['machine_name' => $project, 'write_token' => self::WRITE_TOKEN_PREFIX . $project]); + } + + /** + * The content API ping: 200 for the write token of the limited project. + */ + protected function ping(RequestInterface $request): Response { + $ok = $request->getHeaderLine('Quant-Token') === self::WRITE_TOKEN_PREFIX . $request->getHeaderLine('Quant-Project'); + return self::json($ok ? 200 : 401, $ok ? ['project' => $request->getHeaderLine('Quant-Project')] : ['error' => TRUE]); + } + + /** + * Requires the bearer of the login. + */ + protected function bearer(RequestInterface $request, Response $response): Response { + return $request->getHeaderLine('Authorization') === 'Bearer qca_fake_access' ? $response : self::json(401, ['message' => 'Invalid token']); + } + + /** + * The project the login is limited to, if any. + */ + protected function limited(): ?string { + return $this->state->get('quant_api_oauth_test.limited'); + } + + /** + * Records a request: method and path only. + */ + protected function record(RequestInterface $request): void { + $log = $this->state->get('quant_api_oauth_test.requests', []); + $log[] = $request->getMethod() . ' ' . $request->getUri()->getPath(); + $this->state->set('quant_api_oauth_test.requests', $log); + } + + /** + * A JSON response. + */ + protected static function json(int $status, array $body): Response { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body)); + } + +} diff --git a/modules/quant_api/tests/src/Functional/OAuthConnectTest.php b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php new file mode 100644 index 00000000..783d3fed --- /dev/null +++ b/modules/quant_api/tests/src/Functional/OAuthConnectTest.php @@ -0,0 +1,339 @@ +writeSettings([ + 'settings' => [ + 'quant_api_dashboard_url' => (object) [ + 'value' => $this->baseUrl . '/quant-test-dashboard', + 'required' => TRUE, + ], + ], + ]); + $this->drupalLogin($this->drupalCreateUser(['configure quant'])); + } + + /** + * Logs in on the fake dashboard and picks a project. + */ + protected function logInAndPick(string $project): void { + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm([], 'Log in to Quant'); + $this->assertSession()->pageTextContains('Logged in as editor@example.com'); + $this->submitForm(['project' => 'test-org:' . $project], 'Use this project'); + } + + /** + * The store, read fresh from the test site's database. + */ + protected function store(): OAuthTokenStore { + return \Drupal::service('quant_api.oauth_store'); + } + + /** + * The login, the project choice, change project and log out. + */ + public function testLoginChooseChangeLogout(): void { + $this->logInAndPick('site-b'); + + $authorize = \Drupal::state()->get('quant_api_oauth_test.authorize'); + $this->assertSame('drupal-quant', $authorize['client_id']); + $this->assertSame('S256', $authorize['code_challenge_method']); + $this->assertSame('full_access', $authorize['scope']); + $this->assertStringEndsWith('/admin/config/development/quant/api/oauth/callback', $authorize['redirect_uri']); + + $session = $this->assertSession(); + $session->pageTextContains('This site now publishes to the Quant project site-b.'); + // One success message, not the same news twice. + $session->pageTextNotContains('Connected to the Quant project site-b.'); + $session->pageTextContains('it can reach only that project'); + $session->linkExists('Change project'); + + $config = $this->config('quant_api.settings'); + $this->assertSame('oauth', $config->get('auth_method')); + $this->assertSame('test-org', $config->get('api_account')); + $this->assertSame('site-b', $config->get('api_project')); + $this->assertEmpty($config->get('api_token')); + $this->assertSame('fake-write-token-site-b', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + + // The module limited the token before it read the write token. + $requests = \Drupal::state()->get('quant_api_oauth_test.requests'); + $limit = array_search('POST /quant-test-dashboard/api/oauth/token/project', $requests, TRUE); + $fetch = array_search('GET /quant-test-dashboard/api/v2/organizations/test-org/projects/site-b', $requests, TRUE); + $this->assertNotFalse($limit); + $this->assertGreaterThan($limit, $fetch); + + // Change project: a new login, the old project stays until the choice. + $this->clickLink('Change project'); + $this->assertSame('site-b', $this->config('quant_api.settings')->get('api_project')); + $this->submitForm(['project' => 'test-org:site-a'], 'Use this project'); + $session->pageTextContains('This site now publishes to the Quant project site-a.'); + $this->assertSame('fake-write-token-site-a', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + + \Drupal::state()->delete('quant_api_oauth_test.requests'); + $this->clickLink('Log out of Quant'); + $session->pageTextContains('This site is logged out of Quant.'); + // The login is ended in the dashboard too, not only deleted here. + $this->assertContains('POST /quant-test-dashboard/api/oauth/revoke', \Drupal::state()->get('quant_api_oauth_test.requests', [])); + $this->assertSame('token', $this->config('quant_api.settings')->get('auth_method')); + $this->assertSame([], array_filter($this->store()->getAll())); + $session->buttonExists('Log in to Quant'); + } + + /** + * A callback with a state from another login is refused. + */ + public function testForgedStateIsRefused(): void { + \Drupal::state()->set('quant_api_oauth_test.forge_state', 'attacker-state'); + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm([], 'Log in to Quant'); + $this->assertSession()->pageTextContains('The login expired or was started in another browser.'); + $this->assertNotContains('POST /quant-test-dashboard/oauth/token', \Drupal::state()->get('quant_api_oauth_test.requests', [])); + $this->assertSame('token', $this->config('quant_api.settings')->get('auth_method')); + } + + /** + * A login cancelled in the dashboard changes nothing. + */ + public function testCancelledLogin(): void { + \Drupal::state()->set('quant_api_oauth_test.deny', TRUE); + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm([], 'Log in to Quant'); + $this->assertSession()->pageTextContains('The login was cancelled in the Quant dashboard.'); + $this->assertNull($this->store()->get(OAuthTokenStore::ACCESS_TOKEN)); + } + + /** + * The connect route needs its CSRF token. + */ + public function testConnectNeedsCsrfToken(): void { + $this->drupalGet('admin/config/development/quant/api/oauth/connect'); + $this->assertSession()->statusCodeEquals(403); + $this->drupalGet('admin/config/development/quant/api/oauth/disconnect'); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * The routes need the "configure quant" permission. + */ + public function testRoutesNeedPermission(): void { + $this->drupalLogin($this->drupalCreateUser(['access content'])); + $paths = [ + 'admin/config/development/quant/api', + 'admin/config/development/quant/api/oauth/callback', + 'admin/config/development/quant/api/oauth/project', + ]; + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(403); + } + } + + /** + * A multi-domain site sees why it uses token connect. + */ + public function testMultiDomainSiteUsesTokenConnect(): void { + \Drupal::service('config.storage')->write('domain.config.example_com.quant_api.settings', ['api_project' => 'example']); + $this->drupalGet('admin/config/development/quant/api'); + $this->assertSession()->pageTextContains('Log in to Quant supports one project per site for now'); + $this->assertSession()->buttonExists('Log in to Quant'); + $this->assertSession()->elementAttributeExists('css', 'input[value="Log in to Quant"]', 'disabled'); + } + + /** + * Token connect still works, and QuantGov sets the QuantGov endpoint. + */ + public function testTokenConnectAndPlatform(): void { + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm([ + 'platform' => 'quantgov', + 'api_account' => 'test-org', + 'api_project' => 'site-c', + 'api_token' => 'typed-token', + ], 'Save configuration'); + $config = $this->config('quant_api.settings'); + $this->assertSame('quantgov', $config->get('platform')); + $this->assertSame('https://api.quantgov.cloud', $config->get('api_endpoint')); + $this->assertSame('token', $config->get('auth_method')); + $this->assertSame('typed-token', $config->get('api_token')); + + // An empty token field keeps the saved token. + $this->submitForm(['api_project' => 'site-d'], 'Save configuration'); + $this->assertSame('typed-token', $this->config('quant_api.settings')->get('api_token')); + $this->assertSame('site-d', $this->config('quant_api.settings')->get('api_project')); + } + + /** + * A cancelled login on another platform leaves the connection as it was. + */ + public function testCancelledLoginKeepsThePlatform(): void { + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm(['api_account' => 'test-org', 'api_project' => 'site-c', 'api_token' => 'typed-token'], 'Save configuration'); + \Drupal::state()->set('quant_api_oauth_test.deny', TRUE); + $this->submitForm(['platform' => 'quantgov'], 'Log in to Quant'); + $this->assertSession()->pageTextContains('The login was cancelled'); + $config = $this->config('quant_api.settings'); + $this->assertSame('quantcdn', $config->get('platform')); + $this->assertSame('https://api.quantcdn.io', $config->get('api_endpoint')); + $this->assertSame('typed-token', $config->get('api_token')); + } + + /** + * A completed login saves the chosen platform and fixes its endpoint. + */ + public function testCompletedLoginSavesThePlatform(): void { + $this->drupalGet('admin/config/development/quant/api'); + $this->submitForm(['platform' => 'quantgov'], 'Log in to Quant'); + $this->assertSame('quantcdn', $this->config('quant_api.settings')->get('platform'), 'Not saved before the project choice.'); + $this->submitForm(['project' => 'test-org:site-b'], 'Use this project'); + $config = $this->config('quant_api.settings'); + $this->assertSame('quantgov', $config->get('platform')); + $this->assertSame('https://api.quantgov.cloud', $config->get('api_endpoint')); + $this->assertSession()->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled'); + } + + /** + * A refused refresh is not shown as "Connected"; the status report says so. + */ + public function testRefusedLoginIsShownToTheEditor(): void { + $this->logInAndPick('site-b'); + \Drupal::service('quant_api.oauth_store')->set(OAuthTokenStore::REFUSAL, [ + 'error' => 'invalid_grant', + 'at' => time(), + ]); + + $this->drupalGet('admin/config/development/quant/api'); + $session = $this->assertSession(); + $session->pageTextNotContains('Connected to the Quant project site-b.'); + $session->elementTextContains('css', '[role="contentinfo"], .messages--warning, [data-drupal-messages]', 'Log in to Quant again'); + // The button says what the message asks for. + $session->linkExists('Log in to Quant again'); + $session->linkNotExists('Change project'); + + $this->drupalLogin($this->drupalCreateUser(['configure quant', 'administer site configuration'])); + $this->drupalGet('admin/reports/status'); + $session->pageTextContains('The Quant login needs renewing'); + } + + /** + * A copied site says so and can be confirmed as the same site. + */ + public function testCopiedSiteCanBeConfirmed(): void { + $this->logInAndPick('site-b'); + $store = \Drupal::service('quant_api.oauth_store'); + $fingerprint = $store->get(OAuthTokenStore::FINGERPRINT); + $fingerprint['database'] = hash('sha256', 'old database'); + $store->set(OAuthTokenStore::FINGERPRINT, $fingerprint); + + $this->drupalGet('admin/config/development/quant/api'); + $this->assertSession()->pageTextContains('This site looks like a copy of the connected site (different: database)'); + $this->submitForm([], 'This is the same site'); + $this->assertSession()->pageTextContains('Confirmed. This site publishes with its Quant login again.'); + $this->assertSession()->pageTextNotContains('This site looks like a copy'); + } + + /** + * The settings.php endpoint is read-only and never shows its userinfo. + */ + public function testEndpointOverrideIsReadOnly(): void { + $this->writeSettings([ + 'settings' => [ + 'quant_api_endpoint_url' => (object) [ + 'value' => 'https://rehearsal:placeholder-pass@uploads.stage.example', + 'required' => TRUE, + ], + ], + ]); + $this->drupalGet('admin/config/development/quant/api'); + $session = $this->assertSession(); + $session->elementAttributeExists('css', 'input[name="api_endpoint"]', 'disabled'); + $session->fieldValueEquals('api_endpoint', 'https://uploads.stage.example'); + $session->pageTextContains('Set in settings.php'); + $session->responseNotContains('placeholder-pass'); + $session->responseNotContains('rehearsal:'); + } + + /** + * A push that failed after a save is shown on the next page view. + */ + public function testFailedPushIsShownOnTheNextPageView(): void { + \Drupal::service('quant_api.push_failures')->record('/news/a', 'publish', new \Exception('x')); + $this->drupalGet('admin/config/development/quant/api'); + $session = $this->assertSession(); + $session->pageTextContains('1 change did not reach Quant'); + $session->pageTextContains('/news/a'); + + // Another editor of the same site sees it too: it is per site. + $this->drupalLogin($this->drupalCreateUser(['access content overview'])); + $this->drupalGet('admin/content'); + $session->pageTextContains('1 change did not reach Quant'); + + // A visitor does not. + $this->drupalLogout(); + $this->drupalGet('user/login'); + $session->pageTextNotContains('did not reach Quant'); + } + + /** + * A connected site shows its saved platform, so a save keeps the login. + * + * With a dashboard override outside quantgov.cloud (a local QuantGov + * dashboard), the radio used to default to QuantCDN while the saved + * platform was QuantGov. Saving the form for another reason then logged + * the site out. + */ + public function testSaveKeepsTheLoginWithAnOverrideOnAnotherHost(): void { + $this->logInAndPick('site-b'); + $this->config('quant_api.settings')->set('platform', 'quantgov')->save(); + + $this->drupalGet('admin/config/development/quant/api'); + $this->assertSession()->checkboxChecked('edit-platform-quantgov'); + $this->submitForm([], 'Save configuration'); + $this->assertSession()->pageTextNotContains('The platform changed'); + $this->assertSame('oauth', $this->config('quant_api.settings')->get('auth_method')); + } + + /** + * A QuantGov dashboard in settings.php makes QuantGov the starting platform. + */ + public function testDashboardOverrideSetsTheDefaultPlatform(): void { + $this->writeSettings([ + 'settings' => [ + 'quant_api_dashboard_url' => (object) [ + 'value' => 'https://dash.stage.quantgov.cloud', + 'required' => TRUE, + ], + ], + ]); + $this->drupalGet('admin/config/development/quant/api'); + $this->assertSession()->checkboxChecked('edit-platform-quantgov'); + } + +} diff --git a/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php new file mode 100644 index 00000000..dd4e2c73 --- /dev/null +++ b/modules/quant_api/tests/src/Kernel/OAuthAuthServiceTest.php @@ -0,0 +1,565 @@ +installConfig(['quant_api']); + $this->mock = new MockHandler(); + $stack = HandlerStack::create($this->mock); + $stack->push(Middleware::history($this->history)); + // Set before the OAuth services are first built, so they get this client. + $this->container->set('http_client', new Client(['handler' => $stack])); + } + + /** + * The store. + */ + protected function store(): OAuthTokenStore { + return $this->container->get('quant_api.oauth_store'); + } + + /** + * The service under test. + * + * @return \Drupal\quant_api\OAuth\AuthService + * The service. + */ + protected function auth() { + return $this->container->get('quant_api.oauth'); + } + + /** + * Stores a connection whose access token expires in $expires_in seconds. + */ + protected function connect(int $expires_in, array $extra = []): void { + $this->auth()->saveConnection([ + 'access_token' => 'qca_old', + 'refresh_token' => 'qcr_old', + 'expires_in' => $expires_in, + ], 'write-token', ['name' => 'Editor', 'email' => 'editor@example.com']); + if ($extra) { + $this->store()->setMany($extra); + } + } + + /** + * A JSON response. + */ + protected static function json(int $status, array $body): Response { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body)); + } + + /** + * A successful refresh answer with a rotated pair. + */ + protected static function rotated(): Response { + return self::json(200, ['access_token' => 'qca_new', 'refresh_token' => 'qcr_new', 'expires_in' => 7776000]); + } + + /** + * The RFC 7636 example: S256 of the example verifier. + * + * @covers ::computePkceChallenge + * @covers ::generatePkceVerifier + */ + public function testPkce(): void { + $this->assertSame('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', $this->auth()->computePkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')); + $verifier = $this->auth()->generatePkceVerifier(); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43}$/', $verifier); + } + + /** + * The authorize URL names the client, the scope and an S256 challenge. + * + * @covers ::getAuthorizationUrl + */ + public function testAuthorizationUrlPerPlatform(): void { + $url = $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch'); + $this->assertStringStartsWith('https://dashboard.quantcdn.io/oauth/authorize?', $url); + parse_str(parse_url($url, PHP_URL_QUERY), $query); + $this->assertSame([ + 'client_id' => Platform::CLIENT_ID, + 'response_type' => 'code', + 'redirect_uri' => 'https://site.example/cb', + 'state' => 'st', + 'scope' => 'full_access', + 'code_challenge' => 'ch', + 'code_challenge_method' => 'S256', + ], $query); + + $this->config('quant_api.settings')->set('platform', Platform::QUANTGOV)->save(); + $this->assertStringStartsWith('https://dash.quantgov.cloud/oauth/authorize?', $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch')); + } + + /** + * A token far from expiry is used as it is, with no call. + * + * @covers ::getValidAccessToken + */ + public function testFreshTokenNeedsNoRefresh(): void { + $this->connect(86400 * 60); + $this->assertSame('qca_old', $this->auth()->getValidAccessToken()); + $this->assertCount(0, $this->history); + } + + /** + * An expiring token is refreshed and the rotated pair is saved. + * + * @covers ::getValidAccessToken + */ + public function testExpiringTokenIsRefreshedAndRotated(): void { + $this->connect(30); + $this->mock->append(self::rotated()); + + $this->assertSame('qca_new', $this->auth()->getValidAccessToken()); + $this->assertSame('qcr_new', $this->store()->get(OAuthTokenStore::REFRESH_TOKEN)); + $this->assertNull($this->store()->get(OAuthTokenStore::PENDING)); + $this->assertSame('write-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + parse_str((string) $this->history[0]['request']->getBody(), $sent); + $this->assertSame([ + 'grant_type' => 'refresh_token', + 'refresh_token' => 'qcr_old', + 'client_id' => 'drupal-quant', + ], $sent); + } + + /** + * A final refusal stops further refresh attempts and keeps the tokens. + * + * @covers ::getValidAccessToken + * @covers ::getReconnectReason + */ + public function testRefusalIsFinal(): void { + $this->connect(30); + $this->mock->append(self::json(400, [ + 'error' => 'invalid_grant', + 'error_description' => 'Refresh token revoked', + ])); + + $this->assertSame('qca_old', $this->auth()->getValidAccessToken()); + $this->assertSame('refused', $this->auth()->getReconnectReason()); + $this->assertSame('qca_old', $this->auth()->getValidAccessToken()); + $this->assertCount(1, $this->history, 'No second refresh after a refusal.'); + $this->assertSame('write-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + } + + /** + * A peer's "just rotated" answer backs off and keeps the stored pair. + * + * @covers ::getValidAccessToken + */ + public function testJustRotatedBacksOff(): void { + $this->connect(30); + $this->mock->append(self::json(400, [ + 'error' => 'invalid_grant', + 'error_description' => 'Refresh token was just rotated', + ])); + + $this->auth()->getValidAccessToken(); + $this->assertNull($this->auth()->getReconnectReason()); + $this->assertGreaterThan(time(), $this->store()->get(OAuthTokenStore::RETRY_AFTER)); + $this->auth()->getValidAccessToken(); + $this->assertCount(1, $this->history, 'The back-off holds the next request.'); + } + + /** + * No answer leaves a pending marker; the same token is retried in the window. + * + * @covers ::getValidAccessToken + */ + public function testLostAnswerIsRetriedInsideThePortalWindow(): void { + $this->connect(30); + $this->mock->append(new ConnectException('timeout', new Request('POST', 'https://dashboard.quantcdn.io/oauth/token'))); + $this->auth()->getValidAccessToken(); + $pending = $this->store()->get(OAuthTokenStore::PENDING); + $this->assertSame(hash('sha256', 'qcr_old'), $pending['token']); + + // Too early: the portal answers "just rotated" before 45 s. + $this->auth()->getValidAccessToken(); + $this->assertCount(1, $this->history); + + // Inside the recovery window: the same refresh token is sent again. + $pending['since'] = time() - 60; + $this->store()->set(OAuthTokenStore::PENDING, $pending); + $this->mock->append(self::rotated()); + $this->assertSame('qca_new', $this->auth()->getValidAccessToken()); + parse_str((string) $this->history[1]['request']->getBody(), $sent); + $this->assertSame('qcr_old', $sent['refresh_token']); + } + + /** + * A database copy does not refresh the original site's tokens. + * + * @covers ::getValidAccessToken + * @covers ::getReconnectReason + */ + public function testCopiedDatabaseDoesNotRefresh(): void { + $this->connect(30); + $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT); + $fingerprint['uuid'] = hash('sha256', 'another site'); + $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint); + + $this->assertSame('qca_old', $this->auth()->getValidAccessToken()); + $this->assertSame('other_site', $this->auth()->getReconnectReason()); + $this->assertCount(0, $this->history); + } + + /** + * Cron refreshes 30 days before expiry, a request only at the last minute. + * + * @covers ::refreshFromCron + */ + public function testCronRefreshesEarly(): void { + $this->connect(86400 * 20, [OAuthTokenStore::LIFETIME => 7776000]); + $this->auth()->getValidAccessToken(); + $this->assertCount(0, $this->history); + + $this->mock->append(self::rotated()); + $this->auth()->refreshFromCron(); + $this->assertSame('qca_new', $this->store()->get(OAuthTokenStore::ACCESS_TOKEN)); + } + + /** + * Disconnect deletes every stored value. + * + * @covers ::disconnect + */ + public function testDisconnect(): void { + $this->connect(86400); + $this->auth()->disconnect(); + $this->assertSame([], array_filter($this->store()->getAll())); + } + + /** + * The write token: settings.php, then the OAuth connection, then config. + * + * @covers \Drupal\quant_api\Credentials::getToken + */ + public function testCredentialOrder(): void { + $credentials = $this->container->get('quant_api.credentials'); + $this->config('quant_api.settings')->set('api_token', 'typed-token')->save(); + $this->assertSame('typed-token', $credentials->getToken()); + + $this->connect(86400); + $this->assertSame('typed-token', $credentials->getToken(), 'The OAuth store is used only with auth_method oauth.'); + $this->config('quant_api.settings')->set('auth_method', 'oauth')->save(); + $this->assertSame('write-token', $credentials->getToken()); + + $GLOBALS['config']['quant_api.settings']['api_token'] = 'settings-php-token'; + $this->container->get('config.factory')->reset('quant_api.settings'); + $this->assertSame('settings-php-token', $credentials->getToken()); + unset($GLOBALS['config']['quant_api.settings']); + } + + /** + * No token ever reaches exportable configuration. + * + * @covers ::saveConnection + */ + public function testTokensStayOutOfConfig(): void { + $this->connect(86400); + $this->config('quant_api.settings')->set('auth_method', 'oauth')->save(); + $raw = json_encode($this->container->get('config.storage')->read('quant_api.settings')); + foreach (['qca_old', 'qcr_old', 'write-token'] as $secret) { + $this->assertStringNotContainsString($secret, $raw); + } + } + + /** + * Sites from before 2.3 get the platform of their endpoint. + * + * @covers ::getPlatform + */ + public function testUpdateHookSetsThePlatformFromTheEndpoint(): void { + $this->config('quant_api.settings') + ->clear('platform') + ->clear('auth_method') + ->set('api_endpoint', 'https://api.quantgov.cloud') + ->save(); + \Drupal::moduleHandler()->loadInclude('quant_api', 'install'); + quant_api_update_11001(); + + $config = $this->config('quant_api.settings'); + $this->assertSame('quantgov', $config->get('platform')); + $this->assertSame('token', $config->get('auth_method')); + $this->assertSame('https://dash.quantgov.cloud', $this->auth()->getDashboardUrl()); + } + + /** + * A copied database publishes nothing with the original site's login. + * + * @covers \Drupal\quant_api\Credentials::getToken + * @covers \Drupal\quant_api\Credentials::isCopiedSite + */ + public function testCopiedSiteGetsNoToken(): void { + $this->connect(86400); + $this->config('quant_api.settings')->set('auth_method', 'oauth')->save(); + $credentials = $this->container->get('quant_api.credentials'); + $this->assertSame('write-token', $credentials->getToken()); + + $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT); + $fingerprint['database'] = hash('sha256', 'production database'); + $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint); + $this->assertTrue($credentials->isCopiedSite()); + $this->assertNull($credentials->getToken()); + $this->assertFalse($credentials->hasToken()); + } + + /** + * With a login, the endpoint is the platform's own host. + * + * @covers \Drupal\quant_api\Credentials::getEndpoint + */ + public function testLoginFixesTheEndpoint(): void { + $credentials = $this->container->get('quant_api.credentials'); + $this->config('quant_api.settings')->set('api_endpoint', 'https://collector.example')->save(); + $this->assertSame('https://collector.example', $credentials->getEndpoint(), 'Token connect keeps a custom endpoint.'); + + $this->config('quant_api.settings')->set('auth_method', 'oauth')->set('platform', 'quantgov')->save(); + $this->assertSame('https://api.quantgov.cloud', $credentials->getEndpoint()); + $this->config('quant_api.settings')->set('platform', 'quantcdn')->save(); + $this->assertSame('https://api.quantcdn.io', $credentials->getEndpoint()); + } + + /** + * A write token rotated in the dashboard is re-read with the login. + * + * @covers \Drupal\quant_api\OAuth\WriteTokenSync::syncIfDue + */ + public function testWriteTokenResync(): void { + $this->connect(86400 * 80); + $this->config('quant_api.settings')->set('auth_method', 'oauth') + ->set('api_account', 'test-org')->set('api_project', 'site-a')->save(); + $sync = $this->container->get('quant_api.write_token_sync'); + + $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token'])); + $this->assertTrue($sync->syncIfDue(FALSE)); + $this->assertSame('rotated-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + $request = $this->history[0]['request']; + $this->assertSame('/api/v2/organizations/test-org/projects/site-a', $request->getUri()->getPath()); + $this->assertSame('with_token=1', $request->getUri()->getQuery()); + $this->assertSame('Bearer qca_old', $request->getHeaderLine('Authorization')); + + // Within a day nothing is sent, unless the login was just refreshed. + $this->assertNull($sync->syncIfDue(FALSE)); + $this->assertCount(1, $this->history); + $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token'])); + $this->assertFalse($sync->syncIfDue(TRUE)); + } + + /** + * Cron reports whether it rotated the tokens. + * + * @covers ::refreshFromCron + */ + public function testCronReportsTheRotation(): void { + $this->connect(86400 * 80, [OAuthTokenStore::LIFETIME => 7776000]); + $this->assertFalse($this->auth()->refreshFromCron()); + $this->store()->set(OAuthTokenStore::EXPIRES_AT, time() + 86400); + $this->mock->append(self::rotated()); + $this->assertTrue($this->auth()->refreshFromCron()); + } + + /** + * A 401 from the content API re-reads the write token, once per 15 minutes. + * + * @covers \Drupal\quant_api\Http\UnauthorizedResyncMiddleware + * @covers \Drupal\quant_api\OAuth\WriteTokenSync::syncAfterUnauthorized + */ + public function testUnauthorizedTriggersOneResync(): void { + $this->connect(86400 * 80); + $this->config('quant_api.settings')->set('auth_method', 'oauth') + ->set('api_account', 'test-org')->set('api_project', 'site-a')->save(); + $this->store()->set(OAuthTokenStore::SYNCED_AT, time()); + $middleware = (new UnauthorizedResyncMiddleware())(); + $refused = fn () => Create::promiseFor(new Response(401)); + $call = fn () => $middleware($refused)(new Request('POST', 'https://api.quantcdn.io/v1', ['Quant-Token' => 'old']), [])->wait(); + + $this->mock->append(self::json(200, ['machine_name' => 'site-a', 'write_token' => 'rotated-token'])); + $this->assertSame(401, $call()->getStatusCode()); + $this->assertSame('rotated-token', $this->store()->get(OAuthTokenStore::WRITE_TOKEN)); + $this->assertCount(1, $this->history); + + // A second 401 within 15 minutes does not call the dashboard again. + $call(); + $this->assertCount(1, $this->history); + + // A 401 from a call without Quant-Token (the dashboard itself) is ignored. + $this->store()->delete([OAuthTokenStore::UNAUTHORIZED_AT]); + $middleware($refused)(new Request('GET', 'https://dashboard.quantcdn.io/api/oauth/user'), [])->wait(); + $this->assertCount(1, $this->history); + } + + /** + * A copy is explicit, names what differs, and recovers with "same site". + * + * @covers \Drupal\quant_api\Credentials::confirmSameSite + */ + public function testSameSiteConfirmationRestoresPublishing(): void { + $this->connect(86400); + $this->config('quant_api.settings')->set('auth_method', 'oauth')->save(); + $credentials = $this->container->get('quant_api.credentials'); + $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT); + $fingerprint['database'] = hash('sha256', 'old database'); + $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint); + $this->assertSame(['database'], $credentials->copyDifferences()); + $this->assertNull($credentials->getToken()); + + \Drupal::moduleHandler()->loadInclude('quant_api', 'install'); + $requirements = quant_api_requirements('runtime'); + $this->assertSame(REQUIREMENT_ERROR, $requirements['quant_api_copied_site']['severity']); + + $credentials->confirmSameSite(); + $this->assertSame([], $credentials->copyDifferences()); + $this->assertSame('write-token', $credentials->getToken()); + } + + /** + * The settings.php endpoint wins in every mode and is shown without userinfo. + * + * @covers \Drupal\quant_api\Credentials::getEndpoint + * @covers \Drupal\quant_api\Credentials::redact + * @covers \Drupal\quant_api\Http\EndpointUserInfoMiddleware + */ + public function testEndpointOverrideWithUserInfo(): void { + $this->setSetting('quant_api_endpoint_url', 'https://rehearsal:placeholder-pass@uploads.stage.example/'); + $credentials = $this->container->get('quant_api.credentials'); + $this->config('quant_api.settings')->set('auth_method', 'oauth')->set('platform', 'quantgov')->save(); + $this->assertSame('https://rehearsal:placeholder-pass@uploads.stage.example', $credentials->getEndpoint()); + $this->assertSame('https://uploads.stage.example', Credentials::redact($credentials->getEndpoint())); + + $sent = NULL; + $middleware = (new EndpointUserInfoMiddleware())(); + $handler = function ($request) use (&$sent) { + $sent = $request; + return Create::promiseFor(new Response(200)); + }; + $middleware($handler)(new Request('GET', 'https://rehearsal:placeholder-pass@uploads.stage.example/v1/ping'), [])->wait(); + $this->assertSame('https://uploads.stage.example/v1/ping', (string) $sent->getUri()); + $this->assertSame('Basic ' . base64_encode('rehearsal:placeholder-pass'), $sent->getHeaderLine('Authorization')); + } + + /** + * Server-to-server calls use the internal dashboard; the browser does not. + * + * @covers ::getInternalDashboardUrl + */ + public function testInternalDashboardForServerCalls(): void { + $this->setSetting('quant_api_dashboard_url', 'http://localhost:8002'); + $this->setSetting('quant_api_dashboard_internal_url', 'http://host.docker.internal:8002/'); + $this->assertStringStartsWith('http://localhost:8002/oauth/authorize?', $this->auth()->getAuthorizationUrl('st', 'https://site.example/cb', 'ch')); + + $this->mock->append(self::json(200, ['access_token' => 'qca_x', 'refresh_token' => 'qcr_x', 'expires_in' => 60])); + $this->auth()->exchangeCode('code', 'https://site.example/cb', 'verifier'); + $this->assertSame('http://host.docker.internal:8002/oauth/token', (string) $this->history[0]['request']->getUri()); + + // Without the internal setting, both are the same. + $this->setSetting('quant_api_dashboard_internal_url', ''); + $this->assertSame('http://localhost:8002', $this->auth()->getInternalDashboardUrl()); + } + + /** + * Log out revokes this site's own login at the dashboard. + * + * @covers ::disconnect + */ + public function testLogOutRevokesTheLogin(): void { + $this->connect(86400 * 60); + $this->mock->append(self::json(200, ['revoked' => TRUE])); + $this->auth()->disconnect(); + $this->assertCount(1, $this->history); + $this->assertStringEndsWith('/api/oauth/revoke', (string) $this->history[0]['request']->getUri()); + $this->assertSame([], array_filter($this->store()->getAll())); + } + + /** + * Log out on a copy of the database never revokes the original's login. + * + * @covers ::disconnect + */ + public function testLogOutOnCopiedSiteDoesNotRevoke(): void { + $this->connect(86400 * 60); + $fingerprint = $this->store()->get(OAuthTokenStore::FINGERPRINT); + $fingerprint['database'] = hash('sha256', 'production database'); + $this->store()->set(OAuthTokenStore::FINGERPRINT, $fingerprint); + // A call would be answered (and recorded), so silence means no call. + $this->mock->append(self::json(200, ['revoked' => TRUE])); + + $this->auth()->disconnect(); + $this->assertCount(0, $this->history, 'The production login is left alone.'); + $this->assertSame([], array_filter($this->store()->getAll())); + } + + /** + * A new connection revokes the login it replaces (Change project). + * + * @covers ::saveConnection + */ + public function testNewConnectionRevokesTheOldLogin(): void { + $this->connect(86400 * 60); + $this->mock->append(self::json(200, ['revoked' => TRUE])); + $this->auth()->saveConnection(['access_token' => 'qca_next', 'refresh_token' => 'qcr_next', 'expires_in' => 3600], 'write-token-2', ['name' => 'Editor']); + $this->assertCount(1, $this->history); + $this->assertSame('Bearer qca_old', $this->history[0]['request']->getHeaderLine('Authorization')); + $this->assertSame('qca_next', $this->store()->get(OAuthTokenStore::ACCESS_TOKEN)); + } + + /** + * An expired access token with a refresh token is not "needs a new login". + * + * @covers ::getReconnectReason + */ + public function testExpiredAccessTokenWithRefreshNeedsNoNewLogin(): void { + $this->connect(3600, [OAuthTokenStore::EXPIRES_AT => time() - 60]); + $this->assertNull($this->auth()->getReconnectReason()); + $this->store()->delete([OAuthTokenStore::REFRESH_TOKEN]); + $this->assertSame(AuthService::RECONNECT_EXPIRED, $this->auth()->getReconnectReason()); + } + +} diff --git a/modules/quant_api/tests/src/Kernel/PushFailureTest.php b/modules/quant_api/tests/src/Kernel/PushFailureTest.php new file mode 100644 index 00000000..6f274717 --- /dev/null +++ b/modules/quant_api/tests/src/Kernel/PushFailureTest.php @@ -0,0 +1,156 @@ +createMock(QuantClientInterface::class); + foreach (['send', 'sendRedirect', 'unpublish'] as $method) { + $client->method($method)->willReturnCallback(function () { + if ($this->next instanceof \Throwable) { + throw $this->next; + } + return $this->next; + }); + } + $this->container->set('quant_api.client', $client); + $this->container->set('quant.output.api', NULL); + } + + /** + * The subscriber under test, built with the stub client. + */ + protected function subscriber() { + return $this->container->get('quant.output.api'); + } + + /** + * A content push event for a path. + */ + protected static function page(string $path): QuantEvent { + return new QuantEvent('Page', $path, [ + 'published' => TRUE, + 'transitions' => [], + 'info' => [], + 'proxy_override' => FALSE, + ]); + } + + /** + * A failed push is stored with a short reason; a later success clears it. + */ + public function testFailureIsKeptUntilThePageIsPushed(): void { + $failures = $this->container->get('quant_api.push_failures'); + + $this->next = new RequestException('Server error with the token in it', new Request('POST', 'https://user:secret@api.example/v1'), new Response(503)); + $this->subscriber()->onOutput(self::page('/news/a')); + $this->next = new ConnectException('cURL error 7 for https://user:secret@api.example', new Request('POST', 'https://api.example')); + $this->subscriber()->onRedirect(new QuantRedirectEvent('/old', '/news/a', 301)); + + $all = $failures->all(); + $this->assertSame(['/news/a', '/old'], array_keys($all)); + $this->assertSame('publish', $all['/news/a']['operation']); + $this->assertSame('Quant answered 503', $all['/news/a']['reason']); + $this->assertSame('Quant could not be reached', $all['/old']['reason']); + $this->assertStringNotContainsString('secret', serialize($all)); + + // The status report says so. + \Drupal::moduleHandler()->loadInclude('quant_api', 'install'); + $requirements = quant_api_requirements('runtime'); + $this->assertArrayHasKey('quant_api_push_failures', $requirements); + $this->assertSame(REQUIREMENT_WARNING, $requirements['quant_api_push_failures']['severity']); + $items = implode(' ', array_map('strval', $requirements['quant_api_push_failures']['description']['list']['#items'])); + $this->assertStringContainsString('/news/a (publish, Quant answered 503', $items); + + // A later successful push of the same page clears its failure only. + $this->next = []; + $this->subscriber()->onOutput(self::page('/news/a')); + $this->assertSame(['/old'], array_keys($failures->all())); + } + + /** + * An unpublish of a page that is already gone is not a failure. + */ + public function testAlreadyUnpublishedIsNoFailure(): void { + $this->next = new \Exception('Resource is already unpublished'); + $this->subscriber()->onUnpublish(new QuantEvent('', '/gone', [])); + $this->assertSame([], $this->container->get('quant_api.push_failures')->all()); + } + + /** + * A change to the list invalidates pages that show it. + */ + public function testChangeInvalidatesTheCacheTag(): void { + $failures = $this->container->get('quant_api.push_failures'); + \Drupal::cache()->set('page', 'cached', -1, [PushFailures::CACHE_TAG]); + $failures->record('/a', 'publish', new \Exception('x')); + $this->assertFalse(\Drupal::cache()->get('page'), 'A new failure invalidates.'); + + \Drupal::cache()->set('page', 'cached', -1, [PushFailures::CACHE_TAG]); + $failures->clear('/not-listed'); + $this->assertNotFalse(\Drupal::cache()->get('page'), 'No change, no invalidation.'); + $failures->clear('/a'); + $this->assertFalse(\Drupal::cache()->get('page'), 'A cleared failure invalidates.'); + } + + /** + * The list keeps the newest 50 pages. + */ + public function testTheListIsCapped(): void { + $failures = $this->container->get('quant_api.push_failures'); + for ($i = 1; $i <= 55; $i++) { + $failures->record('/p' . $i, 'publish', new \Exception('x')); + } + $all = $failures->all(); + $this->assertCount(50, $all); + $this->assertArrayNotHasKey('/p1', $all); + $this->assertArrayHasKey('/p55', $all); + } + +} diff --git a/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php b/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php index e093079c..92744b58 100644 --- a/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php +++ b/modules/quant_api/tests/src/Unit/QuantApiFilesTest.php @@ -14,6 +14,7 @@ use Drupal\quant\Event\QuantFileEvent; use Drupal\quant_api\Client\QuantClientInterface; use Drupal\quant_api\EventSubscriber\QuantApi; +use Drupal\quant_api\PushFailures; use Drupal\Tests\UnitTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -117,7 +118,7 @@ protected function setUp(): void { }); $this->client = $this->createMock(QuantClientInterface::class); - $this->subscriber = new QuantApi($this->client, $logger_factory, $dispatcher); + $this->subscriber = new QuantApi($this->client, $logger_factory, $dispatcher, $this->createMock(PushFailures::class)); } /** diff --git a/modules/quant_api/tests/src/Unit/QuantClientTest.php b/modules/quant_api/tests/src/Unit/QuantClientTest.php index c67a2a36..cccff770 100644 --- a/modules/quant_api/tests/src/Unit/QuantClientTest.php +++ b/modules/quant_api/tests/src/Unit/QuantClientTest.php @@ -6,11 +6,13 @@ use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Messenger\MessengerInterface; use Drupal\quant_api\Client\QuantClient; use Drupal\quant_api\Exception\InvalidPayload; use Drupal\Tests\UnitTestCase; use GuzzleHttp\Client; +use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; @@ -104,10 +106,19 @@ protected function client(array $responses, array $overrides = []) : QuantClient return new QuantClient( new Client(['handler' => $stack]), $this->configFactory($overrides), - $this->createMock(LoggerChannelFactoryInterface::class) + $this->loggerFactory() ); } + /** + * A logger factory whose channel accepts every call. + */ + protected function loggerFactory() : LoggerChannelFactoryInterface { + $factory = $this->createMock(LoggerChannelFactoryInterface::class); + $factory->method('get')->willReturn($this->createMock(LoggerChannelInterface::class)); + return $factory; + } + /** * Builds a config factory returning the test credentials. * @@ -491,4 +502,64 @@ public function testGetOverridesReportsChangedKeys() { $this->assertEquals(['api_project' => 'overridden-project'], $client->getOverrides()); } + /** + * With no token the client sends nothing at all (fail closed). + * + * An unconnected site sent an unpublish with empty credentials when a + * search page was deleted. No call may leave Drupal without a token. + */ + public function testNoTokenMeansNoRequest() : void { + $logger = $this->createMock(LoggerChannelInterface::class); + $logger->expects($this->atMost(1))->method('warning') + ->with($this->stringContains('no project token is set')); + $factory = $this->createMock(LoggerChannelFactoryInterface::class); + $factory->method('get')->willReturn($logger); + $stack = HandlerStack::create(new MockHandler([])); + $stack->push(Middleware::history($this->history)); + $client = new QuantClient(new Client(['handler' => $stack]), $this->configFactory(['api_token' => '']), $factory); + + $this->assertSame([], $client->send(['url' => '/x', 'content' => 'x'])); + $this->assertSame([], $client->sendRedirect(['url' => '/a', 'redirect_url' => '/b'])); + $this->assertSame([], $client->unpublish('/x')); + $this->assertSame([], $client->sendSearchRecords([['url' => '/x']])); + $this->assertSame([], $client->clearSearchIndex()); + $this->assertSame([], $client->addFacets(['tags_en'])); + $this->assertSame([], $client->getUrlMeta(['/x'])); + $this->assertFalse($client->ping()); + $this->assertFalse($client->project()); + $this->assertFalse($client->search()); + + $this->assertCount(0, $this->history, 'No request was sent.'); + } + + /** + * An unreachable endpoint is a plain message, not a crash or a URL leak. + * + * Guzzle's ConnectException is not a RequestException, so a catch of the + * latter let it through and the settings page failed with an error page. + * The exception text carries the endpoint URL, which can hold credentials. + */ + public function testUnreachableEndpointIsReportedPlainly() : void { + $messenger = $this->createMock(MessengerInterface::class); + $shown = []; + $messenger->method('addError')->willReturnCallback(function ($message) use (&$shown, $messenger) { + $shown[] = (string) $message; + return $messenger; + }); + \Drupal::getContainer()->set('messenger', $messenger); + + $down = fn () => new ConnectException('cURL error 7: Failed to connect for https://user:secret@uploads.example/ping', new Request('GET', 'https://user:secret@uploads.example/ping')); + $client = $this->client([$down(), $down(), $down()]); + + $this->assertFalse($client->ping()); + $this->assertTrue($client->lastCallUnreachable()); + $this->assertFalse($client->project()); + $this->assertFalse($client->search()); + $this->assertNotEmpty($shown); + foreach ($shown as $message) { + $this->assertStringContainsString('could not reach Quant', $message); + $this->assertStringNotContainsString('secret', $message); + } + } + } diff --git a/modules/quant_search/config/install/quant_search.entities.settings.yml b/modules/quant_search/config/install/quant_search.entities.settings.yml index 39c0f866..af993850 100644 --- a/modules/quant_search/config/install/quant_search.entities.settings.yml +++ b/modules/quant_search/config/install/quant_search.entities.settings.yml @@ -1,6 +1,6 @@ -quant_search_entity_node: 1 +quant_search_entity_node: true quant_search_title_token: '[node:title]' quant_search_summary_token: '[node:summary]' quant_search_content_viewmode: full quant_search_image_token: '[node:field_image:thumbnail:url]' -quant_search_entity_taxonomy_term: 1 +quant_search_entity_taxonomy_term: true diff --git a/modules/quant_search/src/Controller/Search.php b/modules/quant_search/src/Controller/Search.php index 27852623..bb77b1ac 100644 --- a/modules/quant_search/src/Controller/Search.php +++ b/modules/quant_search/src/Controller/Search.php @@ -61,7 +61,7 @@ public function statusPage() { $searchEnabled = $this->enabled(); if (!$searchEnabled) { - if ($config->get('api_token') && $project = $this->client->project()) { + if (\Drupal::service('quant_api.credentials')->hasToken($config) && $project = $this->client->project()) { \Drupal::messenger()->addError(t('Search is not enabled for this project. It can be enabled via the Quant Dashboard. See Quant Search documentation.')); } else { @@ -390,7 +390,7 @@ public function administerAccess(AccountInterface $account) { public function enabled() { $config = $this->config(self::SETTINGS); - $searchEnabled = ($config->get('api_token') && $this->client->project() && $this->client->project()->config->search_enabled); + $searchEnabled = (\Drupal::service('quant_api.credentials')->hasToken($config) && $this->client->project() && $this->client->project()->config->search_enabled); if ($searchEnabled) { \Drupal::messenger()->addMessage(t('Search is enabled for project %api.', ['%api' => $config->get('api_project')])); } diff --git a/quant.install b/quant.install index d64bf072..e54b7481 100644 --- a/quant.install +++ b/quant.install @@ -204,3 +204,74 @@ function quant_update_9008() { } return implode('\n\n', $message); } + +/** + * Store the on/off settings as booleans, as their schema says. + * + * Sites that installed the module on Drupal 11.1 or earlier kept 0 and 1 from + * config/install (quant.settings, quant.token_settings, and the Quant Search + * entity settings), which fails strict config schema checks. + */ +function quant_update_9009() { + $booleans = [ + 'quant.settings' => [ + 'quant_enabled', + 'quant_enabled_nodes', + 'quant_enabled_taxonomy', + 'quant_enabled_views', + 'quant_enabled_redirects', + 'ssl_cert_verify', + 'disable_content_drafts', + 'quant_show_page_info_block', + ], + 'quant.token_settings' => ['disable', 'strict'], + // Quant Search settings, if that submodule is on. + 'quant_search.entities.settings' => [ + 'quant_search_entity_node', + 'quant_search_entity_taxonomy_term', + ], + ]; + foreach ($booleans as $name => $keys) { + $config = \Drupal::configFactory()->getEditable($name); + if ($config->isNew()) { + continue; + } + foreach ($keys as $key) { + $value = $config->get($key); + if ($value !== NULL && !is_bool($value)) { + $config->set($key, (bool) $value); + } + } + $config->save(TRUE); + } +} + +/** + * Keep only the ticked keys in the Quant metadata Views field option. + * + * The option came from checkboxes and kept 0 for each unticked key, which + * fails strict config schema checks. The field reads only ticked keys, so + * the result is the same. + */ +function quant_update_9010() { + $factory = \Drupal::configFactory(); + foreach ($factory->listAll('views.view.') as $name) { + $view = $factory->getEditable($name); + $changed = FALSE; + foreach ((array) $view->get('display') as $display_id => $display) { + foreach ((array) ($display['display_options']['fields'] ?? []) as $field_id => $field) { + if (($field['plugin_id'] ?? '') !== 'quant_metadata' || !is_array($field['quant_metadata'] ?? NULL)) { + continue; + } + $kept = array_filter($field['quant_metadata']); + if ($kept !== $field['quant_metadata']) { + $view->set("display.$display_id.display_options.fields.$field_id.quant_metadata", $kept); + $changed = TRUE; + } + } + } + if ($changed) { + $view->save(TRUE); + } + } +} diff --git a/quant.module b/quant.module index eed9d5a1..07ca1c1f 100644 --- a/quant.module +++ b/quant.module @@ -226,7 +226,7 @@ function quant_entity_predelete(EntityInterface $entity) { // This needs to not be a shutdown hook and also needs to be on // the predelete hook as the path alias is removed in the entity // delete clean-up. - _quant_unpublish_entity($entity); + _quant_unpublish_entity($entity, TRUE); // Handle any translations. if (method_exists($entity, 'isTranslatable') && $entity->isTranslatable() && method_exists($entity, 'getTranslationLanguages')) { @@ -234,7 +234,7 @@ function quant_entity_predelete(EntityInterface $entity) { if ($language->getId() != $entity->language()->getId()) { $translation = $entity->getTranslation($language->getId()); - _quant_unpublish_entity($translation); + _quant_unpublish_entity($translation, TRUE); } } } @@ -401,14 +401,17 @@ function _quant_entity_update_op(EntityInterface $entity) { * * @param Drupal\Core\Entity\EntityInterface $entity * The entity. + * @param bool $deleted + * TRUE when the entity or translation is being deleted, so its internal + * paths are unpublished instead of redirected. */ -function _quant_unpublish_entity(EntityInterface $entity) { +function _quant_unpublish_entity(EntityInterface $entity, bool $deleted = FALSE) { // @todo Try to handle entities more generically. // @todo Check tracking is enabled for entity first. switch ($entity->getEntityTypeId()) { case 'node': - Seed::unpublishNode($entity); + Seed::unpublishNode($entity, $deleted); break; case 'path_alias': @@ -416,7 +419,7 @@ function _quant_unpublish_entity(EntityInterface $entity) { break; case 'taxonomy_term': - Seed::unpublishTaxonomyTerm($entity); + Seed::unpublishTaxonomyTerm($entity, $deleted); break; case 'file': @@ -448,7 +451,7 @@ function quant_entity_translation_delete($entity) { return; } - _quant_unpublish_entity($entity); + _quant_unpublish_entity($entity, TRUE); } @@ -555,7 +558,7 @@ function quant_modules_uninstalled($modules) { } /** - * Unpublish old alias if it changes. + * Unpublish an old alias when it changes, and redirect it to the new one. * * @param Drupal\Core\Entity\EntityInterface $pathAlias * The path alias. @@ -575,9 +578,11 @@ function quant_path_alias_presave(EntityInterface $pathAlias) { $alias = $pathAlias->get('alias')->value; $langcode = $pathAlias->get('langcode')->value; - // Unpublish original alias if it changed. + // Unpublish the original alias if it changed, and send a 301 from it to + // the new alias so links and search results to the old URL keep working. if ($original_path == $path && $langcode == $original_langcode && $original_alias != $alias) { Seed::unpublishPathAlias($pathAlias->original); + Seed::redirectChangedAlias($pathAlias->original, $pathAlias); } } diff --git a/src/Form/SeedForm.php b/src/Form/SeedForm.php index f8f63b7b..4681b293 100644 --- a/src/Form/SeedForm.php +++ b/src/Form/SeedForm.php @@ -340,7 +340,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { return; } - if ($config->get('api_token')) { + if (\Drupal::service('quant_api.credentials')->hasToken()) { if (!$project = $this->client->ping()) { \Drupal::messenger()->addError(t('Unable to connect to Quant API, check settings.')); return; diff --git a/src/Plugin/views/field/QuantMetadata.php b/src/Plugin/views/field/QuantMetadata.php index fd58ce74..d4c93b21 100644 --- a/src/Plugin/views/field/QuantMetadata.php +++ b/src/Plugin/views/field/QuantMetadata.php @@ -70,6 +70,21 @@ protected function defineOptions() { return $options; } + /** + * {@inheritdoc} + * + * Keeps only the ticked metadata keys, so the option is a list of strings, + * as its config schema says. + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + $options = $form_state->getValue('options'); + if (isset($options['quant_metadata']) && is_array($options['quant_metadata'])) { + $options['quant_metadata'] = array_filter($options['quant_metadata']); + $form_state->setValue('options', $options); + } + parent::submitOptionsForm($form, $form_state); + } + /** * {@inheritdoc} */ diff --git a/src/Seed.php b/src/Seed.php index 2a98531e..3af030d9 100644 --- a/src/Seed.php +++ b/src/Seed.php @@ -373,8 +373,10 @@ public static function seedNode(EntityInterface $entity, $langcode = NULL) { * * @param Drupal\Core\Entity\EntityInterface $entity * The node entity. + * @param bool $deleted + * TRUE when the node is being deleted. */ - public static function unpublishNode(EntityInterface $entity) { + public static function unpublishNode(EntityInterface $entity, bool $deleted = FALSE) { $langcode = $entity->language()->getId(); $nid = $entity->get('nid')->value; @@ -386,8 +388,14 @@ public static function unpublishNode(EntityInterface $entity) { Utility::unpublishUrl('/', 'Unpublished home page'); } - // Handle internal path redirects. - self::handleInternalPathRedirects($entity, $langcode, $url); + // A deleted node keeps no /node/N redirect. An unpublished one keeps it + // while another translation is still published. + if ($deleted) { + self::unpublishInternalPaths('node', (int) $nid, $langcode, $entity->isDefaultTranslation()); + } + else { + self::handleInternalPathRedirects($entity, $langcode, $url); + } Utility::unpublishUrl($url, 'Unpublished content page'); } @@ -397,15 +405,21 @@ public static function unpublishNode(EntityInterface $entity) { * * @param Drupal\Core\Entity\EntityInterface $entity * The term entity. + * @param bool $deleted + * TRUE when the term is being deleted. */ - public static function unpublishTaxonomyTerm(EntityInterface $entity) { + public static function unpublishTaxonomyTerm(EntityInterface $entity, bool $deleted = FALSE) { $langcode = $entity->language()->getId(); $tid = $entity->get('tid')->value; $url = Utility::getCanonicalUrl('taxonomy_term', $tid, $langcode); - // Handle internal path redirects. - self::handleInternalPathRedirects($entity, $langcode, $url); + if ($deleted) { + self::unpublishInternalPaths('taxonomy_term', (int) $tid, $langcode, $entity->isDefaultTranslation()); + } + else { + self::handleInternalPathRedirects($entity, $langcode, $url); + } Utility::unpublishUrl($url, 'Unpublished taxonomy term page'); } @@ -585,7 +599,36 @@ public static function unpublishView(EntityInterface $entity) { * Unpublish path alias via API request. */ public static function unpublishPathAlias($pathAlias) { + Utility::unpublishUrl(self::aliasUrl($pathAlias), 'Unpublished path alias'); + } + + /** + * Sends a 301 from an old alias to the new one, so old links keep working. + * + * Only for published content and only when the Redirect module is off: + * with Redirect on, its own redirect entity is pushed instead. + * + * @param \Drupal\path_alias\PathAliasInterface $original + * The alias before the change. + * @param \Drupal\path_alias\PathAliasInterface $pathAlias + * The alias after the change. + */ + public static function redirectChangedAlias($original, $pathAlias) : void { + if (\Drupal::moduleHandler()->moduleExists('redirect') || !self::isPublishedEntityPath((string) $pathAlias->get('path')->value, (string) $pathAlias->get('langcode')->value)) { + return; + } + $from = self::aliasUrl($original); + $to = self::aliasUrl($pathAlias); + if ($from === $to || !trim($to)) { + return; + } + \Drupal::service('event_dispatcher')->dispatch(new QuantRedirectEvent($from, $to, 301), QuantRedirectEvent::UPDATE); + } + /** + * The public URL of a path alias, with the language prefix if any. + */ + protected static function aliasUrl($pathAlias) : string { $langcode = $pathAlias->get('langcode')->value; $alias = Utility::getUrl($pathAlias->get('alias')->value, $langcode); @@ -593,8 +636,48 @@ public static function unpublishPathAlias($pathAlias) { if ($langcode == LanguageInterface::LANGCODE_NOT_SPECIFIED) { $alias = str_replace("/{$langcode}", '', $alias); } + return $alias; + } + + /** + * Whether a system path is a published node or taxonomy term. + * + * The translation in the alias language decides, when it exists. + */ + protected static function isPublishedEntityPath(string $path, string $langcode) : bool { + if (!preg_match('#^/(node|taxonomy/term)/(\d+)$#', $path, $m)) { + return FALSE; + } + $type = $m[1] === 'node' ? 'node' : 'taxonomy_term'; + $entity = \Drupal::entityTypeManager()->getStorage($type)->load($m[2]); + if ($entity && method_exists($entity, 'hasTranslation') && $entity->hasTranslation($langcode)) { + $entity = $entity->getTranslation($langcode); + } + return $entity && (!method_exists($entity, 'isPublished') || $entity->isPublished()); + } - Utility::unpublishUrl($alias, 'Unpublished path alias'); + /** + * Unpublishes the internal paths of deleted content. + * + * @param string $type + * The entity type: node or taxonomy_term. + * @param int $id + * The entity ID. + * @param string $langcode + * The language of the deleted translation. + * @param bool $whole + * TRUE when the whole entity is deleted, so the unprefixed internal path + * goes too; FALSE for one translation, which loses only its prefixed path. + */ + protected static function unpublishInternalPaths(string $type, int $id, string $langcode, bool $whole) : void { + $internalPath = ($type == 'node') ? "/node/{$id}" : "/taxonomy/term/{$id}"; + if ($whole) { + Utility::unpublishUrl($internalPath, 'Unpublished internal path'); + } + $prefix = Utility::getPathPrefix($langcode); + if ($prefix !== '' && $prefix !== '/') { + Utility::unpublishUrl("{$prefix}{$internalPath}", 'Unpublished internal path'); + } } /** diff --git a/tests/src/Kernel/AliasChangeRedirectTest.php b/tests/src/Kernel/AliasChangeRedirectTest.php new file mode 100644 index 00000000..1c15c51f --- /dev/null +++ b/tests/src/Kernel/AliasChangeRedirectTest.php @@ -0,0 +1,118 @@ +installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('path_alias'); + $this->installSchema('node', ['node_access']); + $this->installConfig(['quant_api']); + NodeType::create(['type' => 'page', 'name' => 'Page'])->save(); + + $dispatcher = $this->container->get('event_dispatcher'); + $dispatcher->addListener(QuantRedirectEvent::UPDATE, function (QuantRedirectEvent $event) { + $this->events[] = 'redirect ' . $event->getSourceUrl() . ' -> ' . $event->getDestinationUrl() . ' ' . $event->getStatusCode(); + }); + $dispatcher->addListener(QuantEvent::UNPUBLISH, function (QuantEvent $event) { + $this->events[] = 'unpublish ' . $event->getLocation(); + }); + } + + /** + * Renaming a published page unpublishes the old URL and redirects it. + */ + public function testRenamedPublishedPageRedirects() : void { + $alias = $this->pageWithAlias(TRUE, '/news/old-title'); + $this->events = []; + $alias->setAlias('/news/new-title')->save(); + + $this->assertSame([ + 'unpublish /news/old-title', + 'redirect /news/old-title -> /news/new-title 301', + ], $this->events); + } + + /** + * An unpublished page gets no redirect to a URL that is not on the site. + */ + public function testRenamedUnpublishedPageDoesNotRedirect() : void { + $alias = $this->pageWithAlias(FALSE, '/news/draft-title'); + $this->events = []; + $alias->setAlias('/news/draft-title-2')->save(); + + $this->assertSame(['unpublish /news/draft-title'], $this->events); + } + + /** + * An alias for a path that is not a node or term gets no redirect. + */ + public function testOtherPathsDoNotRedirect() : void { + $alias = PathAlias::create(['path' => '/user/login', 'alias' => '/sign-in', 'langcode' => 'und']); + $alias->save(); + $this->events = []; + $alias->setAlias('/log-in')->save(); + + $this->assertSame(['unpublish /sign-in'], $this->events); + } + + /** + * Creates a page and its alias. + */ + protected function pageWithAlias(bool $published, string $path) : PathAlias { + $node = Node::create(['type' => 'page', 'title' => 'A page', 'status' => $published]); + $node->save(); + $alias = PathAlias::create(['path' => '/node/' . $node->id(), 'alias' => $path, 'langcode' => 'und']); + $alias->save(); + return $alias; + } + +} diff --git a/tests/src/Kernel/EntityDeleteUnpublishTest.php b/tests/src/Kernel/EntityDeleteUnpublishTest.php new file mode 100644 index 00000000..43f7d690 --- /dev/null +++ b/tests/src/Kernel/EntityDeleteUnpublishTest.php @@ -0,0 +1,113 @@ +installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('taxonomy_term'); + $this->installEntitySchema('path_alias'); + $this->installSchema('node', ['node_access']); + $this->installConfig(['quant', 'quant_api']); + // Delete handling needs only quant_enabled. Push on save stays off, so + // creating the test content queues no shutdown push. + $this->config('quant.settings') + ->set('quant_enabled', TRUE) + ->set('quant_enabled_nodes', FALSE) + ->set('quant_enabled_taxonomy', FALSE) + ->save(); + NodeType::create(['type' => 'page', 'name' => 'Page'])->save(); + Vocabulary::create(['vid' => 'topic', 'name' => 'Topic'])->save(); + + $dispatcher = $this->container->get('event_dispatcher'); + $dispatcher->addListener(QuantRedirectEvent::UPDATE, function (QuantRedirectEvent $event) { + $this->events[] = 'redirect ' . $event->getSourceUrl() . ' -> ' . $event->getDestinationUrl(); + }); + $dispatcher->addListener(QuantEvent::UNPUBLISH, function (QuantEvent $event) { + $this->events[] = 'unpublish ' . $event->getLocation(); + }); + } + + /** + * A deleted published node loses its page and its /node/N redirect. + */ + public function testDeletedNodeUnpublishesInternalPath() : void { + $node = Node::create(['type' => 'page', 'title' => 'Temporary', 'status' => 1]); + $node->save(); + PathAlias::create(['path' => '/node/' . $node->id(), 'alias' => '/temporary', 'langcode' => 'und'])->save(); + $this->events = []; + + $node->delete(); + + $this->assertContains('unpublish /temporary', $this->events); + $this->assertContains('unpublish /node/' . $node->id(), $this->events); + $this->assertEmpty(preg_grep('/^redirect /', $this->events), 'No redirect is sent for deleted content.'); + } + + /** + * A deleted published term loses its page and its /taxonomy/term/N path. + */ + public function testDeletedTermUnpublishesInternalPath() : void { + $term = Term::create(['vid' => 'topic', 'name' => 'Drought', 'status' => 1]); + $term->save(); + PathAlias::create(['path' => '/taxonomy/term/' . $term->id(), 'alias' => '/topics/drought', 'langcode' => 'und'])->save(); + $this->events = []; + + $term->delete(); + + $this->assertContains('unpublish /topics/drought', $this->events); + $this->assertContains('unpublish /taxonomy/term/' . $term->id(), $this->events); + $this->assertEmpty(preg_grep('/^redirect /', $this->events), 'No redirect is sent for deleted content.'); + } + +} diff --git a/tests/src/Kernel/SettingsBooleanTest.php b/tests/src/Kernel/SettingsBooleanTest.php new file mode 100644 index 00000000..e7cdfb71 --- /dev/null +++ b/tests/src/Kernel/SettingsBooleanTest.php @@ -0,0 +1,97 @@ +container->get('config.storage')->write('quant.settings', $values + ['xpath_selectors' => '']); + $this->container->get('config.factory')->reset('quant.settings'); + $this->assertSame(1, $this->config('quant.settings')->get('quant_enabled')); + + \Drupal::moduleHandler()->loadInclude('quant', 'install'); + quant_update_9009(); + + $settings = $this->config('quant.settings'); + foreach (self::KEYS as $key) { + $this->assertIsBool($settings->get($key), "$key is a boolean after the update."); + } + $this->assertTrue($settings->get('quant_enabled')); + $this->assertFalse($settings->get('disable_content_drafts')); + + // The token settings too. + $this->container->get('config.storage')->write('quant.token_settings', [ + 'disable' => 0, + 'strict' => 1, + 'secret' => 's', + 'timeout' => '+1 minute', + ]); + $this->container->get('config.factory')->reset('quant.token_settings'); + quant_update_9009(); + $this->assertFalse($this->config('quant.token_settings')->get('disable')); + $this->assertTrue($this->config('quant.token_settings')->get('strict')); + } + + /** + * The update drops the unticked keys of the Quant metadata Views option. + */ + public function testUpdateFiltersTheViewsOption(): void { + $name = 'views.view.quant_metadata_node'; + $path = 'display.default.display_options.fields.quant_metadata.quant_metadata'; + $file = $this->root . '/' . \Drupal::service('extension.list.module')->getPath('quant') . "/config/install/$name.yml"; + $view = Yaml::decode(file_get_contents($file)); + $option = &$view['display']['default']['display_options']['fields']['quant_metadata']['quant_metadata']; + $option['md5'] = 0; + $option['seq_num'] = 0; + $this->container->get('config.storage')->write($name, $view); + $this->container->get('config.factory')->reset($name); + + \Drupal::moduleHandler()->loadInclude('quant', 'install'); + quant_update_9010(); + + $kept = $this->config($name)->get($path); + $this->assertArrayNotHasKey('md5', $kept); + $this->assertArrayNotHasKey('seq_num', $kept); + $this->assertSame('url', $kept['url']); + } + +} diff --git a/tests/src/Kernel/ShippedConfigSchemaTest.php b/tests/src/Kernel/ShippedConfigSchemaTest.php new file mode 100644 index 00000000..d0e529bf --- /dev/null +++ b/tests/src/Kernel/ShippedConfigSchemaTest.php @@ -0,0 +1,90 @@ +root . '/' . \Drupal::service('extension.list.module')->getPath('quant'); + $files = glob($root . '{,/modules/*}/config/{install,optional}/*.yml', GLOB_BRACE); + $this->assertNotEmpty($files); + + $typed = \Drupal::service('config.typed'); + $handler = \Drupal::moduleHandler(); + $errors = []; + $checked = 0; + foreach ($files as $file) { + $name = basename($file, '.yml'); + $data = Yaml::decode(file_get_contents($file)); + // Optional config whose modules are not here would not be installed. + $needs = $data['dependencies']['module'] ?? []; + if (array_filter($needs, fn ($module) => !$handler->moduleExists($module))) { + continue; + } + $result = $this->checkConfigSchema($typed, $name, $data); + $checked++; + if ($result === FALSE) { + $errors[] = "$name: no schema"; + } + elseif (is_array($result)) { + foreach ($result as $key => $error) { + $errors[] = "$key: $error"; + } + } + } + $this->assertGreaterThanOrEqual(9, $checked, 'The shipped config files were checked.'); + $this->assertSame([], $errors, "Shipped config that does not match its schema:\n" . implode("\n", $errors)); + } + +}