Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> before assuming something isn't built. `@api` = stable to build on; `@internal` = may change.
> Grouped by **capability** (across layers), not by directory.

**222 classes** across **33 capabilities** · **23 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).
**222 classes** across **33 capabilities** · **24 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md).

## Capabilities (`library/Tiger`)

Expand Down Expand Up @@ -342,6 +342,7 @@
- **Code** (`code`, developer) · services: Code · `modules/code`
- **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment`
- **Identity** (`identity`, plugin) · services: Identity · `modules/identity`
- **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo`
- **Mcp** (`mcp`, module) · services: Settings · `modules/mcp`
- **Media** (`media`, plugin) · services: Media, Settings · `modules/media`
- **Messages** (`message`, app) · services: Message · `modules/message`
Expand Down
76 changes: 76 additions & 0 deletions modules/matomo/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# AGENTS.md — working on the `matomo` module

> **Step 0 — check core before you build.** Before writing any mechanism, grep
> [`../../CAPABILITIES.md`](../../CAPABILITIES.md) — a generated, CI-checked index of every `Tiger_*` class and module.
> Core probably already has it. **Assume a capability exists until you have grepped the index and confirmed it doesn't.**

Instructions for an AI assistant (or a new contributor) working on Tiger's **matomo** module. For
platform conventions read the root **AGENTS.md** (`read.guide` with no module). This file is the
module-specific layer; match the surrounding style.

> First-party **self-hosted Matomo** integration — the "paste your Matomo URL + site id" tracker done
> the Tiger way: config-backed (no deploy), consent-aware (`Tiger_Consent`), provider-extensible
> (`Tiger_Tracking`). It emits the Matomo `_paq` snippet on public pages AND reads the `/a/<name>`
> affiliate cookie into a Matomo **visit-scoped custom dimension** so affiliate traffic is segmentable.
> This module is the **sibling of `analytics`** (GA4) — same shape, same registry, and **both can be
> active at once**.

## The one thing to know: this module only TALKS to Matomo, it is not Matomo

Matomo is a **separate GPLv3 application** (its own install at e.g. `analytics.webtigers.com`, its own
PHP pool + DB). This BSD-3 module is **never** vendored into it and never embeds its code — it only
speaks Matomo's client-side tracking API (`_paq`) and (future) its HTTP Reporting API. Keep that wall.

## Where things live (no models of its own)

- `Bootstrap.php` — registers the tag plugin (stackIndex 92), declares `matomo` to `Tiger_Tracking`,
and lists the screen under the admin **Settings** tree (`Tiger_Admin_Settings`).
- `plugins/Tag.php` — **`Matomo_Plugin_Tag`**, the `_paq` emitter (the whole runtime).
- `controllers/AdminController.php` — `/matomo/admin` (settings), admin-only.
- `services/Matomo.php` — `Matomo_Service_Matomo::save` (writes the config tier). `forms/Settings.php`.
- `views/scripts/admin/index.phtml` + `assets/js/matomo.settings.js` (the save wiring).
- **No `models/`** — settings live in `Tiger_Model_Config` (the live-override tier).

## Config keys (all `tiger.matomo.*`, GLOBAL scope today)

- `enabled` (bool) · `url` (the Matomo base URL, trailing-slashed) · `site_id` (idsite, int) ·
`exclude_signed_in` (bool, default on) · `affiliate.dimension_id` (the visit-scoped custom-dimension
**index**, default 1).

## The affiliate cookie contract (TIGER-306 ↔ TIGER-303)

- **`Matomo_Plugin_Tag::AFFILIATE_COOKIE` = `tiger_aff`** holds the affiliate **name**; the plugin reads
it, validates it against `AFFILIATE_NAME_RE` (`[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}`), and pushes
`setCustomDimension` **before** `trackPageView`. The value is JSON-encoded before it reaches the page.
- **This module READS the cookie; it never WRITES it and never owns the `/a/<name>` route.** Writing the
cookie (and validating the name against real affiliate records, recording the click, stamping the
account) is **TigerAffiliates**' job (TIGER-303). When that module lands it must write **this same
cookie name**. Until then the dimension is simply never set (set the cookie by hand to test the read).

## Conventions + gotchas (this module)

