commit 089c23f0f6939b829aeec53f3eeeb86465f58d23 Author: Jean-Christian Denis Date: Sun Jul 23 00:50:27 2023 +0200 first commit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..29dfe9c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +0.1 - 2023.xx.xx +- require dotclear 2.27 +- require php 7.4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..73c037d --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# README + + +## WHAT IS DOTCLEARWATCH ? + +"dotclear watch" is a plugin for the open-source +web publishing software called Dotclear. + +It tracks Dotclear's installation to get stats about it. + +What's being track : + * Dotclear version, + * PHP version, + * Database version, + * List of installed modules (only id and version) + * Number of blogs. (no name or id or whatever else) + +If you want to hide some modules, just enter their IDs in a comma separeted list +in aboutConfig global parameters called DotclearWatch->hidden_modules + +## REQUIREMENTS + + DotclearWatch requires: + + * super admin permission to intall it + * Dotclear 2.27 + +## USAGE + +Install DotclearWatch, manualy from a zip package or from +Dotaddict repository. (See Dotclear's documentation to know how do this) + +To disable sending stats, just deactivate or uninstall this plugin. + +## LINKS + + * License : [GNU GPL v2](https://www.gnu.org/licenses/old-licenses/lgpl-2.0.html) + * Source & contributions : [GitHub Page](https://github.com/JcDenis/DotclearWatch) + * Packages & details : [Dotaddict Page](https://plugins.dotaddict.org/dc2/details/DotclearWatch) + * Discussion & Help : [Github issue](https://github.com/JcDenis/DotclearWatch/issues) + +## CONTRIBUTORS + + * Jean-Christian Denis (author) + + You are welcome to contribute to this code. \ No newline at end of file diff --git a/_define.php b/_define.php new file mode 100644 index 0000000..caf8a83 --- /dev/null +++ b/_define.php @@ -0,0 +1,15 @@ +registerModule( + 'Dotclear Watch', + 'Send report about your Dotclear', + 'Jean-Christian Denis and contributors', + '0.1', + [ + 'requires' => [ + ['php', '7.4'], + ['core', '2.27'], + ], + 'type' => 'plugin', + ] +); diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..71c5ea4 --- /dev/null +++ b/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/locales/fr/main.po b/locales/fr/main.po new file mode 100644 index 0000000..04a92b5 --- /dev/null +++ b/locales/fr/main.po @@ -0,0 +1,51 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Project-Id-Version: DotclearWatch 0.1\n" +"POT-Creation-Date: \n" +"PO-Revision-Date: 2023-07-22T22:18:27+00:00\n" +"Last-Translator: Jean-Christain Denis\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Cache directory sucessfully cleared." +msgstr "Répertoire de cache des rapports nettoyé." + +msgid "Settings successfully updated." +msgstr "Paramètres mis à jour." + +msgid "Report sent." +msgstr "Rapport envoyé." + +msgid "Settings are globals. Reports are by blog." +msgstr "Les paramètres sont globaux. Les rapports sont par blog." + +msgid "Hidden modules:" +msgstr "Modules cachés :" + +msgid "This is the comma separated list of plugins IDs and themes IDs to ignore in report." +msgstr "C'est la liste des modules cachés séparés par une virgule." + +msgid "Distant API URL:" +msgstr "URL de l'API distante :" + +msgid "This is the URL of the API to send report. Leave empty to reset value." +msgstr "C'est L'URL de l'API où sera envoyer le rapport. Laisser vide pour remettre par défaut." + +msgid "Clear reports cache directory" +msgstr "Nettoyer le répertoire de cache des rapports." + +msgid "This deletes all blogs reports in cache." +msgstr "Ceci efface tous les rapports des blogs en cache." + +msgid "Send report now" +msgstr "Envoyer le rapport maintenant" + +msgid "This sent report for current blog even if report exists in cache." +msgstr "Ceci envoie le rapport pou r le blog courant même si un rapport existe en cache." + +msgid "Report that will be sent for this blog:" +msgstr "Rapport qui sera envoyé pour ce blog :" + diff --git a/src/Backend.php b/src/Backend.php new file mode 100644 index 0000000..7fa6193 --- /dev/null +++ b/src/Backend.php @@ -0,0 +1,32 @@ +addBehaviors([ + 'adminDashboardHeaders' => [Utils::class, 'sendReport'], + 'adminPageFooterV2' => [Utils::class, 'addMark'], + ]); + + return true; + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..e71ff67 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,136 @@ +auth->user_prefs->get('interface')->get('colorsyntax')) { + dcCore::app()->addBehavior('pluginsToolsHeadersV2', fn (bool $plugin): string => Page::jsLoadCodeMirror(dcCore::app()->auth->user_prefs->get('interface')->get('colorsyntax_theme'))); + } + + self::$hidden_modules = (string) My::settings()->getGlobal('hidden_modules'); + self::$distant_api_url = (string) My::settings()->getGlobal('distant_api_url'); + + if (empty($_POST)) { + return true; + } + + if (!empty($_POST['clear_cache'])) { + Utils::clearCache(); + Notices::AddSuccessNotice(__('Cache directory sucessfully cleared.')); + } + + self::$hidden_modules = ''; + foreach (explode(',', $_POST['hidden_modules']) as $hidden) { + $hidden = trim($hidden); + if (!empty($hidden)) { + self::$hidden_modules .= trim($hidden) . ','; + } + } + + self::$distant_api_url = !empty($_POST['distant_api_url']) && is_string($_POST['distant_api_url']) ? $_POST['distant_api_url'] : Utils::DISTANT_API_URL; + + My::settings()->put('hidden_modules', self::$hidden_modules, 'string', 'Hidden modules from report', true, true); + My::settings()->put('distant_api_url', self::$distant_api_url, 'string', 'Distant API report URL', true, true); + Notices::AddSuccessNotice(__('Settings successfully updated.')); + + if (!empty($_POST['send_report'])) { + Utils::sendReport(true); + Notices::AddSuccessNotice(__('Report sent.')); + } + + dcCore::app()->admin->url->redirect('admin.plugins', ['module' => My::id(), 'conf' => '1']); + + return true; + } + + public static function render(): void + { + if (!self::status()) { + return; + } + + echo + (new Div())->items([ + (new Text('p', __('Settings are globals. Reports are by blog.')))->class('message'), + (new Text('pre', sprintf(__('API %s'), Utils::DISTANT_API_VERSION))), + (new Para())->items([ + (new Label(__('Hidden modules:')))->for('hidden_modules'), + (new Input('hidden_modules'))->class('maximal')->size(65)->maxlenght(255)->value(self::$hidden_modules), + ]), + (new Note())->class('form-note')->text(__('This is the comma separated list of plugins IDs and themes IDs to ignore in report.')), + (new Para())->items([ + (new Label(__('Distant API URL:')))->for('distant_api_url'), + (new Input('distant_api_url'))->class('maximal')->size(65)->maxlenght(255)->value(self::$distant_api_url), + ]), + (new Note())->class('form-note')->text(__('This is the URL of the API to send report. Leave empty to reset value.')), + (new Para())->items([ + (new Checkbox('clear_cache', false))->value(1), + (new Label(__('Clear reports cache directory'), Label::OUTSIDE_LABEL_AFTER))->for('clear_cache')->class('classic'), + ]), + (new Note())->class('form-note')->text(__('This deletes all blogs reports in cache.')), + (new Para())->items([ + (new Checkbox('send_report', false))->value(1), + (new Label(__('Send report now'), Label::OUTSIDE_LABEL_AFTER))->for('send_report')->class('classic'), + ]), + (new Note())->class('form-note')->text(__('This sent report for current blog even if report exists in cache.')), + ])->render(); + + $contents = Utils::getReport(); + if (!empty($contents)) { + echo + (new Para())->items([ + (new Label(__('Report that will be sent for this blog:')))->for('report_contents'), + (new Textarea('report_contents', Html::escapeHTML($contents))) + ->cols(165) + ->rows(14) + ->readonly(true) + ->class('maximal'), + ])->render() . + ( + !dcCore::app()->auth->user_prefs->get('interface')->get('colorsyntax') ? '' : + Page::jsRunCodeMirror(My::id() . 'editor', 'report_contents', 'json', dcCore::app()->auth->user_prefs->get('interface')->get('colorsyntax_theme')) + ); + } + } +} diff --git a/src/Install.php b/src/Install.php new file mode 100644 index 0000000..71a57d4 --- /dev/null +++ b/src/Install.php @@ -0,0 +1,40 @@ +put( + 'hidden_modules', + 'DotclearWatch', + 'string', + 'Hidden modules from report', + false, + true + ); + $s->put( + 'distant_api_url', + 'https://dotclear.watch/report', + 'string', + 'Distant API report URL', + false, + true + ); + } + + return self::status(); + } +} diff --git a/src/My.php b/src/My.php new file mode 100644 index 0000000..bf248b2 --- /dev/null +++ b/src/My.php @@ -0,0 +1,17 @@ +auth->isSuperAdmin(); + } +} diff --git a/src/Utils.php b/src/Utils.php new file mode 100644 index 0000000..5b775f5 --- /dev/null +++ b/src/Utils.php @@ -0,0 +1,260 @@ + The hiddens modules IDs */ + private static array $hiddens = []; + + /** + * Add mark to backend menu footer. + */ + public static function addMark(): void + { + if (My::settings()->get('distant_api_url')) { + echo '

