Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,14 @@ The Product Changelog at **[matomo.org/changelog](https://matomo.org/changelog)*
narrower set of columns than before.
* The `Referrers_distinctWebsitesUrls` metric is now listed among the metrics of the `Referrers.get` report, carries the label `Distinct website URLs`, and is declared as a numeric metric. It was already archived and already part of `Referrers.get`'s own output, so that call returns the same columns as before; what changes is the surfaces driven by the report's metric list. `API.getProcessedReport` for `Referrers.get` now returns the metric instead of stripping it, and scheduled and emailed reports include it -- a new row in the HTML rendering, a new column in the CSV and TSV ones -- so consumers parsing those exports by column position will see a changed header and column count. Wherever a rendering resolves metric labels, `Distinct website URLs` now appears in place of the raw column name. Being declared numeric also brings it under CNIL data rounding, so on installations with that setting enabled the count is rounded to the same scale as other counts.
* The `UserCountry_distinctCountries` metric, returned by `UserCountry.getNumberOfDistinctCountries` and plotted by the distinct-countries sparkline widget on the Locations page, is now declared as a numeric metric and is therefore subject to CNIL data rounding. On installations with CNIL rounding enabled the returned count is rounded to the same scale as other counts instead of being passed through unrounded, so a value of `18` now reads as `20`. Installations without CNIL rounding are unaffected.
* Tooltip content - the `title` of the hovered element, or the `data-tooltip` a report cell carries - may only use simple inline formatting (`b`, `br`, `em`, `i`, `small`, `span`, `strong`, `u`, without attributes). Content carrying anything else is displayed as text in full rather than rendered, so nothing is lost from it, but a `div`, an `img` or a `class` no longer has any effect. A report cell tooltip built from `<column>_tooltip` row metadata used to be inserted without a sanitizer and now shows markup as text. Markup Matomo puts in a tooltip itself lost the classes it carried - `tooltip-action-*` in the visits log tooltip and `comparison-card-tooltip` in the comparison cards - since attributes are not kept; nothing in Matomo styled them, but a third-party theme might. Tooltips track the cursor and close as soon as it leaves their target, so their content was never interactive.

### New APIs
* The new `Piwik\Http\SecurityHeaders::sendForDataResponse()` sends the header set for a response that is data rather than application UI: `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: deny` unless `[General] enable_framed_pages` allows embedding, and a `Content-Security-Policy` that allows no scripts, forms or base URI, only inline styles and images from Matomo itself (built by the new `Piwik\View\SecurityPolicy::restrictToDataResponse()`). Core sends it for the API endpoint itself, report exports, inline report previews, and the API module's `listAllMethods` and `listSegments` actions, which return HTML without a view; `action=listAllAPI`, which renders one, keeps the headers of a regular page. Call it in a plugin that streams an export or a report, before writing any output.
* Two new events let plugins customise the "No data has been recorded yet" page, on both the standalone page and its embedding in the reporting UI:
* `Template.siteWithoutData.afterTrackingMethods` collects additional HTML rendered below the tracking methods list and the section for temporarily hiding the page.
* `SitesManager.siteWithoutData.showInviteTeamMemberLink` lets a plugin hide the "Invite Team Member" link by setting the posted flag to `false`.
* The new `CoreHome.tooltipContent` renders the `title` of the hovered element as the content of a jQuery UI tooltip, and `window.vueSanitizeTooltip()` does the same for a value at hand in plain JavaScript. Both keep only the simple inline formatting a tooltip may show and fall back to displaying the whole value as text, so a title that was not written for a tooltip loses nothing while nothing in it is rendered.

### HTTP API
* A new `keep_flattened_dimension_columns` parameter keeps the columns a flattened report adds for its
Expand Down
2 changes: 1 addition & 1 deletion plugins/Contents/templates/_actionContent.twig
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<li class="content"
title="{{ postEvent('Live.renderActionTooltip', action, visitInfo) }}">
title="{{ postEvent('Live.renderActionTooltip', action, visitInfo)|e('html') }}">
Comment thread
sgiehl marked this conversation as resolved.
Outdated
<div>
{% if action.contentInteraction %}
<img src='{{ action.iconSVG|default(action.icon) }}' title='{{ 'Contents_ContentInteraction'|translate }}'
Expand Down
16 changes: 9 additions & 7 deletions plugins/CoreHome/javascripts/dataTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -1456,18 +1456,23 @@ $.extend(DataTable.prototype, UIControl.prototype, {

$domElement.data('tooltip', 'enabled');

if (!isTextEllipsized($domElement)) {
// a label with a title of its own always needs the tooltip to render it; one that only
// repeats the cell text needs it just when that text is cut off
if (!$domElement.attr('title') && !isTextEllipsized($domElement)) {
return;
}

var customToolTipText = $domElement.attr('title') || $domElement.text();
// the cell's own text is escaped once, as the tooltip renders its title as HTML
var customToolTipText = $domElement.attr('title')
|| piwikHelper.htmlEntities($domElement.text());

if (customToolTipText) {
$domElement.attr('title', customToolTipText);
}

$domElement.tooltip({
track: true,
content: window.CoreHome.tooltipContent,
show: false,
hide: false
});
Expand Down Expand Up @@ -1681,18 +1686,15 @@ $.extend(DataTable.prototype, UIControl.prototype, {
track: true,
items: 'span',
content: function() {
return $(this).parent().data('tooltip');
return window.vueSanitizeTooltip($(this).parent().data('tooltip'));
},
show: false,
hide: false,
tooltipClass: 'small'
});
domElem.find('span.ratio').tooltip({
track: true,
content: function() {
var title = $(this).attr('title');
return piwikHelper.escape(title.replace(/\n/g, '<br />'));
},
content: window.CoreHome.tooltipContent,
show: {delay: 700, duration: 200},
hide: false
})
Expand Down
2 changes: 1 addition & 1 deletion plugins/CoreHome/templates/_dataTableCell.twig
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
{% import 'macros.twig' as piwik %}

<span class='label{% if row.getMetadata('is_aggregate') %} highlighted{% endif %}'
{% if properties is defined and properties.tooltip_metadata_name is not empty %}title="{{ row.getMetadata(properties.tooltip_metadata_name) }}"{% endif %}>
{% if properties is defined and properties.tooltip_metadata_name is not empty %}title="{{ row.getMetadata(properties.tooltip_metadata_name)|e('html')|e('html') }}"{% endif %}>
{% if column=='label' %}
{% if not row.getIdSubDataTable() and row.getMetadata('url') and not row.getMetadata('logo') %}
<span class="icon-outlink"></span>
Expand Down
16 changes: 11 additions & 5 deletions plugins/CoreHome/vue/dist/CoreHome.umd.min.js

Large diffs are not rendered by default.

7 changes: 0 additions & 7 deletions plugins/CoreHome/vue/src/Comparisons/Comparisons.less
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,3 @@
}
}

.comparison-card-tooltip {
p {
font-size: 1.1em;
line-height: 1.3em;
color: @theme-color-text-invert-contrast;
}
}
14 changes: 2 additions & 12 deletions plugins/CoreHome/vue/src/Comparisons/Comparisons.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
v-if="isComparing"
ref="root"
class="matomo-comparisons"
v-tooltips="{ duration: 200, delay: 200, content: transformTooltipContent }"
v-tooltips="{ duration: 200, delay: 200 }"
>
<h3>{{ translate('General_Comparisons') }}</h3>
<div
Expand Down Expand Up @@ -121,20 +121,11 @@ export default defineComponent({
const periodComparisons = computed(() => ComparisonsStoreInstance.getPeriodComparisons());
const getSeriesColor = ComparisonsStoreInstance.getSeriesColor.bind(ComparisonsStoreInstance);

function transformTooltipContent(this: HTMLElement) {
const title = window.$(this).attr('title');
if (!title) {
return title;
}
return window.vueSanitize(title.replace(/\n/g, '<br />'));
}

return {
isComparing,
segmentComparisons,
periodComparisons,
getSeriesColor,
transformTooltipContent,
};
},
methods: {
Expand Down Expand Up @@ -234,7 +225,7 @@ export default defineComponent({

const firstPeriodRow = visitsSummary.reportData.comparisons[segmentComp.index];

let tooltip = '<div class="comparison-card-tooltip">';
let tooltip = '';

let visitsPercent = ((comparisonRow.nb_visits / firstRow.nb_visits) * 100)
.toFixed(2);
Expand All @@ -256,7 +247,6 @@ export default defineComponent({
]);
}

tooltip += '</div>';
return tooltip;
},
htmlentities(str: string): string {
Expand Down
3 changes: 2 additions & 1 deletion plugins/CoreHome/vue/src/Tooltips/Tooltips.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import Tooltips from './Tooltips';

// The directive only ever reads binding.value, so a minimal object is enough.
// A custom `content` avoids the default transform's dependency on
// window.vueSanitize, and `show: false` opens tooltips without the show delay.
// window.vueSanitizeTooltip, and `show: false` opens tooltips without the show
// delay.
function makeBinding(value: Record<string, unknown> = {}) {
return { value: { content: () => 'tooltip content', show: false, ...value } };
}
Expand Down
8 changes: 2 additions & 6 deletions plugins/CoreHome/vue/src/Tooltips/Tooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { DirectiveBinding } from 'vue';
import tooltipContent from './tooltipContent';

export interface TooltipsArgs {
content?: () => void;
Expand All @@ -28,11 +29,6 @@ const { $ } = window;
// disconnected again when the host element is unmounted.
const observers = new WeakMap<HTMLElement, MutationObserver>();

function defaultContentTransform(this: HTMLElement) {
const title = $(this).attr('title') || '';
return window.vueSanitize(title.replace(/\n/g, '<br />'));
}

/**
* jQuery UI shows a single, delegated tooltip for every descendant of the host
* element that has a `title`. It only auto-closes a tooltip when its target
Expand Down Expand Up @@ -79,7 +75,7 @@ function setupTooltips(el: HTMLElement, binding: DirectiveBinding<TooltipsArgs>)

$(el).tooltip({
track: true,
content: binding.value?.content || defaultContentTransform,
content: binding.value?.content || tooltipContent,
show: typeof binding.value?.show !== 'undefined'
? binding.value?.show
: {
Expand Down
43 changes: 43 additions & 0 deletions plugins/CoreHome/vue/src/Tooltips/tooltipContent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*!
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

import tooltipContent from './tooltipContent';

// The sanitizer itself lives in the polyfill bundle, so it is stubbed with an identity function
// here - these tests cover what the helper does around it.
const sanitize = vi.fn((value: unknown) => `${value}`);

(window as unknown as { vueSanitizeTooltip: unknown }).vueSanitizeTooltip = sanitize;

function element(title?: string): HTMLElement {
const el = document.createElement('span');
if (typeof title !== 'undefined') {
el.setAttribute('title', title);
}
return el;
}

describe('CoreHome/tooltipContent', () => {
beforeEach(() => {
sanitize.mockClear();
});

it('sanitizes the title of the element it is called on', () => {
expect(tooltipContent.call(element('a title'))).toEqual('a title');
expect(sanitize).toHaveBeenCalledWith('a title');
});

it('hands the title over unchanged, line breaks included', () => {
tooltipContent.call(element('first\nsecond'));
expect(sanitize).toHaveBeenCalledWith('first\nsecond');
});

it('passes an empty string if there is no title', () => {
expect(tooltipContent.call(element())).toEqual('');
expect(sanitize).toHaveBeenCalledWith('');
});
});
20 changes: 20 additions & 0 deletions plugins/CoreHome/vue/src/Tooltips/tooltipContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*!
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

/**
* Content transform for jQuery UI tooltips that show the `title` of the hovered element.
*
* Meant to be passed as the widget's `content` option, so it is called with the target element as
* `this`.
*
* jQuery UI inserts what we return with `.html()`, and the browser has already decoded the
* attribute, so the value is parsed as HTML a second time. It therefore goes through the tooltip
* sanitizer rather than the general one, which also turns the title's line breaks into `<br />`.
*/
export default function tooltipContent(this: HTMLElement): string {
return window.vueSanitizeTooltip(this.getAttribute('title') || '');
}
10 changes: 5 additions & 5 deletions plugins/CoreHome/vue/src/Widget/Widget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class="matomo-widget"
:class="{'isFirstWidgetInPage': actualWidget.isFirstInPage}"
:id="actualWidget.uniqueId"
v-tooltips="{ content: tooltipContent }"
v-tooltips="{ content: widgetTooltipContent }"
>
<WidgetLoader
v-if="!actualWidget.isContainer && actualWidget.parameters && !actualWidget.clientComponent"
Expand Down Expand Up @@ -54,6 +54,7 @@ import {
import AjaxHelper from '../AjaxHelper/AjaxHelper';
import ReportMetadataStoreInstance from '../ReportMetadata/ReportMetadata.store';
import Tooltips from '../Tooltips/Tooltips';
import tooltipContent from '../Tooltips/tooltipContent';

function findContainer(
widgetsByCategory: typeof WidgetsStoreInstance.widgets.value,
Expand Down Expand Up @@ -116,19 +117,18 @@ export default defineComponent({
};
},
setup() {
function tooltipContent(this: HTMLElement) {
function widgetTooltipContent(this: HTMLElement) {
const $this = window.$(this) as JQuery;
if ($this.hasClass('matomo-form-field')) {
// do not show it for form fields
return '';
}

const title = window.$(this).attr('title') || '';
return window.vueSanitize(title.replace(/\n/g, '<br />'));
return tooltipContent.call(this);
}

return {
tooltipContent,
widgetTooltipContent,
};
},
created() {
Expand Down
1 change: 1 addition & 0 deletions plugins/CoreHome/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export { default as FocusAnywhereButHere } from './FocusAnywhereButHere/FocusAny
export { default as FocusIf } from './FocusIf/FocusIf';
export { default as Tooltips } from './Tooltips/Tooltips';
export { default as closeTooltips } from './Tooltips/closeTooltips';
export { default as tooltipContent } from './Tooltips/tooltipContent';
export { default as MatomoDialog } from './MatomoDialog/MatomoDialog.vue';
export { default as MatomoModal } from './MatomoModal/MatomoModal.vue';
export { default as ExpandOnClick } from './ExpandOnClick/ExpandOnClick';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<div class="tagCloud">
{% for word,value in cloudValues %}
<span title="{{ value.word|rawSafeDecoded|e('html_attr') }} ({{ value.value }} {{ properties.translations[cloudColumn]|default(cloudColumn) }})" class="word size{{ value.size }}
<span title="{{ value.word|rawSafeDecoded|e('html_attr') }} ({{ value.value|e('html_attr') }} {{ properties.translations[cloudColumn]|default(cloudColumn)|e('html_attr') }})" class="word size{{ value.size }}
{# we strike tags with 0 hits #}
{% if value.value == 0 %}valueIsZero{% endif %}">
{% if labelMetadata[value.word].url is not same as(false) %}
<a href="{{ labelMetadata[value.word].url }}" rel="noreferrer noopener" target="_blank">
<a href="{{ labelMetadata[value.word].url|safelink|e('html_attr') }}" rel="noreferrer noopener" target="_blank">
{% endif %}
{% if labelMetadata[value.word].logo is not same as(false) %}
<img src="{{ labelMetadata[value.word].logo }}" width="{{ value.logoWidth }}" />
Expand Down
Loading
Loading