- **The tag rides a front-controller plugin** (`Matomo_Plugin_Tag`, stackIndex 92, `preDispatch`,
emit-once) appending the `_paq` snippet to the `tigerTracking` head placeholder — which **only the
public layout renders**, so admin traffic is never tagged. It emits only when enabled + a valid
`http(s)` URL + a positive site id + `Tiger_Consent::allows('analytics')` (TRUE until the cookie
feature sets a mode, so Matomo works standalone) + not excluding signed-in staff. **Fail-open** — a
broken lookup never breaks the page render.
- **`isConfigured()` is the registry's `active` signal** — enabled AND url AND site id. The URL + id are
validated in the plugin before ever being emitted (don't trust the stored config shape).
- **The two switches (`enabled`, `exclude_signed_in`) are NOT form elements** — the form validates only
the URL / site id / dimension index; the switches come straight off `$params`.
- **Config scope is GLOBAL today**, isolated in `_scope()` (multi-site flips to per-org later — same as
the analytics module).
- **Everything is `class_exists`-guarded** against a Core predating `Tiger_Tracking`/`Tiger_Admin_Settings`
— the module still loads (degraded) on older cores.

## ACL

admin: `Matomo_AdminController`, `Matomo_Service_Matomo` (both in `configs/acl.ini`, deny-by-default).

## Do / Don't

- **Do** keep tagging consent-gated + fail-open; **do** validate the URL/site id before emitting.
- **Don't** write the `/a/<name>` cookie or add an `/a/:name` route here — that's TigerAffiliates.
- **Don't** emit the tag into admin/auth layouts (they don't render `tigerTracking` — keep it that way).
- **Don't** embed or vendor any Matomo code — HTTP/`_paq` only.
66 changes: 66 additions & 0 deletions modules/matomo/Bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Matomo module bootstrap.
*
* First-party self-hosted analytics — the "paste your Matomo URL + site id" tracker, done the Tiger
* way: config-backed (no deploy), consent-aware (Tiger_Consent), provider-extensible via the
* Tiger_Tracking registry, and intelligent about affiliate attribution (it reads the /a/<name> cookie
* — the contract TigerAffiliates writes — into a Matomo visit-scoped custom dimension). This module
* owns only the client-side tracker + its settings screen; the cookie-consent banner is a separate
* feature, and the Matomo server install is a separate app (GPLv3) reached only over HTTP.
*
* It is a SIBLING of the analytics (GA4) module — both register as `analytics`-category trackers and
* both can be active at once; a site can run GA4 and Matomo together, each consent-gated.
*
* Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader, so
* Matomo_Service_* / Matomo_Form_* / Matomo_Plugin_* load by convention; controllers load via the
* registered module dir; configs/acl.ini + languages/ are picked up by the core globs.
*/
class Matomo_Bootstrap extends Zend_Application_Module_Bootstrap
{
/**
* Contribute the Matomo tag to the head (config-driven, consent-gated). No explicit stackIndex —
* the analytics (GA4) sibling already holds 92, and the Zend plugin broker throws (fatally, during
* the module scan) on a duplicate index; order is irrelevant here since the plugin only appends to a
* head placeholder, so we let the broker auto-assign a free slot.
*/
protected function _initMatomoTag()
{
Zend_Controller_Front::getInstance()->registerPlugin(new Matomo_Plugin_Tag());
}

/**
* Declare Matomo to the tracking registry so the cookie-consent feature's "auto" mode can tell that
* tracking is present (and pop the banner). Active = enabled AND a valid Matomo URL + site id are set.
* Guarded so the module still loads on a Core that predates Tiger_Tracking.
*/
protected function _initMatomoTracker()
{
if (!class_exists('Tiger_Tracking')) {
return;
}
Tiger_Tracking::register('matomo', [
'label' => 'Matomo',
'category' => 'analytics',
'active' => Matomo_Plugin_Tag::isConfigured(),
]);
}

/** List Matomo under the admin Settings tree (ACL-gated to Matomo_AdminController). */
protected function _initAdminSettings()
{
if (!class_exists('Tiger_Admin_Settings')) {
return;
}
Tiger_Admin_Settings::register([
'key' => 'matomo',
'label' => 'matomo.nav.label',
'icon' => 'fa-chart-simple',
'href' => '/matomo/admin',
'resource' => 'Matomo_AdminController',
'order' => 41,
]);
}
}
56 changes: 56 additions & 0 deletions modules/matomo/assets/js/matomo.settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* SPDX-License-Identifier: BSD-3-Clause
* Copyright (c) 2026 WebTigers.
*
* Matomo settings — save wiring. Posts the form to /api (matomo/matomo/save) through the house UI
* primitives: TigerButton.run for the busy state, TigerDOM.notify for feedback, res.form for inline
* field errors. The two switches (enabled, exclude_signed_in) ride along as checkbox values. No
* page-POST, no <button type="submit"> — per ADMIN.md.
*/
(function () {
'use strict';

document.addEventListener('DOMContentLoaded', function () {
var saveBtn = document.getElementById('matomo-save');
if (!saveBtn) { return; }

var form = document.getElementById('matomo-form');
var fb = document.getElementById('matomo-feedback');

saveBtn.addEventListener('click', function () {
// Clear any prior inline errors.
Array.prototype.forEach.call(form.querySelectorAll('.is-invalid'), function (el) {
el.classList.remove('is-invalid');
});

var fd = new URLSearchParams(new FormData(form));
fd.set('module', 'matomo');
fd.set('service', 'matomo');
fd.set('method', 'save');

TigerButton.run(this, function () {
return fetch('/api', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd
}).then(function (r) { return r.json().catch(function () { return {}; }); });
}).then(function (res) {
if (res && res.result === 1) {
TigerDOM.notify(fb, (res.messages && res.messages[0] && res.messages[0].message) || 'Saved.', { type: 'success' });
return;
}
if (res && res.form) {
Object.keys(res.form).forEach(function (f) {
var el = form.querySelector('[name="' + f + '"]');
if (el) { el.classList.add('is-invalid'); }
});
}
(res && res.messages || []).forEach(function (m) {
TigerDOM.notify(fb, m.message, { type: m.class });
});
}).catch(function () {
TigerDOM.notify(fb, 'Network error — please try again.', { type: 'error' });
});
});
});
}());
29 changes: 29 additions & 0 deletions modules/matomo/configs/acl.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
; ==============================================================================
; TIGER-OWNED - DO NOT EDIT. Ships in the framework and is REPLACED on every
; update (composer update / Module Manager) -- any edit here is SILENTLY LOST.
; Customize by OVERRIDING in an APP-OWNED tier (merges on top, later wins, and
; survives updates): application/configs/application.ini * local.ini (secrets) *
; the `config` DB table (runtime, no deploy). ACL rules: your OWN module's
; acl.ini or the acl_* DB tables. See ARCHITECTURE.md Sec 0 + Sec 5 (cascade).
; ==============================================================================

