Skip to content
V
For developers
Architecture, conventions, the core and security
Core / External connections

External connections#

Third-party services (reCAPTCHA, Google Analytics, Tag Manager, Search Console, Maps) whose keys the operator switches on and fills in the administration — without editing configuration and without clearing the cache. For operators the same is in Third-party connections. Core: app/Core/Integrations/Connections/.

The model#

A connection is created by installation, not by a click in the administration: a row in the table cms_system_external_connections is written by an SQL script. The table holds just four things:

Column Meaning
type (UNIQUE) the type code (recaptcha_v3, google_maps, …)
enabled on = the site uses the connection
config a JSON object key => scalar\|null; holds secret keys
insert_datetime, update_datetime times

Which fields a type has is not stated by the database but by code: the type registry. The stored JSON carries only values.

Class Job
ConnectionTypeInterface getCode(), getGroup(), getFields(), supportsCheck(), check()
ConnectionField name, kind, required, public
ConnectionFieldKind Text, Secret, Bool, Number
ConnectionTypeRegistry types by code; normalize() drops undeclared keys and casts
ExternalConnections runtime reading: isEnabled(), values(), value(), publicValues(), reset()
GoogleIntegrations the values of the Google services as the site uses them
  • A group (getGroup()) marks alternatives: connections of the same group switch each other off (captcha = reCAPTCHA v2 × v3). null = an independent connection.
  • public on a field says the value may go to the browser (it is put into the page). A secret field (Secret) is public never — ConnectionField::isPublic() enforces it.
  • supportsCheck() decides whether the administration offers the Ověřit připojení (verify the connection) button. Today for no type: since 2025 Google siteverify cannot tell a wrong secret key without a real token from a browser (it always returns invalid-input-response), so a test without a browser proves nothing.

Runtime reading#

ExternalConnections reads the rows through QueryCache::rememberTagged() with the tag ExternalConnection; saving in the administration bumps the tag, so a change applies at once. Within one request it keeps the result in memory; after a write in the same request reset() is called.

A missing table must not take the site down

When the rows cannot be read (the migration has not run yet), the warning EXTERNAL_CONNECTIONS_READ_ERROR is logged and every connection looks as not installed. The consumers fall back to their default behaviour. A damaged cache record does not turn a connection on.

The cache needs the service in enabledServices

For the rows to be cached, App\UI\Api\System\Models\Services\ExternalConnectionService must be in cache.config.enabledServices in the server's local.neon. Without it the application works but makes one query per request. The same holds for SettingService. See Cache.

Consumers#

Type Who reads it How
recaptcha_v2, recaptcha_v3 ReCaptchaRuntime see below
google_analytics, google_tag_manager, google_search_console analytics.latte of all 18 themes $googleIntegrations from the Front BasePresenter, GoogleIntegrations::forPageHead()
google_maps the GoogleMap component (through a factory) and GoogleGeocoder GoogleIntegrations::mapsKey(), mapId(); geocoding reads geocodingKey() (the server key, otherwise the pages key), see Google Maps

GoogleIntegrations returns a value only for an enabled and filled-in connection, otherwise null (the service is off). The analytics.latte templates embed the measurement scripts as type="text/plain"; cookie-bar.js starts them after consent to the cookie category.

Where the values came from

Until October 2026 the Google values lived in cms_system_settings (module 1: googleAnalyticsId, googleTagManagerId, googleSiteVerification, googleMapsKey). They moved into connections and disappeared from the site settings.

Which categories the cookie bar offers is set by a single file, config/Shared/cookieConsent.neon, read by the App\Core\Cookie\CookieConsentConfig service. The same list drives the switches of the front-end bar (CookieBar) and the "cookie consent" choice for custom code in the administration (selectableKeys() — only the categories a visitor can turn off).

Key Meaning
categories {key, readonly}; order = order of the switches. readonly: true = always granted, the switch is locked and the administration does not offer it
revision consent version; raising it invalidates all granted consents and the bar shows again
expirationDays consent lifetime in days (default 182)

Procedure: add a category

  1. Add {key: <key>, readonly: false} to categories.
  2. Add the translations front.base.components.cookieBar.categories.<key>.title and .description in all languages (app/Locale/base/<locale>/front.<LOCALE>.neon).
  3. If the category maps to Google Consent Mode or deletes cookies on withdrawal, extend CONSENT_MODE_SIGNALS and CATEGORY_COOKIES in www/themes/frontend/<template>/desktop/assets/js/components/cookie-bar.js, spread the change to all templates (bin/theme-js-sync.php) and run webpack.
  4. Raise revision.

Without a higher revision the new category stays silently denied

An earlier consent does not cover it. Anyone who has already clicked never sees the bar again, and a script waiting for the new category never runs for them.

That is why categories are not in the database or the administration

A category enabled without translations and without handling in cookie-bar.js would promise the visitor a choice nothing implements: the switch can be turned off and nothing happens.

