diff --git a/composer.json b/composer.json index 04303757..d136a9e5 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "jbroadway/urlify": "^1.0", "pragmarx/google2fa": "^8.0", "bacon/bacon-qr-code": "^3.0", - "phpmailer/phpmailer": "^6.5" + "phpmailer/phpmailer": "^6.5", + "matomo/matomo-php-tracker": "^3.4" }, "autoload": { "files": [ diff --git a/composer.lock b/composer.lock index 52c571a3..1eaf2765 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c42e28f737dbacec77bc18555733d9d3", + "content-hash": "334f0e067748536eb03d835eab96ea90", "packages": [ { "name": "bacon/bacon-qr-code", @@ -356,6 +356,62 @@ "abandoned": true, "time": "2016-08-02T19:12:55+00:00" }, + { + "name": "matomo/matomo-php-tracker", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/matomo-org/matomo-php-tracker.git", + "reference": "9462dc6eb718c711545ea1b0f590b9ae892a4212" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/matomo-org/matomo-php-tracker/zipball/9462dc6eb718c711545ea1b0f590b9ae892a4212", + "reference": "9462dc6eb718c711545ea1b0f590b9ae892a4212", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~7.2 || ~7.3 || ~7.4 || ~8.0 || ~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3 || ^10.1" + }, + "suggest": { + "ext-curl": "Using this extension to issue the HTTPS request to Matomo" + }, + "type": "library", + "autoload": { + "classmap": [ + "." + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "The Matomo Team", + "email": "hello@matomo.org", + "homepage": "https://matomo.org/team/" + } + ], + "description": "PHP Client for Matomo Analytics Tracking API", + "homepage": "https://matomo.org", + "keywords": [ + "analytics", + "matomo", + "piwik", + "tracker" + ], + "support": { + "forum": "https://forum.matomo.org/", + "issues": "https://github.com/matomo-org/matomo-php-tracker/issues", + "source": "https://github.com/matomo-org/matomo-php-tracker" + }, + "time": "2025-12-20T18:55:41+00:00" + }, { "name": "michelf/php-markdown", "version": "1.9.1", diff --git a/lang/en_US.ini b/lang/en_US.ini index e1c41ae8..43c8a13e 100644 --- a/lang/en_US.ini +++ b/lang/en_US.ini @@ -94,6 +94,16 @@ github_pre_release = "Github pre-release" google_analytics = "Google Analytics" google_analytics_legacy = "Google Analytics (legacy)" google_search_console = "Google Search Console" +matomo_analytics = "Matomo Analytics" +matomo_url = "Matomo URL" +matomo_site_id = "Matomo Site ID" +matomo_authtoken = "Matomo Auth. Token" +matomo_track_type = "Matomo Tracking Type" +matomo_track_type_js = "Javascript - Client side (can be ditched by browser extensions)" +matomo_track_type_php = "PHP Backend - Server side" +matomo_use_cookies = "Matomo cookies" +matomo_use_cookies_yes = "Use cookies (better analytics)" +matomo_use_cookies_no = "Do NOT use cookies (easier European GDPR compliance)" home = "Home" if_left_empty_we_will_excerpt_it_from_the_content_below = "If leave empty we will excerpt it from the content below" if_the_url_is_left_empty_we_will_use_the_page_title = "If the url leave empty we will use the page title" @@ -407,3 +417,33 @@ backtotop = "Back to top" subpages = "Sub pages" getstarted = "Get started" onthispage = "On this page" +enable_jstime="Enable Javascript and timestamp anti-spam protection" +jstime_desc="Usually bots dont't use Javascript. Form also checks if submitted between 3 and 600 seconds (preventing bots fast submission)" +comment_email_admin_awaiting="New comment awaiting moderation" +comment_email_admin_new="New comment" +comment_email_subscription_subject = "Subscription confirmation to" +comment_email_new = "New comment on" +comment_email_from = "From" +comment_email_moderate = "Moderate comments" +comment_email_new_subscribed = "New reply on a subscribed thread" +comment_email_new_replied = "Someone replied to your comment on" +comment_email_view_comment = "View comment" +comment_subscribe_confirmation = "Subscription confirmation to" +comment_subscribe_thread = "Thread subscription at" +comment_subscribe_request = "We received a subscription request to a thread at" +comment_subscribe_never_requested = "If you never visited the site or requested to be notified on thread messages, please ignore this email." +comment_subscribe_click = "Click" +comment_subscribe_here = "HERE" +comment_subscribe_confirm_message = "to confirm your subscription and start receiving notification emails on replies on the thread." +comment_subscribe_unsubscribe_message = "You can unsubscribe all notifications from" +comment_subscribe_unsubscribe_anytime = "at any time using this link" +comment_unsubscribe = "unsubscribe" +sysmsg_subscribe_success = "Your will receive now new comment notifications on the subscribed threads." +sysmsg_subscribe_fail = "Something went wrong during subscription verification process." +sysmsg_unsubscribe_success = "You have successfully unsubscribed from notification emails." +sysmsg_unsubscribe_fail = "Something wrong during unsubscription process" + + +posts_not_found = "Posts not found!" +page_not_found = "Page not found!" + diff --git a/lang/it_IT.ini b/lang/it_IT.ini index 8e1929dc..4c1ff3a7 100644 --- a/lang/it_IT.ini +++ b/lang/it_IT.ini @@ -403,7 +403,7 @@ comment_submission_error_short = "Il commento è richiesto e deve essere almeno comment_submission_error_spam = "SPAM rilevato!" pending_comments = "Commenti in attesa" level = "Livello" -backtotop = "Back to top" -subpages = "Sub pages" -getstarted = "Get started" -onthispage = "On this page" +backtotop = "Torna in cima" +subpages = "Sottopagine" +getstarted = "Per cominciare" +onthispage = "Su questa pagina" diff --git a/system/admin/admin.php b/system/admin/admin.php index 1e4a09f5..47fa230c 100644 --- a/system/admin/admin.php +++ b/system/admin/admin.php @@ -1728,48 +1728,78 @@ function clear_post_cache($post_date, $post_tag, $post_url, $filename, $category if (file_exists($p)) { unlink($p); } + if (file_exists($p . '.meta.json')) { + unlink($p . '.meta.json'); + } // Delete post permalink $pp = 'cache/page/' . $b . 'post#' . $post_url . '.cache'; if (file_exists($pp)) { unlink($pp); } + if (file_exists($pp . '.meta.json')) { + unlink($pp . '.meta.json'); + } // Delete homepage $yd = 'cache/page/' . $b . '.cache'; if (file_exists($yd)) { unlink($yd); } + if (file_exists($yd . '.meta.json')) { + unlink($yd . '.meta.json'); + } foreach (glob('cache/page/' . $b . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete year $yd = 'cache/page/' . $b . 'archive#' . $t[0] . '.cache'; if (file_exists($yd)) { unlink($yd); } + if (file_exists($yd . '.meta.json')) { + unlink($yd . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete year-month $yd = 'cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '.cache'; if (file_exists($yd)) { unlink($yd); } + if (file_exists($yd . '.meta.json')) { + unlink($yd . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete year-month-day $yd = 'cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '-' . $t[2] . '.cache'; if (file_exists($yd)) { unlink($yd); } + if (file_exists($yd . '.meta.json')) { + unlink($yd . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '-' . $t[2] . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'archive#' . $t[0] . '-' . $t[1] . '-' . $t[2] . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete tag foreach ($c as $tag) { @@ -1777,33 +1807,54 @@ function clear_post_cache($post_date, $post_tag, $post_url, $filename, $category if (file_exists($yd)) { unlink($yd); } + if (file_exists($yd . '.meta.json')) { + unlink($yd . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'tag#' . $tag . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'tag#' . $tag . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } } // Delete search foreach (glob('cache/page/' . $b . 'search#*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'search#*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete category $cc = 'cache/page/' . $b . 'category#' . $category . '.cache'; if (file_exists($cc)) { unlink($cc); } + if (file_exists($cc . '.meta.json')) { + unlink($cc . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'category#' . $category . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'category#' . $category . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Delete type $tp = 'cache/page/' . $b . 'type#' . $type . '.cache'; if (file_exists($tp)) { unlink($tp); } + if (file_exists($tp . '.meta.json')) { + unlink($tp . '.meta.json'); + } foreach (glob('cache/page/' . $b . 'type#' . $type . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'type#' . $type . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } // Get cache post author $arr = pathinfo($filename, PATHINFO_DIRNAME); @@ -1813,9 +1864,16 @@ function clear_post_cache($post_date, $post_tag, $post_url, $filename, $category if (file_exists($a)) { unlink($a); } + if (file_exists($a . '.meta.json')) { + unlink($a . '.meta.json'); + } + foreach (glob('cache/page/' . $b . 'author#' . $x[1] . '~*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/' . $b . 'author#' . $x[1] . '~*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); + } } function clear_page_cache($url) @@ -1825,6 +1883,9 @@ function clear_page_cache($url) if (file_exists($p)) { unlink($p); } + if (file_exists($p . '.meta.json')) { + unlink($p . '.meta.json'); + } } function clear_cache() @@ -1832,6 +1893,9 @@ function clear_cache() foreach (glob('cache/page/*.cache', GLOB_NOSORT) as $file) { unlink($file); } + foreach (glob('cache/page/*.cache.meta.json', GLOB_NOSORT) as $file) { + unlink($file); + } } function valueMaker($value) diff --git a/system/admin/views/clear-cache.html.php b/system/admin/views/clear-cache.html.php index db722fa5..56dad484 100644 --- a/system/admin/views/clear-cache.html.php +++ b/system/admin/views/clear-cache.html.php @@ -10,6 +10,9 @@ foreach (glob('cache/page/*.cache', GLOB_NOSORT) as $file) { unlink($file); } +foreach (glob('cache/page/*.cache.meta.json', GLOB_NOSORT) as $filemeta) { + unlink($filemeta); +} echo i18n('All_cache_has_been_deleted'); diff --git a/system/admin/views/config-widget.html.php b/system/admin/views/config-widget.html.php index 68ed571c..c7a86bd7 100644 --- a/system/admin/views/config-widget.html.php +++ b/system/admin/views/config-widget.html.php @@ -209,6 +209,69 @@ + + +
+

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+ checked> + +
+
+ checked> + +
+
+
+
+
+ +
+ +
+
+
+ +
+
+
+ checked> + +
+
+ checked> + +
+
+
+
+ +


@@ -266,6 +329,12 @@ +
+ +
+ +
+
diff --git a/system/configList.json b/system/configList.json index b417257d..9254680d 100644 --- a/system/configList.json +++ b/system/configList.json @@ -21,6 +21,7 @@ "social.youtube", "social.mastodon", "social.tiktok", + "social.whatsapp", "breadcrumb.home", "comment.system", "fb.appid", @@ -31,6 +32,11 @@ "google.wmt.id", "google.analytics.id", "google.gtag.id", + "matomo.site.id", + "matomo.url", + "matomo.track.type", + "matomo.authtoken", + "matomo.cookies", "login.protect.system", "login.protect.public", "login.protect.private", diff --git a/system/htmly.php b/system/htmly.php index 6ae4e69a..f919222d 100644 --- a/system/htmly.php +++ b/system/htmly.php @@ -2461,8 +2461,8 @@ // Show Config page get('/admin/config/performance', function () { - $user = $_SESSION[site_url()]['user']; - $role = user('role', $user); + $user = $_SESSION[site_url()]['user'] ?? null; + $role = user('role', $user) ?? null; if (login()) { config('views.root', 'system/admin/views'); diff --git a/system/includes/dispatch.php b/system/includes/dispatch.php index 41318e68..13cfd71d 100644 --- a/system/includes/dispatch.php +++ b/system/includes/dispatch.php @@ -437,6 +437,183 @@ function content($value = null) return stash('$content$', $value); } +// Normalize cache metadata type +function normalize_titles_type($type, $canonical = null) +{ + // Map internal types to simple types + $type_map = array( + 'blogPost' => 'post', + 'imagePost' => 'post', + 'videoPost' => 'post', + 'linkPost' => 'post', + 'quotePost' => 'post', + 'audioPost' => 'post', + 'is_frontpage' => 'frontpage', + 'is_tag' => 'tag', + 'is_profile' => 'profile', + 'is_category' => 'category', + 'is_page' => 'page', + 'is_subpage' => 'page', + 'is_archive' => 'archive', + 'is_search' => 'search' + ); + + $subtype_map = array( + 'blogPost' => 'post', + 'imagePost' => 'image', + 'videoPost' => 'video', + 'linkPost' => 'link', + 'quotePost' => 'quote', + 'audioPost' => 'audio', + 'is_frontpage' => 'frontpage', + 'is_tag' => '', + 'is_profile' => '', + 'is_category' => '', + 'is_page' => '', + 'is_subpage' => 'subpage', + 'is_archive' => '', + 'is_search' => '' + ); + + $type_prefix = array( + 'blogPost' => '', + 'imagePost' => '', + 'videoPost' => '', + 'linkPost' => '', + 'quotePost' => '', + 'audioPost' => '', + 'is_frontpage' => i18n('Home') . ': ', + 'is_tag' => i18n('Tag') . ': ', + 'is_profile' => i18n('Author') . ': ', + 'is_category' => i18n('Category') . ': ', + 'is_page' => '', + 'is_subpage' => '', + 'is_archive' => i18n('Archives') . ': ', + 'is_search' => i18n('Search') . ': ' + ); + + // Return mapped type if found + if (isset($type_map[$type])) { + $return_type['type'] = $type_map[$type]; + $return_type['subtype'] = $subtype_map[$type]; + $return_type['prefix'] = $type_prefix[$type]; + return $return_type; + } + + // Try to derive from canonical URL + if ($canonical) { + $parsed = parse_url($canonical); + if (isset($parsed['path'])) { + $path = trim($parsed['path'], '/'); + $segments = explode('/', $path); + + // Get first segment after site path + $site_path = trim(site_path(), '/'); + if (!empty($site_path) && isset($segments[0]) && $segments[0] === $site_path) { + array_shift($segments); + } + + if (!empty($segments)) { + $first_segment = $segments[0]; + + // Check known URL patterns + if ($first_segment === 'tag') { + $return_type['type'] = 'tag'; + $return_type['subtype'] = ''; + $return_type['prefix'] = 'Tag: '; + return $return_type; + } + if ($first_segment === 'author') { + $return_type['type'] = 'profile'; + $return_type['subtype'] = ''; + $return_type['prefix'] = 'Profile: '; + return $return_type; + } + if ($first_segment === 'category') { + $return_type['type'] = 'category'; + $return_type['subtype'] = ''; + $return_type['prefix'] = 'Category: '; + return $return_type; + } + if ($first_segment === 'archive') { + $return_type['type'] = 'archive'; + $return_type['subtype'] = ''; + $return_type['prefix'] = 'Archive: '; + return $return_type; + } + if ($first_segment === 'search') { + $return_type['type'] = 'search'; + $return_type['subtype'] = ''; + $return_type['prefix'] = 'Search: '; + return $return_type; + } + + // If it's a date pattern (YYYY/MM), it's a post + if (preg_match('/^\d{4}$/', $first_segment)) { + $return_type['type'] = 'post'; + $return_type['subtype'] = ''; + $return_type['prefix'] = ''; + return $return_type; + } + } + } + } + + // Default to page for unknown types + $return_type['type'] = 'page'; + $return_type['subtype'] = ''; + $return_type['prefix'] = ''; + return $return_type; +} + + +function generate_meta_info($locals) { + if (is_array($locals) && count($locals)) { + extract($locals, EXTR_SKIP); + } + + // Normalize type + $normalized_type = normalize_titles_type( + isset($type) ? $type : null, + isset($canonical) ? $canonical : null + ); + + // Extract menu name from static page or canonical URL + $filename = "content/data/menu.json"; + if (file_exists($filename)) { + $menu_content = file_get_data($filename) ; + $menu_flat = flattenMenu(json_decode(json_decode($menu_content, true), true)); + } + + // Get language from config + $language = config('language'); + + if (isset($canonical)) { + $slug = parse_url($canonical, PHP_URL_PATH); + } + else { + $slug = ''; + } + + // Save metadata in separate cache file + // $metafile = $cachefile . '.meta.json'; + $metadata = array( + 'title' => isset($title) ? $title : null, + 'prefix' => $normalized_type['prefix'], + 'description' => isset($description) ? $description : '', + 'canonical' => isset($canonical) ? $canonical : '', + 'slug' => $slug, + 'author' => isset($author) ? $author->name : '', + 'type' => $normalized_type['type'], + 'subtype' => $normalized_type['subtype'], + 'menu' => isset($menu_flat[$slug]) ? $menu_flat[$slug] : '', + 'language' => $language + ); + + return $metadata; +} + + function render($view, $locals = null, $layout = null) { if (!login()) { @@ -487,8 +664,14 @@ function render($view, $locals = null, $layout = null) if (config('cache.timestamp') == 'true') { echo "\n" . ''; } - if (isset($cachefile)) + if (isset($cachefile)) { file_put_contents($cachefile, ob_get_contents(), LOCK_EX); + + // Save metadata in separate cache file + $metafile = $cachefile . '.meta.json'; + $metadata = generate_meta_info($locals); + file_put_contents($metafile, json_encode($metadata, JSON_UNESCAPED_UNICODE), LOCK_EX); + } } echo trim(ob_get_clean()); } else { diff --git a/system/includes/functions.php b/system/includes/functions.php index 599c456f..94b838ac 100644 --- a/system/includes/functions.php +++ b/system/includes/functions.php @@ -2513,6 +2513,7 @@ function social($class = null) $youtube = config('social.youtube'); $mastodon = config('social.mastodon'); $tiktok = config('social.tiktok'); + $whatsapp = config('social.whatsapp'); $rss = site_url() . 'feed/rss'; $social = ''; @@ -2553,6 +2554,10 @@ function social($class = null) $social .= ''; } + if (!empty($whatsapp)) { + $social .= ''; + } + $social .= ''; $social .= '
'; return $social; @@ -2697,6 +2702,93 @@ function gtag(){dataLayer.push(arguments);} } } + +// Matomo Analytics +function matomo($metadata, $locals = null) +{ + $matomo_url = config('matomo.url'); + $matomo_id = config('matomo.site.id'); + $matomo_track_type = config('matomo.track.type'); + if (config('matomo.cookies') == 'false') { + $matomo_nocookies = "_paq.push(['disableCookies']);"; + } + else { + $matomo_nocookies = ""; + } + + + if ($matomo_url && $matomo_id) { + if ($matomo_track_type == "js") { + $script = " + + + + "; + return $script; + } + else { + include_once('system/vendor/matomo/matomo-php-tracker/MatomoTracker.php'); + + // Initialize tracker + $t = new MatomoTracker((int)config('matomo.site.id'), config('matomo.url')); + + // Optional: Set auth token (required for some features like custom IP or user ID) + $t->setTokenAuth(config('matomo.authtoken')); + + if ($locals) { + $metadata = generate_meta_info($locals); + } + + $pagePrefix = $metadata['prefix'] ?? ''; + $pageTitle = trim($pagePrefix . $metadata['title']); + + if (substr($pageTitle, -strlen(' - ' . blog_title())) === ' - ' . blog_title()) { + $pageTitle = substr($pageTitle, 0, -strlen(' - ' . blog_title())); + } + + $t->setIp(client_ip()); + if (config('matomo.cookies') == 'false') { + $t->disableCookieSupport(); + } + + // Track page view + $t->doTrackPageView($pageTitle); + + return ""; + } + } +} + + +function flattenMenu(array $items, array &$flat = []): array +{ + foreach ($items as $item) { + // keep only slug + name + if ($item['slug'] != "#") { + $flat[$item['slug']] = $item['name']; + } + // recurse into children + if (!empty($item['children']) && is_array($item['children'])) { + flattenMenu($item['children'], $flat); + } + } + return $flat; +} + + function slashUrl($url) { return rtrim($url, '/') . '/'; @@ -2705,7 +2797,7 @@ function slashUrl($url) function parseNodes($nodes, $child = null, $class = null) { if (empty($child)) { - $ul = '
+ \ No newline at end of file diff --git a/themes/readable/layout.html.php b/themes/readable/layout.html.php index 5baa99a7..7082c06a 100644 --- a/themes/readable/layout.html.php +++ b/themes/readable/layout.html.php @@ -53,5 +53,6 @@ + \ No newline at end of file diff --git a/themes/tailwind/layout.html.php b/themes/tailwind/layout.html.php index 66ff422f..d0771efa 100644 --- a/themes/tailwind/layout.html.php +++ b/themes/tailwind/layout.html.php @@ -157,5 +157,6 @@ + diff --git a/themes/twentyfifteen/layout.html.php b/themes/twentyfifteen/layout.html.php index 54b69fff..c94953b8 100644 --- a/themes/twentyfifteen/layout.html.php +++ b/themes/twentyfifteen/layout.html.php @@ -123,5 +123,6 @@ + diff --git a/themes/twentysixteen/layout.html.php b/themes/twentysixteen/layout.html.php index 821f0ad2..41c9372e 100644 --- a/themes/twentysixteen/layout.html.php +++ b/themes/twentysixteen/layout.html.php @@ -151,5 +151,6 @@ + \ No newline at end of file