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. publicon 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 Googlesiteverifycannot tell a wrong secret key without a real token from a browser (it always returnsinvalid-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.
Cookie consent categories#
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
- Add
{key: <key>, readonly: false}tocategories. - Add the translations
front.base.components.cookieBar.categories.<key>.titleand.descriptionin all languages (app/Locale/base/<locale>/front.<LOCALE>.neon). - If the category maps to Google Consent Mode or deletes cookies on withdrawal, extend
CONSENT_MODE_SIGNALSandCATEGORY_COOKIESinwww/themes/frontend/<template>/desktop/assets/js/components/cookie-bar.js, spread the change to all templates (bin/theme-js-sync.php) and run webpack. - 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.
GoogleMapsLinks#
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:
security.reCaptcha: falsein 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).- The enabled connection
recaptcha_v3with its own site key → v3. - 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#
- A class
App\Core\Integrations\Connections\Types\<Name>ConnectionimplementingConnectionTypeInterface: aCODEconstant,getGroup()(null, or a group code),getFields(). Fields that may go into the page havepublic: true; secret onesSecret. Required fields haverequired: true(checked only for an enabled connection). - Registration in
config/Shared/services.neon: the class into the list of services and into the registry arrayexternalConnectionTypes. - 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) |