Google Maps: keys and component variants#

The google_maps connection (GoogleMapsConnection) has three fields:

Field Kind Access
apiKey text, required, public GoogleIntegrations::mapsKey() — the key for the pages, visible to the visitor
mapId text, optional, public GoogleIntegrations::mapId() — only when the pages key is there too
serverApiKey secret, optional GoogleIntegrations::geocodingKey() — serverApiKey, otherwise mapsKey(); never written into the page

All three accessors return a value only for an enabled connection with a pages key; a serverApiKey alone without apiKey therefore opens nothing. For why there are two keys see Maps and address lookup: the Geocoding web service rejects a key restricted to domains.

Variants of the GoogleMap component#

GoogleMap::render($latitude, $longitude, $info, $style = GoogleMap::STYLE_EMBED):

Constant Template What it does
STYLE_EMBED (default) Style_2.latte an <iframe> with the Maps Embed API and an "Otevřít v Google Mapách" link; it loads neither the Maps JavaScript API nor googlemap.js
STYLE_DYNAMIC Style_1.latte a <div> with data-latitude, data-longitude, data-info, data-api-key, data-map-id attributes, which googlemap.js brings to life

Without coordinates nothing is rendered. The embed variant additionally does not render without the pages key. The dynamic map is called explicitly with a fourth argument: {control frontSystemGoogleMap $lat, $lng, $info, 'Style_1'}. No template calls it today — all 22 places use the default embedded map.

Embed the map where showing a single place is enough

An embedded map has no counted loads, whereas the dynamic one has a monthly free limit at Google (see the Google Maps Platform price list). Call the dynamic one only where you need the controls or your own markers.

App\Core\Integrations\Maps\GoogleMapsLinks builds Google's addresses — a pure function without state:

Method Returns
embedUrl($apiKey, $lat, $lng, $address, $language, $zoom, $mapType) the src for the <iframe>, or null without a key or a place
openUrl($lat, $lng, $address) the "Open in Google Maps" link; it needs no key

Coordinates win over the address (they are exact); they are valid only within ±90 / ±180. zoom is clamped to 0–21, mapType is roadmap or satellite, the language is taken only in the shape cs or cs-cz. The parameters are built with http_build_query (RFC 3986), so the text of the address is never put into the URL by hand.

Inserting text into the bubble safely#

The text in the dynamic map's bubble (an advert's name — entered by the user) is never put into the page as HTML. googlemap.js reads it from the data-info attribute, puts it into a <strong> node through textContent and passes that to the InfoWindow as a DOM node — < and > therefore show literally. It used to pass an HTML string, so markup typed into an advert name could be interpreted as HTML in the bubble. The test tests/Unit/JavaScript/googlemap-no-html-info.test.mjs guards it.

The script is in every theme and the bundle must be rebuilt

googlemap.js sits in assets/js/components/ of every theme and goes into its dist/bundle.js. A fix of the script therefore means an edit in all themes and a rebuild of their bundles (the commit with the bubble fix touched all 18). An old bundle would not carry the fix.

Other properties of the dynamic map: Google Maps loads with loading=async and libraries through importLibrary; with mapId filled in AdvancedMarkerElement is used (the gmp-click event), without it the older google.maps.Marker. With mapId Google ignores the styles option — the map style is controlled in the Cloud console.

Address lookup over IPv4#

GoogleGeocoder::geocode() takes geocodingKey() and calls Google through LocationHelper::lookupAddress() — a bounded HTTPS GET (timeouts, no redirects, a cap on the response size). A failure is not logged with the URL, because it carries the key. The request goes over IPv4 (CURLOPT_IPRESOLVE = CURL_IPRESOLVE_V4, in the fallback branch through the stream's bindto => '0:0'): the server key is often restricted to an IP address and the machine's IPv6 address changes, so it would not match the allowed ones. Without a key the geocoder returns an error and the caller falls back to the municipality centre.

reCAPTCHA#

App\Core\Forms\ReCaptcha\ReCaptchaRuntime decides at runtime whether the forms use reCAPTCHA, which version and with which keys. The order of authority:

  1. security.reCaptcha: false in the configuration is a hard off switch (localhost, where Google does not work; a connection enabled in the database must not be able to turn it on).
  2. The enabled connection recaptcha_v3 with its own site key → v3.
  3. Otherwise the enabled recaptcha_v2.

The keys live only in the connection. reCaptchaSiteKey and reCaptchaSecretKey, the recaptcha: section and the Contributte DI extension disappeared from the configuration; the method addInvisibleReCaptcha() is registered only by ReCaptchaToggleExtension. Without both keys reCAPTCHA is off. v3 has no fallback keys.

Part Job
RuntimeReCaptchaBinding picks the real or the disabled field when the form is built, not when the container is compiled; the disabled variant is DisabledReCaptchaField
RuntimeReCaptchaProvider::validate() sets the score threshold from the runtime (getMinimalScore(): v3 default 0.5, v2 no threshold)
form field carries data-version (v2 / v3), which selects the flow on the front
invisibleRecaptcha.js v2 renders an invisible widget; v3 loads api.js?render=<sitekey>, calls execute() on submit and puts the token into g-recaptcha-response; on error it submits without a token like v2

addInvisibleReCaptcha('recaptcha', true, $t('recaptcha.required')) is mandatory in all 11 forms; an empty token gets the same message as a rejected one. Two notes:

A new reCAPTCHA field = required: true

With false a submission with no token at all passes. Add the message recaptcha.required to the form's (module's) translations, otherwise a bare key is printed.