' . __('/!\ Tracked by dotclear.watch') . '

'; + } + } + + /** + * Get hidden modules. + * + * This does not check if module exists. + * + * @return array The hiddens modules + */ + public static function getHiddens(): array + { + if (empty(self::$hiddens)) { + foreach (explode(',', (string) My::settings()->getGlobal('hidden_modules')) as $hidden) { + $hidden = trim($hidden); + if (!empty($hidden)) { + self::$hiddens[] = trim($hidden); + } + } + } + + return self::$hiddens; + } + + /** + * Get plugins list. + * + * @param bool $strict tak on ly enabled and not hidden plugins + * + * @return array The plugins list. + */ + public static function getPlugins(bool $strict = true): array + { + $modules = []; + $hiddens = self::getHiddens(); + $defines = dcCore::app()->plugins->getDefines($strict ? ['state' => dcModuleDefine::STATE_ENABLED] : []); + foreach ($defines as $define) { + if ($strict && in_array($define->getId(), $hiddens)) { + continue; + } + $modules[$define->getId()] = $define->get('version'); + } + + return $modules; + } + + /** + * Get themes list. + * + * @param bool $strict tak on ly enabled and not hidden themes + * + * @return array The themes list. + */ + public static function getThemes(bool $strict = true): array + { + if (!(dcCore::app()->themes instanceof dcThemes)) { + dcCore::app()->themes = new dcThemes(); + dcCore::app()->themes->loadModules(dcCore::app()->blog->themes_path); + } + + $modules = []; + $hiddens = self::getHiddens(); + $defines = dcCore::app()->themes->getDefines($strict ? ['state' => dcModuleDefine::STATE_ENABLED] : []); + foreach ($defines as $define) { + if ($strict && in_array($define->getId(), $hiddens)) { + continue; + } + $modules[$define->getId()] = $define->get('version'); + } + + return $modules; + } + + /** + * Get report contents. + * + * @return string The report contents as it will be sent + */ + public static function getReport(): string + { + return self::check() ? self::contents() : ''; + } + + /** + * Clear cache directory. + */ + public static function clearCache(): void + { + if (self::check()) { + self::clear(); + } + } + + /** + * Send report. + * + * Report will be by blog to keep track of all used themes. + * Plugins stats are by multiblogs and themes stats by blog. + * + * @param bool $force Send report even if delay not expired + */ + public static function sendReport(bool $force = false): void + { + if (!self::check()) { + return; + } + + $file = self::file(); + + if (!$force && !self::expired($file)) { + return; + } + + $contents = self::contents(); + + self::write($file, $contents); + + try { + $rsp = HttpClient::quickPost(sprintf(self::url(), 'report'), ['key' => self::key(), 'report' => $contents]); + if ($rsp !== 'ok') { + pdump($rsp); + } + } catch (Exception $e) { + dcCore::app()->error->add(__('Dotclear.watch report failed')); + } + } + + private static function check(): bool + { + return defined('DC_MASTER_KEY') && defined('DC_CRYPT_ALGO') && defined('DC_TPL_CACHE') && is_dir(DC_TPL_CACHE) && is_writable(DC_TPL_CACHE); + } + + private static function key(): string + { + return Crypt::hmac(DC_MASTER_KEY, My::id() . __DIR__, DC_CRYPT_ALGO); + } + + private static function uid(): string + { + return md5(DC_MASTER_KEY . My::id()); + } + + private static function buid(): string + { + return md5(DC_MASTER_KEY . My::id() . dcCore::app()->blog->uid); + } + + private static function url() + { + $api_url = My::settings()->getGlobal('distant_api_url'); + + return (is_string($api_url) ? $api_url : self::DISTANT_API_URL) . '/' . self::DISTANT_API_VERSION . '/%s/' . self::uid(); + } + + private static function file(): string + { + $file = self::buid(); + + return sprintf( + '%s/%s/%s/%s/%s.json', + (string) Path::real(DC_TPL_CACHE), + My::id(), + substr($file, 0, 2), + substr($file, 2, 2), + $file + ); + } + + private static function clear(): void + { + $path = (string) Path::real(DC_TPL_CACHE) . DIRECTORY_SEPARATOR . My::id(); + if (is_dir($path)) { + Files::delTree($path); + } + } + + private static function write(string $file, string $contents): void + { + $dir = dirname($file); + if (!is_dir($dir)) { + Files::makeDir($dir, true); + } + file_put_contents($file, $contents); + } + + private static function read(string $file): string + { + return is_file($file) && is_readable($file) ? (string) file_get_contents($file) : ''; + } + + private static function expired(string $file): bool + { + if (!is_file($file) || !is_readable($file) || ($time = filemtime($file)) === false) { + return true; + } + + $time = date('U', $time); + if (!is_numeric($time) || (int) $time + self::EXPIRED_DELAY < time()) { + return true; + } + + return false; + } + + private static function contents(): string + { + // Build json response + return (string) json_encode([ + 'uid' => self::uid(), + 'buid' => self::buid(), + 'plugin' => self::getPlugins(), // enabled plugins + 'theme' => self::getThemes(), // enabled themes + 'server' => [ + 'blogs_count' => (int) dcCore::app()->getBlogs([], true)->f(0), + 'core' => DC_VERSION, + 'php' => phpversion(), + 'thm' => (string) dcCore::app()->blog->settings->get('system')->get('theme'), // selected theme + ], + 'database' => [ + dcCore::app()->con->driver() => dcCore::app()->con->version(), + ], + ], JSON_PRETTY_PRINT); + } +}