; Matomo ACL — the settings screen + its /api service are admin-only (deny-by-default; without these
; allows the screen and save are refused). Its own resource so access is grantable on its own.
[production]

acl.resources.matomo_admin_ctrl.resource = "Matomo_AdminController"
acl.resources.matomo_svc.resource = "Matomo_Service_Matomo"

acl.rules.matomo_admin_ctrl.role = "admin"
acl.rules.matomo_admin_ctrl.resource = "Matomo_AdminController"
acl.rules.matomo_admin_ctrl.permission = "allow"

acl.rules.matomo_svc.role = "admin"
acl.rules.matomo_svc.resource = "Matomo_Service_Matomo"
acl.rules.matomo_svc.permission = "allow"

[staging : production]

[testing : production]

[development : production]
50 changes: 50 additions & 0 deletions modules/matomo/controllers/AdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Matomo_AdminController — the Matomo settings screen. Thin per ADMIN.md: renders the form pre-filled
* from the live config; saving is an /api call (Matomo_Service_Matomo). Its own ACL resource (admin+),
* so access is grantable independently.
*/
class Matomo_AdminController extends Tiger_Controller_Admin_Action
{
/**
* Admin shell (layout) comes from the base; keep the explicit init cascade.
*
* @return void
*/
public function init()
{
parent::init();
}

/**
* Render the settings form, pre-filled from the live config.
*
* @return void
*/
public function indexAction()
{
$tiger = Zend_Registry::get('Zend_Config')->get('tiger');
$m = $tiger ? $tiger->get('matomo') : null;
$aff = $m ? $m->get('affiliate') : null;

$form = new Matomo_Form_Settings();
$form->populate([
'matomo_url' => $m ? (string) $m->get('url') : '',
'matomo_site_id' => $m && $m->get('site_id') !== null ? (string) $m->get('site_id') : '',
'matomo_affiliate_dimension_id' => $aff && $aff->get('dimension_id') !== null ? (string) $aff->get('dimension_id') : '1',
]);

$b = static function ($node, $key, $default = false) {
return $node && $node->get($key) !== null
? filter_var((string) $node->get($key), FILTER_VALIDATE_BOOLEAN)
: $default;
};

$this->view->title = 'Matomo — Tiger Admin';
$this->view->form = $form;
$this->view->enabled = $b($m, 'enabled', false);
$this->view->excludeSignedIn = $b($m, 'exclude_signed_in', true);
}
}
43 changes: 43 additions & 0 deletions modules/matomo/forms/Settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Matomo_Form_Settings — the Matomo settings form. Three validated fields: the Matomo base URL
* (optional, but when present must be an http(s) URL), the numeric site id (idsite), and the affiliate
* custom-dimension index. The two booleans (enabled, exclude signed-in staff) are plain switches the
* view renders and the service reads from $params. The URL + site id are validated here and again in
* the plugin before anything is ever emitted.
*
* @api
*/
class Matomo_Form_Settings extends Tiger_Form
{
/**
* Declare the form's elements.
*
* @return array the element schema
*/
protected function elements(): array
{
return [
['text', 'matomo_url', [
'required' => false,
'filters' => ['StringTrim'],
'validators' => [['Regex', false, ['pattern' => '#^https?://[^\s"\'<>]+$#i']]],
'attribs' => ['class' => 'form-control', 'placeholder' => 'https://analytics.example.com/', 'autocomplete' => 'off'],
]],
['text', 'matomo_site_id', [
'required' => false,
'filters' => ['StringTrim'],
'validators' => [['Digits']],
'attribs' => ['class' => 'form-control', 'placeholder' => '1', 'inputmode' => 'numeric', 'autocomplete' => 'off'],
]],
['text', 'matomo_affiliate_dimension_id', [
'required' => false,
'filters' => ['StringTrim'],
'validators' => [['Digits']],
'attribs' => ['class' => 'form-control', 'placeholder' => '1', 'inputmode' => 'numeric', 'autocomplete' => 'off'],
]],
];
}
}
41 changes: 41 additions & 0 deletions modules/matomo/languages/de/matomo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Matomo module — German strings. Semantic, owner-prefixed keys (matomo.*). Loaded on top of core
* strings by the translate cascade; API response messages resolve these in the caller's locale.
*/
return [
// /api response messages
'matomo.saved' => 'Matomo-Einstellungen gespeichert.',

// Nav + settings screen chrome
'matomo.nav.label' => 'Matomo',
'matomo.title' => 'Matomo-Analyse',
'matomo.subtitle' => 'Verbinde dein selbst gehostetes Matomo, um deine öffentliche Website zu messen und Affiliate-Traffic zuzuordnen.',
'matomo.save' => 'Speichern',

// Connection card
'matomo.card.connection' => 'Matomo-Verbindung',
'matomo.enable' => 'Matomo-Tracking aktivieren',
'matomo.url' => 'Matomo-URL',
'matomo.url_help' => 'Die Basis-URL deiner Matomo-Installation, z. B. https://analytics.example.com/',
'matomo.site_id' => 'Website-ID',
'matomo.site_id_help' => 'Die Matomo-Website (idsite), an die diese Installation meldet — zu finden in Matomo unter Administration → Websites.',

// Affiliate attribution card
'matomo.card.affiliate' => 'Affiliate-Zuordnung',
'matomo.affiliate_help' => 'Wenn ein Besucher über einen Affiliate-Link (/a/<name>) kommt, wird dieser Affiliate-Name beim Besuch als benutzerdefinierte Dimension in Matomo erfasst — so lassen sich Besuche und Conversions nach Affiliate segmentieren. Der Link und sein Cookie werden vom Affiliate-Programm gesetzt; dies liest sie nur aus.',
'matomo.affiliate_dimension' => 'Index der benutzerdefinierten Dimension',
'matomo.affiliate_dimension_help' => 'Der Index der besuchsbezogenen benutzerdefinierten Dimension in Matomo, die den Affiliate-Namen enthält (Standard 1).',

// Privacy & consent card
'matomo.card.privacy' => 'Datenschutz & Einwilligung',
'matomo.exclude_staff' => 'Angemeldetes Personal nicht tracken',
'matomo.exclude_staff_help' => 'Überspringe Besuche von Managern, Administratoren und Entwicklern, damit dein eigenes Team die Zahlen nicht verfälscht.',
'matomo.privacy_note' => 'Der Tracker wird nur geladen, wenn die Analyse-Einwilligung es erlaubt. Matomo berücksichtigt außerdem Do Not Track und anonymisiert IP-Adressen auf dem Server.',

// Client-side
'matomo.net_error' => 'Netzwerkfehler — bitte versuche es erneut.',
'matomo.fix_fields' => 'Bitte korrigiere die hervorgehobenen Felder.',
];
Loading
Loading