The JavaScript is canonical in template2

invisibleRecaptcha.js is maintained in template2 and copied into the other themes by php bin/theme-js-sync.php --apply. After a change bump the version in the link (?v=).

Procedure: add a new connection type#

  1. A class App\Core\Integrations\Connections\Types\<Name>Connection implementing ConnectionTypeInterface: a CODE constant, getGroup() (null, or a group code), getFields(). Fields that may go into the page have public: true; secret ones Secret. Required fields have required: true (checked only for an enabled connection).
  2. Registration in config/Shared/services.neon: the class into the list of services and into the registry array externalConnectionTypes.
  3. Installation SQL sql/<date>_<NN>_install_<code>_connection.sql:
INSERT IGNORE INTO `cms_system_external_connections` (`type`, `enabled`, `config`)
VALUES ('my_type', 0, NULL);

Idempotent, disabled. A rollback with a high prefix. 4. Translations admin.system.presenters.externalConnection.form.types.<code>.{title,description} and ….fields.<field>.{label,description} in cs, en and sk (Locale/system/*/admin.*.neon). A missing key breaks the card. 5. A consumer: read through ExternalConnections::isEnabled() / value(); your own class, or an extension of GoogleIntegrations. Read only for an enabled and filled-in connection. Never put a secret field into a page. 6. A test: field normalisation, a disabled connection = null, a missing row = null. 7. Deployment: SQL before the code (the code expects the table, but without the row it counts as not installed); after deployment clear temp/cache (a new service in DI).

The admin page adds and deletes nothing

A card appears only for an installed row. A row whose type the code does not know is listed as unsupported and does not break the page; it cannot be edited.

Administration#

Thing Detail
presenter Admin\System\Presenters\ExternalConnectionPresenter, address /admin/system-napojeni-tretich-stran/prehled, a tab of the Settings section
form one per row (connectionForm-<id> Multiplier); ExternalConnectionFormTrait
secret field addPassword, the value is never written into HTML; an empty submission = keep, a filled one = overwrite, a ticked clear_<field> = delete
validation required fields only for an enabled connection; for a secret one a stored value counts as filled (without a ticked "Delete")
group switching on turns off the other enabled connections of the same group (flash groupDisabled)
save one Api operation saveExclusive() in one DB transaction: locks the group rows, writes the card and turns off the alternatives; on an error nothing changes (ExternalConnectionGroupSaveTrait)
field names prefixed field_ and clear_, so a type field never collides with enabled or another form control
after saving the card stays open (one-off through the session), the others collapsed
permissions admin :Admin:System:ExternalConnection:show; Api :Api:System:ExternalConnection:* administrator only

The Api resource external-connection carries secret keys

config is read whole from it. RBAC (SQL …_02) allows reading and writing only to the role admin. No grant for guest or registrated.

The debug panel (Tracy) shows the stored secret key in the SQL and dump panels

Do not switch debug on on a server with live keys.

Migrations#

The order on deployment:

File What it does
2026-10-02_01_system_external_connections.sql the table and the row recaptcha_v2 (on, without keys)
…_02_api_system_external_connection_privileges.sql Api RBAC (admin only)
…_03_admin_external_connection_page.sql the admin page and the key :Admin:System:ExternalConnection:show
…_04_install_recaptcha_v3_connection.sql the row recaptcha_v3 (off)
…_05_install_google_connections.sql the four Google types; takes over values from cms_system_settings (filled = on) — before deploying the code
…_06_remove_google_settings.sql deletes the old settings rows — after deploying the code

Rollbacks …_91 to …_96. After applying _01–_03 clear temp/cache and flush Redis (RBAC and the router are cached).

Where to look#

I want Where
the connection types app/Core/Integrations/Connections/Types/
runtime reading app/Core/Integrations/Connections/ExternalConnections.php
cookie consent categories config/Shared/cookieConsent.neon, app/Core/Cookie/CookieConsentConfig.php
reCAPTCHA app/Core/Forms/ReCaptcha/, app/Core/Forms/DI/ReCaptchaToggleExtension.php
the administration app/UI/Admin/System/Presenters/ExternalConnectionPresenter.php + Traits/Forms/ExternalConnectionFormTrait.php
type registration config/Shared/services.neon (externalConnectionTypes)

Cookie settings