From 4088623e8a9896d1b952b8ece26f28066faf2ed8 Mon Sep 17 00:00:00 2001 From: Ente Date: Tue, 4 Nov 2025 18:38:58 +0100 Subject: [PATCH 01/24] v8.3.2: Demo --- CHANGELOG.md | 5 ++ README.md | 4 ++ VERSION | 2 +- api/v1/inc/app.json.sample | 3 +- composer.json | 2 +- demo_setup.sh | 31 +++++++++++ .../20250331160543_init_user_scheme.php | 1 + migrations/seeds/DemoSeed.php | 53 +++++++++++++++++++ suite/login.php | 5 ++ 9 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 demo_setup.sh create mode 100644 migrations/seeds/DemoSeed.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c335c57..8c316cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## v8.3.2 + +* Added script to initialize the database for demo purposes when using Docker (See `README.md`) +* Fixed missing `active` column within the `users` table when initializing the database for the first time + ## v8.3.1 * Removed deprecated `app` attribute from `general` section within `app.json` diff --git a/README.md b/README.md index 0456851..40949cd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ You can quickly get started with TimeTrack using Docker. Follow these steps: Certain features, like the NFC login may require additional setup for parsing the USB device. +If you want to use the demo, you can run the provided `demo_setup.sh` script within the project root. This will automatically setup the database with demo data (worktimes and users) and rebuilds the entire container. +You may want to set the `demo` setting within the `app.json` to `true` to display the demo credentials on the login page. + ### Requirements - PHP 8.2 (`curl|gd|gmp|intl|mbstring|mysqli|openssl|xsl|gettext|dom|ldap`) - tested with PHP 8.2.26 @@ -74,6 +77,7 @@ In step 2, you need to configure the `app.json.sample` within the `api/v1/inc` f - `timezone`: Set the timezone of your application, e.g. `Europe/Berlin` or `America/New_York` (default: `UTC`) - `force_theme`: Force a theme for all users, this disables the feature allowing users to set their own theme. - `theme_file`: If `force_theme` is true, the specified theme is used (default: `/assets/css/v8.css`) +- `demo`: If set to `true`, demo credentials are shown on the login page. Useful for demo installations. #### **SMTP section** diff --git a/VERSION b/VERSION index 905c243..9246c4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.3.1 \ No newline at end of file +8.3.2 \ No newline at end of file diff --git a/api/v1/inc/app.json.sample b/api/v1/inc/app.json.sample index e35a914..bf96ff3 100644 --- a/api/v1/inc/app.json.sample +++ b/api/v1/inc/app.json.sample @@ -7,7 +7,8 @@ "auto_update": "false", "timezone": "UTC", "theme_file": "/assets/css/v8.css", - "force_theme": "false" + "force_theme": "false", + "demo": false }, "mysql": { "db_host": "db", diff --git a/composer.json b/composer.json index 1ab6355..6eb643f 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.3.1", + "version": "8.3.2", "authors": [ { "name": "Bryan Boehnke-Avan", diff --git a/demo_setup.sh b/demo_setup.sh new file mode 100644 index 0000000..07e423f --- /dev/null +++ b/demo_setup.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +echo "TimeTrack Demo Setup starting..." +WORKDIR="$(dirname "$(realpath "$0")")" +cd "$WORKDIR" + +echo "Stopping existing demo container..." +docker compose -f docker-compose.yml down -v || true + +echo "Building image..." +docker build -t openducks/timetrack . + +echo "Starting demo instance..." +docker compose -f docker-compose.yml up -d + +echo "Waiting for database to be ready..." +sleep 10 + + +echo "Running migrations..." +docker exec timetrack vendor/bin/phinx migrate -e production + +echo "Seeding demo data..." +docker exec timetrack vendor/bin/phinx seed:run -s DemoSeed -e production + + +echo "✅ Demo instance ready!" +echo " URL: http://localhost:8080" +echo " Admin: demo_admin / demo123" +echo " User : demo_user / demo123" diff --git a/migrations/migrations/20250331160543_init_user_scheme.php b/migrations/migrations/20250331160543_init_user_scheme.php index cc8abe8..28c4f55 100644 --- a/migrations/migrations/20250331160543_init_user_scheme.php +++ b/migrations/migrations/20250331160543_init_user_scheme.php @@ -19,6 +19,7 @@ public function change(): void ->addColumn("username", "string", ["limit" => 255]) ->addColumn("email", "string", ["limit" => 256]) ->addColumn("password", "string", ["limit" => 256]) + ->addColumn("active", "boolean", ["default" => true, "null" => false]) ->addColumn("email_confirmed", "boolean") ->addColumn("isAdmin", "string", ["limit" => 256]) ->addColumn("state", "text", ["null" => true]) diff --git a/migrations/seeds/DemoSeed.php b/migrations/seeds/DemoSeed.php new file mode 100644 index 0000000..43d1359 --- /dev/null +++ b/migrations/seeds/DemoSeed.php @@ -0,0 +1,53 @@ + 'demo_admin', 'password' => password_hash('demo123', PASSWORD_DEFAULT), 'email' => 'admin@example.com', 'active' => 1], + ['username' => 'demo_user', 'password' => password_hash('demo123', PASSWORD_DEFAULT), 'email' => 'user@example.com', 'active' => 1], + ]; + $this->insert('users', $users); + + $usernames = ['demo_admin', 'demo_user']; + + $types = ['normal', 'remote', 'training', 'meeting', 'vacation', 'sickness']; + $locations = ['Berlin', 'Hamburg', 'Home Office', 'Munich']; + $projects = ['Website Relaunch', 'Customer Portal', 'TimeTrack QA', 'Internal Review']; + + $today = new DateTimeImmutable('today'); + $entries = []; + + foreach ($usernames as $username) { + for ($i = 0; $i < 10; $i++) { + $date = $today->sub(new DateInterval("P{$i}D"))->format('Y-m-d'); + + $startHour = rand(7, 9); + $endHour = $startHour + 8; + $pauseStart = sprintf("%02d:00", rand(12, 13)); + $pauseEnd = date('H:i', strtotime($pauseStart) + 1800); + + $entries[] = [ + 'name' => ucfirst(str_replace('_', ' ', $username)), + 'email' => "{$username}@example.com", + 'schicht_tag' => $date, + 'schicht_anfang' => sprintf("%02d:00", $startHour), + 'schicht_ende' => sprintf("%02d:00", $endHour), + 'username' => $username, + 'ort' => $locations[array_rand($locations)], + 'active' => 1, + 'review' => (rand(0, 10) > 1) ? 1 : 0, + 'type' => $types[array_rand($types)], + 'pause_start' => $pauseStart, + 'pause_end' => $pauseEnd, + 'attachements' => null, + 'project' => $projects[array_rand($projects)], + ]; + } + } + + $this->table('arbeitszeiten')->insert($entries)->saveData(); + + echo "Inserted " . count($entries) . " demo worktime entries.\n"; + } +} diff --git a/suite/login.php b/suite/login.php index 2aa78aa..d0ec3d4 100644 --- a/suite/login.php +++ b/suite/login.php @@ -33,6 +33,11 @@ + Demo Credentials:

"; + } + ?> read_plugin_configuration("nfclogin")["enabled"] == "true"){ From 0e881e6e741f594b7a1b513dae7d7c9fe5526792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bryan=20B=C3=B6hnke-Avan?= Date: Tue, 4 Nov 2025 18:42:17 +0100 Subject: [PATCH 02/24] Add demo link to README Added demo link to README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 40949cd..0e51737 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ TimeTrack aims to be an easy-to-use time recording software for small enterprise - Plugin Support - Exporting to PDF/CSV +A demo is available here: [https://tt-demo.openducks.org](https://tt-demo.openducks.org) + ## Installation ### Quick Install with Docker From 7dbe615004e318204335cd9e047123eb54ccf226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bryan=20B=C3=B6hnke-Avan?= Date: Thu, 6 Nov 2025 02:35:59 +0100 Subject: [PATCH 03/24] Clarify demo feature limitations Added note about limited features in demo. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0e51737..1de06a8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ TimeTrack aims to be an easy-to-use time recording software for small enterprise - Exporting to PDF/CSV A demo is available here: [https://tt-demo.openducks.org](https://tt-demo.openducks.org) +**The demo is available with limited features only, e.g. the plugin system disabled.** ## Installation From 42cd4dd539c8a0d93f7884d8dcaff8886a3f9521 Mon Sep 17 00:00:00 2001 From: Ente Date: Sat, 22 Nov 2025 16:26:51 +0100 Subject: [PATCH 04/24] Add migration script for 'pid' column in projects_items table --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- composer.json | 2 +- ...22144317_migrate_projects_i_d_to_p_i_d.php | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 migrations/migrations/20251122144317_migrate_projects_i_d_to_p_i_d.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c316cc..17bbd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## v8.3.3 + +**This update might require DB migration** - see `README.md` section `Database` + +* Added migration script for adding the `pid` column to the `projects_items` table for already existing installations. The script will not be executed for new installations. + ## v8.3.2 * Added script to initialize the database for demo purposes when using Docker (See `README.md`) diff --git a/VERSION b/VERSION index 9246c4f..910bb54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.3.2 \ No newline at end of file +8.3.3 \ No newline at end of file diff --git a/composer.json b/composer.json index 6eb643f..746fc36 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.3.2", + "version": "8.3.3", "authors": [ { "name": "Bryan Boehnke-Avan", diff --git a/migrations/migrations/20251122144317_migrate_projects_i_d_to_p_i_d.php b/migrations/migrations/20251122144317_migrate_projects_i_d_to_p_i_d.php new file mode 100644 index 0000000..575b62a --- /dev/null +++ b/migrations/migrations/20251122144317_migrate_projects_i_d_to_p_i_d.php @@ -0,0 +1,21 @@ +table("projects_items")->hasColumn("pid")){ + $this->table("projects_items")->addColumn("pid", "integer", [ + "null" => true, + "after" => "id" + ])->update(); + $this->execute('UPDATE projects_items SET pid = id WHERE pid IS NULL'); + } else { + echo "pid column already exists in projects_items table\n"; + } + } +} From eefb91e980c884c2e39f9e55a790d02ef5bcf26b Mon Sep 17 00:00:00 2001 From: Ente Date: Mon, 24 Nov 2025 23:49:03 +0100 Subject: [PATCH 05/24] v8.4 --- CHANGELOG.md | 7 ++ api/v1/class/i18n/i18n.arbeit.inc.php | 99 ++++++++++----- .../PluginDevTool.plugins.arbeit.inc.php | 8 -- .../class/plugins/plugins/nfcclock/README.md | 23 ++++ .../class/plugins/plugins/nfcclock/plugin.yml | 15 +++ .../plugins/plugins/nfcclock/src/Main.php | 63 ++++++++++ .../plugins/plugins/nfcclock/views/index.php | 19 +++ .../plugins/plugins/nfcclock/views/open.php | 2 + .../plugins/nfcclock/views/routes/login.php | 59 +++++++++ .../routes/nfcclock.ep.toil.arbeit.inc.php | 38 ++++++ .../settings.nfcclock.ep.toil.arbeit.inc.php | 38 ++++++ .../nfcclock/views/routes/settings.php | 115 ++++++++++++++++++ .../class/plugins/plugins/nfclogin/plugin.yml | 2 +- .../plugins/plugins/nfclogin/src/Main.php | 1 + .../routes/nfclclock.ep.toil.arbeit.inc.php | 74 +++++++++++ .../Permissions.routes.toil.arbeit.inc.php | 2 +- data/i18n/README.md | 15 +++ data/i18n/custom/admin/.gitkeep | 0 data/i18n/custom/plugins/.gitkeep | 0 data/i18n/custom/suite/.gitkeep | 0 20 files changed, 539 insertions(+), 41 deletions(-) create mode 100644 api/v1/class/plugins/plugins/nfcclock/README.md create mode 100644 api/v1/class/plugins/plugins/nfcclock/plugin.yml create mode 100644 api/v1/class/plugins/plugins/nfcclock/src/Main.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/index.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/open.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/routes/login.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/routes/nfcclock.ep.toil.arbeit.inc.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/routes/settings.nfcclock.ep.toil.arbeit.inc.php create mode 100644 api/v1/class/plugins/plugins/nfcclock/views/routes/settings.php create mode 100644 api/v1/class/plugins/plugins/nfclogin/src/routes/nfclclock.ep.toil.arbeit.inc.php create mode 100644 data/i18n/README.md create mode 100644 data/i18n/custom/admin/.gitkeep create mode 100644 data/i18n/custom/plugins/.gitkeep create mode 100644 data/i18n/custom/suite/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c316cc..a32ee76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## v8.4 + +* Added `nfcclock` plugin to allow clocking in and out with NFC tags (requires `nfclogin` plugin) +* Updated `nfclogin` plugin to version `1.2` (added Toil API route for `nfcclock` called `nfclclock`) +* Removed unused `extract_plugin` function from `PluginDevTool` class +* Custom language files can now be used with the `i18n` class by placing them within the `data/i18n/custom/` directory. This also works for plugins. + ## v8.3.2 * Added script to initialize the database for demo purposes when using Docker (See `README.md`) diff --git a/api/v1/class/i18n/i18n.arbeit.inc.php b/api/v1/class/i18n/i18n.arbeit.inc.php index 0b8f205..44497f9 100644 --- a/api/v1/class/i18n/i18n.arbeit.inc.php +++ b/api/v1/class/i18n/i18n.arbeit.inc.php @@ -24,45 +24,82 @@ class i18n */ public function loadLanguage($locale = null, $page = "index", $area = "suite"){ - if($locale == null){ - $locale = @basename(locale_accept_from_http($_SERVER["HTTP_ACCEPT_LANGUAGE"])); - if($locale == null){ - $locale = "en_EN"; - } + if ($locale == null) { + $locale = @basename(locale_accept_from_http($_SERVER["HTTP_ACCEPT_LANGUAGE"])); + if ($locale == null) { + $locale = "en_EN"; } + } + + $lang = substr($locale, 0, 2); + $lang_upper = strtoupper($lang); + + $default_path = dirname(__FILE__) . "/$area/{$page}/snippets_{$lang_upper}.json"; + if (file_exists($default_path)) { + $json_data = file_get_contents($default_path); + $decoded_data = json_decode($json_data, true); + + if (!is_array($decoded_data)) { + Exceptions::error_rep("Invalid JSON format in '$default_path'", 1, "N/A"); + return []; + } + + Exceptions::error_rep("Default language file for '$page' and '$area' with locale '$lang' loaded successfully", 1, "N/A"); + return $this->sanitizeOutput($decoded_data); + } + + $docRoot = rtrim(isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : '', '/\\'); + $custom_path = ($docRoot !== '' ? $docRoot : '') . "/data/i18n/custom/{$page}/snippets_{$lang_upper}.json"; + + if (file_exists($custom_path)) { + $json_data = file_get_contents($custom_path); + $decoded_data = json_decode($json_data, true); - $langlist = ["de", "en", "nl"]; - $locale = substr($locale, 0, 2); - - if (in_array($locale, $langlist)) { - $file_path = dirname(__FILE__) . "/$area/{$page}/snippets_" . strtoupper($locale) . ".json"; - if (file_exists($file_path)) { - $json_data = file_get_contents($file_path); - $decoded_data = json_decode($json_data, true); - - if (!is_array($decoded_data)) { - Exceptions::error_rep("Invalid JSON format in '$file_path'", 1, "N/A"); - return []; - } - - Exceptions::error_rep("Language files for '$page' and '$area' with locale '$locale' loaded successfully", 1, "N/A"); - return $this->sanitizeOutput($decoded_data); - } else { - Exceptions::error_rep("Could not retrieve language files for '$page' and '$area' and locale '$locale' | Using fallback language 'EN'", 1, "N/A"); - $fallback_path = dirname(__FILE__) . "/$area/{$page}/snippets_EN.json"; - - if (file_exists($fallback_path)) { - return $this->sanitizeOutput(json_decode(file_get_contents($fallback_path), true)); - } else { - return []; - } - } + if (!is_array($decoded_data)) { + Exceptions::error_rep("Invalid JSON format in custom file '$custom_path'", 1, "N/A"); + return []; + } + + Exceptions::error_rep("Custom language file for '$page' with locale '$lang' loaded successfully", 1, "N/A"); + return $this->sanitizeOutput($decoded_data); + } + + $fallback_path = dirname(__FILE__) . "/$area/{$page}/snippets_EN.json"; + if (file_exists($fallback_path)) { + $json_data = file_get_contents($fallback_path); + $decoded_data = json_decode($json_data, true); + + if (!is_array($decoded_data)) { + Exceptions::error_rep("Invalid JSON format in fallback file '$fallback_path'", 1, "N/A"); + return []; + } + + Exceptions::error_rep("Fallback English language file for '$page' and '$area' loaded", 1, "N/A"); + return $this->sanitizeOutput($decoded_data); } Exceptions::failure(1, "Could not retrieve language files for '$page' and '$area' and locale '$locale'", "N/A"); return []; } + public function loadCustomLanguageFile($file_path = "") { + if (empty($file_path) || !file_exists($file_path)) { + Exceptions::failure(1, "Invalid or non-existent file path provided for custom language file", "N/A"); + return []; + } + + $json_data = file_get_contents($file_path); + $decoded_data = json_decode($json_data, true); + + if (!is_array($decoded_data)) { + Exceptions::error_rep("Invalid JSON format in '$file_path'", 1, "N/A"); + return []; + } + + Exceptions::error_rep("Custom language file '$file_path' loaded successfully", 1, "N/A"); + return $this->sanitizeOutput($decoded_data); + } + public function sanitizeOutput($data) { if (is_array($data)) { return array_map([$this, 'sanitizeOutput'], $data); diff --git a/api/v1/class/plugins/PluginDevTool.plugins.arbeit.inc.php b/api/v1/class/plugins/PluginDevTool.plugins.arbeit.inc.php index 0232e9c..6985e52 100644 --- a/api/v1/class/plugins/PluginDevTool.plugins.arbeit.inc.php +++ b/api/v1/class/plugins/PluginDevTool.plugins.arbeit.inc.php @@ -34,14 +34,6 @@ public function create_plugin($name, $stub){ } return false; } - - public function extract_plugin($name){ - $this->logger(parent::$la . " Extracting plugin '$name'..."); - $plugins = $this->get_plugins(); - if(file_exists($name)){ - - } - } } } diff --git a/api/v1/class/plugins/plugins/nfcclock/README.md b/api/v1/class/plugins/plugins/nfcclock/README.md new file mode 100644 index 0000000..af99143 --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/README.md @@ -0,0 +1,23 @@ +# NFC Clock plugin + +This plugin allows you to use NFC tags to clock in and out of your time tracking system. By scanning an NFC tag, users can quickly log their work hours without needing to manually enter their information. +**This plugin requires the nfclogin plugin to function properly. Please ensure that the nfclogin plugin is enabled before using this plugin.** + +## Features + +- Clock in and out using NFC tags +- Automatic user identification based on NFC tag data + +## Installation + +1. Ensure that the nfclogin plugin is installed and enabled. +2. Download the NFC Clock plugin and place the `nfcclock` folder in the `api/v1/class/plugins/plugins/` directory. +3. Enable the plugin either within the `plugin.yml` file or through the `PluginManager` plugin. + +To allow the plugin to register its custom routes, make sure you open the "[nfcclock] Open About Page" once after enabling the plugin. + +## Using the Plugin + +1. Assign NFC tags to users using the nfclogin plugin. +2. Users have to open the NFC clocking URL on their device, which is `http:///api/v1/nfcclock` +3. When a user scans their assigned NFC tag, the plugin will log their clock-in or clock-out time automatically. A simple web interface will confirm either action. diff --git a/api/v1/class/plugins/plugins/nfcclock/plugin.yml b/api/v1/class/plugins/plugins/nfcclock/plugin.yml new file mode 100644 index 0000000..86e7572 --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/plugin.yml @@ -0,0 +1,15 @@ +name: nfcclock +src: /src +main: Main +namespace: NFCClock +author: Ente +description: 'Allow clocking in with NFC cards (requires nfclogin plugin)' +version: '1.0' +api: 0.1 +permissions: none +enabled: true +custom.values: + license: LIC +nav_links: + 'Open NFCClock': views/open.php + 'Open About Page': views/index.php \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfcclock/src/Main.php b/api/v1/class/plugins/plugins/nfcclock/src/Main.php new file mode 100644 index 0000000..c9cb418 --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/src/Main.php @@ -0,0 +1,63 @@ +set_plugin_configuration(); + $this->set_log_append(); + + $this->setup(); + } + + public function setup(): void { + $this->register_routes(); + } + + public function register_routes(): void { + Exceptions::error_rep("{$this->log_append} Registering custom routes..."); + CustomRoutes::registerCustomRoute("nfcclock", "/api/v1/class/plugins/plugins/nfcclock/views/routes/nfcclock.ep.toil.arbeit.inc.php", 2); + CustomRoutes::registerCustomRoute("nfcclocksettings", "/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.nfcclock.ep.toil.arbeit.inc.php", 2); + } + + public function set_log_append(): void { + $v = $this->read_plugin_configuration("nfcclock")["version"] ?? "unknown"; + $this->log_append = "[nfcclock v{$v}]"; + } + + public function get_log_append(): string { + return $this->log_append; + } + + public function set_plugin_configuration(): void { + $this->plugin_configuration = $this->read_plugin_configuration("nfcclock"); + } + + public function get_plugin_configuration(): array { + return $this->plugin_configuration; + } + + public function onDisable(): void { + $this->log_append = $this->get_log_append(); + } + + public function onEnable(): void {} + + public function onLoad(): void {} + +} diff --git a/api/v1/class/plugins/plugins/nfcclock/views/index.php b/api/v1/class/plugins/plugins/nfcclock/views/index.php new file mode 100644 index 0000000..397c390 --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/views/index.php @@ -0,0 +1,19 @@ + +
+
+
+

NFCClock Plugin

+

Welcome to the NFCClock plugin for TimeTrack!

+

This plugin allows you to clock in and out using NFC cards.

+

Go to Login Page or type in http://get_app_ini()["general"]["base_url"] ?>/api/v1/toil/nfcclock

+
+
\ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfcclock/views/open.php b/api/v1/class/plugins/plugins/nfcclock/views/open.php new file mode 100644 index 0000000..fe9d74e --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/views/open.php @@ -0,0 +1,2 @@ +get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclock"); +} + +if(isset($_GET["nosession"])){ + $status =''; + +} +?> + + + + + + + NFCClock - <?= $arbeit->get_app_ini()["general"]["app_name"] ?> + + + + +
+
+
+
+ +

NFCClock Login

+

Please click on the login button to log in with your NFC tag.
Hold it close to the NFC reader.

+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfcclock/views/routes/nfcclock.ep.toil.arbeit.inc.php b/api/v1/class/plugins/plugins/nfcclock/views/routes/nfcclock.ep.toil.arbeit.inc.php new file mode 100644 index 0000000..c40e33d --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/views/routes/nfcclock.ep.toil.arbeit.inc.php @@ -0,0 +1,38 @@ +$name = $value; + } + + public function __get($name){ + return $this->$name; + } + +} \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.nfcclock.ep.toil.arbeit.inc.php b/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.nfcclock.ep.toil.arbeit.inc.php new file mode 100644 index 0000000..aeaa086 --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.nfcclock.ep.toil.arbeit.inc.php @@ -0,0 +1,38 @@ +$name = $value; + } + + public function __get($name){ + return $this->$name; + } + +} \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.php b/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.php new file mode 100644 index 0000000..8022f6c --- /dev/null +++ b/api/v1/class/plugins/plugins/nfcclock/views/routes/settings.php @@ -0,0 +1,115 @@ + unset session and redirect to nfcclock + unset($_SESSION['nfcclock_user']); + header('Location: /api/v1/toil/nfcclock'); + exit; + } +} elseif (!empty($_GET['uid']) && !empty($_GET['user'])) { + $nfc = new NFClogin(); + $mappedUser = $nfc->getUser($_GET['uid']); + if ($mappedUser === $_GET['user']) { + // valid NFC -> set session + $_SESSION['nfcclock_user'] = $_GET['user']; + } else { + // invalid mapping -> redirect to nfcclock with error and original uid/block if present + $uri = $statusMessages->URIBuilder('wrongdata') ?? ''; + $params = ['uid=' . urlencode($_GET['uid'])]; + if (isset($_GET['block'])) { + $params[] = 'block=' . urlencode($_GET['block']); + } + header('Location: /api/v1/toil/nfcclock?' . $uri . (empty($uri) ? '' : '&') . implode('&', $params)); + exit; + } +} else { + // no session and no uid/user in query -> redirect to nfcclock + header('Location: /api/v1/toil/nfcclock?nosession=true'); + exit; +} + +$link_logout = "http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclock?logout=true"; + +$worktimeStatus = [ + "link" => "http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclocksettings?worktime_start=true", + "action" => "Start Worktime" +]; +$active = Arbeitszeit::check_easymode_worktime_finished($_SESSION["nfcclock_user"]); +$worktime = Arbeitszeit::get_worktime_by_id($active); + +if(isset($_GET["worktime_start"])){ + $arbeit->add_easymode_worktime($_SESSION["nfcclock_user"]); + $status =''; + header("Location: http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclocksettings"); +} + +if(isset($_GET["worktime_end"])){ + $arbeit->end_easymode_worktime($_SESSION["nfcclock_user"], $active); + $status =''; + header("Location: http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclocksettings"); +} + +if($active == -1){ + // start + $worktimeStatus = [ + "link" => "http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclocksettings?worktime_start=true", + "action" => "Start Worktime" + ]; +} else { + // end + $worktimeStatus = [ + "link" => "http://" . $arbeit->get_app_ini()["general"]["base_url"] . "/api/v1/toil/nfcclocksettings?worktime_end=true", + "action" => "End Worktime" + ]; +} +?> + + + + + + + NFCClock - <?= $arbeit->get_app_ini()["general"]["app_name"] ?> + + + + +
+
+
+
+
+
+

NFCClock Plugin - Settings

+
+

Status: 

+

Username: 

+
+
+
+
+
Logout +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/nfclogin/plugin.yml b/api/v1/class/plugins/plugins/nfclogin/plugin.yml index c2e821f..2c271b1 100644 --- a/api/v1/class/plugins/plugins/nfclogin/plugin.yml +++ b/api/v1/class/plugins/plugins/nfclogin/plugin.yml @@ -4,7 +4,7 @@ main: Main namespace: NFClogin author: Ente description: 'Allow the use of NFC cards to login.' -version: '1.1' +version: '1.2' api: 0.1 permissions: none enabled: true diff --git a/api/v1/class/plugins/plugins/nfclogin/src/Main.php b/api/v1/class/plugins/plugins/nfclogin/src/Main.php index 5dd7109..ba8cf60 100644 --- a/api/v1/class/plugins/plugins/nfclogin/src/Main.php +++ b/api/v1/class/plugins/plugins/nfclogin/src/Main.php @@ -42,6 +42,7 @@ public function register_routes(): void { CustomRoutes::registerCustomRoute("writeNfc", "/api/v1/class/plugins/plugins/nfclogin/src/routes/writeNfc.ep.toil.arbeit.inc.php", 1); CustomRoutes::registerCustomRoute("readBlock4", "/api/v1/class/plugins/plugins/nfclogin/src/routes/readBlock4.ep.toil.arbeit.inc.php", 1); CustomRoutes::registerCustomRoute("nfcclogin", "/api/v1/class/plugins/plugins/nfclogin/src/routes/nfcclogin.ep.toil.arbeit.inc.php", 2); + CustomRoutes::registerCustomRoute("nfclclock", "/api/v1/class/plugins/plugins/nfclogin/src/routes/nfclclock.ep.toil.arbeit.inc.php", 2); } public function set_log_append(): void { diff --git a/api/v1/class/plugins/plugins/nfclogin/src/routes/nfclclock.ep.toil.arbeit.inc.php b/api/v1/class/plugins/plugins/nfclogin/src/routes/nfclclock.ep.toil.arbeit.inc.php new file mode 100644 index 0000000..e08114b --- /dev/null +++ b/api/v1/class/plugins/plugins/nfclogin/src/routes/nfclclock.ep.toil.arbeit.inc.php @@ -0,0 +1,74 @@ +$name = $value; + } + + public function __get($name) + { + return $this->$name; + } + + public function get() + { + header('Content-Type: application/json'); + + try { + $nfc = new NFClogin; + $block = $nfc->readBlock4(); + $uid = $nfc->readCard()["uid"] ?? null; + $val = $block["value"]; + $statusMessages = new StatusMessages; + $getMapUser = $nfc->getUser($uid); + $dbUser = Benutzer::get_user_from_id($val)["username"] ?? null; + if($getMapUser == $dbUser){ + header("Location: /api/v1/toil/nfcclocksettings?uid={$uid}&user={$dbUser}"); + exit; + } else { + header("Location: /api/v1/toil/nfcclock?" . $statusMessages->URIBuilder("wrongdata") . "&uid={$uid}&block={$val}"); + exit; + } + + } catch (\Exception $e) { + Exceptions::error_rep("An error occurred while processing the NFC login: " . $e->getMessage()); + echo json_encode(["error" => true, "message" => "An error occured."]); + } + } + + public function post($post = null) + { + // Optional + } + + public function delete() + { + // Optional + } + + public function put() + { + // Optional + } + } +} diff --git a/api/v1/toil/Permissions.routes.toil.arbeit.inc.php b/api/v1/toil/Permissions.routes.toil.arbeit.inc.php index f0f6aaa..543bcef 100644 --- a/api/v1/toil/Permissions.routes.toil.arbeit.inc.php +++ b/api/v1/toil/Permissions.routes.toil.arbeit.inc.php @@ -39,7 +39,7 @@ private function loadPermissionSet(){ private function checkUserPermission($username){ $user = $this->arbeitszeit->benutzer()->get_user($username); - return $user["isAdmin"]; + return @$user["isAdmin"] ?? 0; } diff --git a/data/i18n/README.md b/data/i18n/README.md new file mode 100644 index 0000000..d869bc9 --- /dev/null +++ b/data/i18n/README.md @@ -0,0 +1,15 @@ +# Custom Language files + +This feature can be used, by placing your language files into this directory. + +The structure of the custom language files should follow the same structure as the default `loadLanguage()` method: + +`/data/i18n/custom/{page_identifier}/snippets_{language_code}.json` + +The workflow will be the following: + +- Locale requested has default language file? + - Yes: load default language file + - No: Locale requested has custom language file? + - Yes: load custom language file + - No: load default language file (English) \ No newline at end of file diff --git a/data/i18n/custom/admin/.gitkeep b/data/i18n/custom/admin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/i18n/custom/plugins/.gitkeep b/data/i18n/custom/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/i18n/custom/suite/.gitkeep b/data/i18n/custom/suite/.gitkeep new file mode 100644 index 0000000..e69de29 From 27bf2f95502a23e1ff5be6ee4f6838d3d9c0903b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bryan=20B=C3=B6hnke-Avan?= Date: Mon, 24 Nov 2025 23:57:23 +0100 Subject: [PATCH 06/24] Update README with demo limitations Added note about demo functionality issues. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1de06a8..6c8e267 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ TimeTrack aims to be an easy-to-use time recording software for small enterprise A demo is available here: [https://tt-demo.openducks.org](https://tt-demo.openducks.org) **The demo is available with limited features only, e.g. the plugin system disabled.** +**The demo currently does not work as intended...** ## Installation From 32995d461ddb8c9dac44adf2d5f8124d83cca05f Mon Sep 17 00:00:00 2001 From: Ente Date: Sun, 30 Nov 2025 19:29:21 +0100 Subject: [PATCH 07/24] v8.4.1: telemetry & telemetry server --- CHANGELOG.md | 7 + api/v1/class/arbeitszeit.inc.php | 13 +- .../class/i18n/suite/status/snippets_DE.json | 4 +- .../class/i18n/suite/status/snippets_EN.json | 4 +- .../class/i18n/suite/status/snippets_NL.json | 4 +- .../PluginBuilder.plugins.arbeit.inc.php | 14 +- api/v1/class/telemetry/server/README.md | 6 + .../server/Server.telemetry.arbeit.inc.php | 76 ++++++ api/v1/class/telemetry/server/server.php | 19 ++ .../class/telemetry/telemetry.arbeit.inc.php | 221 ++++++++++++++++++ api/v1/inc/app.json.sample | 4 +- api/v1/inc/arbeit.inc.php | 2 + api/v1/toil/Routes.toil.arbeit.inc.php | 3 + .../20251129222418_add_telemetry_table.php | 18 ++ ...51129230457_add_telemetry_server_table.php | 32 +++ suite/admin/actions/users/telemetry.php | 23 ++ suite/admin/users/settings.php | 21 ++ 17 files changed, 462 insertions(+), 9 deletions(-) create mode 100644 api/v1/class/telemetry/server/README.md create mode 100644 api/v1/class/telemetry/server/Server.telemetry.arbeit.inc.php create mode 100644 api/v1/class/telemetry/server/server.php create mode 100644 api/v1/class/telemetry/telemetry.arbeit.inc.php create mode 100644 migrations/migrations/20251129222418_add_telemetry_table.php create mode 100644 migrations/migrations/20251129230457_add_telemetry_server_table.php create mode 100644 suite/admin/actions/users/telemetry.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c316cc..d051a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## v8.4.1 + +**This update requires DB migration** - see `README.md` section `Database` + +* Added telemetry support (disabled by default, can be enabled within `app.json`) +* Added telemetry server to receive telemetry data (see `api/v1/class/telemetry/server/README.md` for more information) + ## v8.3.2 * Added script to initialize the database for demo purposes when using Docker (See `README.md`) diff --git a/api/v1/class/arbeitszeit.inc.php b/api/v1/class/arbeitszeit.inc.php index 8222d10..1eed762 100644 --- a/api/v1/class/arbeitszeit.inc.php +++ b/api/v1/class/arbeitszeit.inc.php @@ -15,6 +15,7 @@ use Arbeitszeit\Nodes; use Arbeitszeit\Projects; use Arbeitszeit\StatusMessages; + use Arbeitszeit\Telemetry; use Arbeitszeit\Events\EventDispatcherService; use Arbeitszeit\Events\EasymodeWorktimeAddedEvent; // "EasymodeWorktimeSTARTED" Event, actually. use Arbeitszeit\Events\EasymodeWorktimeEndedEvent; @@ -48,6 +49,7 @@ class Arbeitszeit private $mails; private $nodes; private $statusMessages; + private $telemetry; private $projects; @@ -919,9 +921,12 @@ public function projects(): Projects $this->projects = new Projects; return $this->projects; } + + public function telemetry(): Telemetry + { + if (!$this->telemetry) + $this->telemetry = new Telemetry; + return $this->telemetry; + } } } - - - -?> \ No newline at end of file diff --git a/api/v1/class/i18n/suite/status/snippets_DE.json b/api/v1/class/i18n/suite/status/snippets_DE.json index 76439ef..f32ebe4 100644 --- a/api/v1/class/i18n/suite/status/snippets_DE.json +++ b/api/v1/class/i18n/suite/status/snippets_DE.json @@ -49,5 +49,7 @@ "changed_sickness": "Hinweis: Die Krankheit wurde erfolgreich aktualisiert!", "changed_vacation": "Hinweis: Der Urlaub wurde erfolgreich aktualisiert!", "userinactive": "Fehler: Dein Konto wurde deaktiviert. Bitte wende dich an deinen Administrator!", - "plugins_disabled": "Fehler: Das Plugin-System ist deaktiviert. Bitte wende dich an deinen Administrator!" + "plugins_disabled": "Fehler: Das Plugin-System ist deaktiviert. Bitte wende dich an deinen Administrator!", + "telemetry_sent": "Hinweis: Telemetriedaten wurden erfolgreich gesendet!", + "telemetry_disabled": "Hinweis: Telemetriedaten wurden nicht gesendet!" } diff --git a/api/v1/class/i18n/suite/status/snippets_EN.json b/api/v1/class/i18n/suite/status/snippets_EN.json index 97df70d..6120fdb 100644 --- a/api/v1/class/i18n/suite/status/snippets_EN.json +++ b/api/v1/class/i18n/suite/status/snippets_EN.json @@ -49,5 +49,7 @@ "changed_sickness": "Note: Sickness successfully updated!", "changed_vacation": "Note: Vacation successfully updated!", "userinactive": "Error: Your account has been disabled. Please contact your administrator!", - "plugins_disabled": "Error: The plugin system is disabled. Please contact your administrator!" + "plugins_disabled": "Error: The plugin system is disabled. Please contact your administrator!", + "telemetry_sent": "Note: Telemetry data sent successfully!", + "telemetry_disabled": "Note: Telemetry data not sent!" } diff --git a/api/v1/class/i18n/suite/status/snippets_NL.json b/api/v1/class/i18n/suite/status/snippets_NL.json index 36dcb03..d8b9f19 100644 --- a/api/v1/class/i18n/suite/status/snippets_NL.json +++ b/api/v1/class/i18n/suite/status/snippets_NL.json @@ -49,5 +49,7 @@ "changed_sickness": "Opmerking: Ziekte succesvol bijgewerkt!", "changed_vacation": "Opmerking: Vakantie succesvol bijgewerkt!", "userinactive": "Fout: Je account is uitgeschakeld. Neem contact op met je beheerder!", - "plugins_disabled": "Fout: Het plugin-systeem is uitgeschakeld. Neem contact op met je beheerder!" + "plugins_disabled": "Fout: Het plugin-systeem is uitgeschakeld. Neem contact op met je beheerder!", + "telemetry_sent": "Opmerking: Telemetriegegevens succesvol verzonden!", + "telemetry_disabled": "Opmerking: Telemetriegegevens niet verzonden!" } diff --git a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php index 9b19037..90055e5 100644 --- a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php +++ b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php @@ -2,7 +2,7 @@ declare(strict_types=1); namespace Arbeitszeit{ - require_once $_SERVER["DOCUMENT_ROOT"] . "/vendor/autoload.php"; + require_once dirname(__DIR__, 4) . "/vendor/autoload.php"; use Symfony\Component\Yaml\Yaml; use Exception; @@ -474,6 +474,18 @@ public function loadPluginClass($pluginName) { } } + public function countPlugins(): int { + $this->logger("{$this->la} Counting plugins..."); + $plugins = $this->get_plugins(); + if (is_array($plugins) && isset($plugins['plugins'])) { + $count = count($plugins['plugins']); + $this->logger("{$this->la} Found {$count} plugins."); + return $count; + } + $this->logger("{$this->la} No plugins found."); + return 0; + } + /* Plugin section */ protected function onLoad(): void{ diff --git a/api/v1/class/telemetry/server/README.md b/api/v1/class/telemetry/server/README.md new file mode 100644 index 0000000..9d83184 --- /dev/null +++ b/api/v1/class/telemetry/server/README.md @@ -0,0 +1,6 @@ +# Self host telemetry server. + +Make sure your clients point to a specific client for the telemetry server. +Install TimeTrack on that server and start the telemetry server with `cd path/to/timetrack/api/v1/class/telemetry/server && php -S 0.0.0.0:8888 server.php` + +Inside DB check the results within the `telemetry_server` table. A GUI is comming soon. diff --git a/api/v1/class/telemetry/server/Server.telemetry.arbeit.inc.php b/api/v1/class/telemetry/server/Server.telemetry.arbeit.inc.php new file mode 100644 index 0000000..20319da --- /dev/null +++ b/api/v1/class/telemetry/server/Server.telemetry.arbeit.inc.php @@ -0,0 +1,76 @@ +listenForTelemetry(); + } + + public function listenForTelemetry() { + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo "Method not allowed. You have to use POST"; + return; + } + + $data = json_decode(file_get_contents("php://input"), true); + + if (!$data) { + http_response_code(400); + echo "Invalid JSON"; + return; + } + + $this->processTelemetry($data); + + http_response_code(200); + echo "OK"; + } + + public function processTelemetry($data) { + if ($this->isExistingInstance($data["instance_uuid"])) { + $this->updateTelemetry($data); + } else { + $this->insertTelemetry($data); + } + } + + public function isExistingInstance($uuid): bool { + $db = new \Arbeitszeit\DB(); + $sql = "SELECT COUNT(*) AS count FROM telemetry_server WHERE instance_uuid = :uuid"; + $stmt = $db->sendQuery($sql); + $stmt->execute(["uuid" => $uuid]); + $res = $stmt->fetch(\PDO::FETCH_ASSOC); + return $res["count"] > 0; + } + + public function insertTelemetry($data): void { + $db = new \Arbeitszeit\DB(); + $sql = "INSERT INTO telemetry_server + (instance_uuid, time_track_version, api_version, php_version, + os_version_and_name, total_plugins, total_users, total_worktimes, api_calls_total) + VALUES + (:instance_uuid, :time_track_version, :api_version, :php_version, + :os_version_and_name, :total_plugins, :total_users, :total_worktimes, :api_calls_total)"; + $db->sendQuery($sql)->execute($data); + } + + public function updateTelemetry($data): void { + $db = new \Arbeitszeit\DB(); + $sql = "UPDATE telemetry_server SET + time_track_version = :time_track_version, + api_version = :api_version, + php_version = :php_version, + os_version_and_name = :os_version_and_name, + total_plugins = :total_plugins, + total_users = :total_users, + total_worktimes = :total_worktimes, + api_calls_total = :api_calls_total, + updated_at = CURRENT_TIMESTAMP + WHERE instance_uuid = :instance_uuid"; + + $db->sendQuery($sql)->execute($data); + } +} diff --git a/api/v1/class/telemetry/server/server.php b/api/v1/class/telemetry/server/server.php new file mode 100644 index 0000000..6855d72 --- /dev/null +++ b/api/v1/class/telemetry/server/server.php @@ -0,0 +1,19 @@ +db = new DB; + $this->pb = new PluginBuilder; + } + + public function getAndSendTelemetryData(): void + { + + # get uuid + $uuid = $this->getInstanceUUID(); + # prepare data + $data = [ + "instance_uuid" => $uuid, + "time_track_version" => $this->getData("version", "VERSION"), + "api_version" => $this->getData("api_version", "API_VERSION"), + "php_version" => $this->getData("php_version", "PHP_VERSION"), + "os_version_and_name" => $this->getData("os_version_and_name", "OS_VERSION_AND_NAME"), + "total_plugins" => $this->getData("total_plugins", "PLUGINS"), + "total_users" => $this->getData("total_users", "ALL_USERS"), + "total_worktimes" => $this->getData("total_worktimes", "ALL_WORKTIMES"), + "api_calls_total" => $this->getData("api_calls_total", "API_CALLS_TOTAL") + ]; + $this->sendRequest($data); + } + + public function getInstanceUUID(): string + { + $this->checkForInstanceUUID(); + + $stmt = $this->db->sendQuery("SELECT instance_uuid FROM telemetry LIMIT 1"); + $stmt->execute(); + + $data = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $data["instance_uuid"]; + } + + public function isTelemetryEnabled(): bool + { + $ini = $this->get_app_ini(); + return isset($ini["general"]["telemetry"]) && $ini["general"]["telemetry"] === "enabled"; + } + + + +public function sendRequest($data) +{ + $ini = $this->get_app_ini(); + $debugMode = isset($ini["telemetry"]["debug"]) && $ini["telemetry"]["debug"] === true; + + $url = $ini["general"]["telemetry_server_url"] ?? "https://telemetry.openducks.org/timetrack/submit"; + $payload = json_encode($data); + + $ch = curl_init($url); + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + "Content-Type: application/json", + "Content-Length: " . strlen($payload) + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_FAILONERROR => false, + CURLOPT_VERBOSE => $debugMode, + ]); + + $verbose = null; + if ($debugMode) { + $verbose = fopen('php://temp', 'w+'); + curl_setopt($ch, CURLOPT_STDERR, $verbose); + } + + $response = curl_exec($ch); + $error = curl_error($ch); + $info = curl_getinfo($ch); + + $debugOutput = null; + if ($debugMode) { + rewind($verbose); + $debugOutput = stream_get_contents($verbose); + fclose($verbose); + } + + curl_close($ch); + + if ($debugMode) { + $log = "=== RESPONSE ===\n" . var_export($response, true) . + "\n\n=== ERROR ===\n" . var_export($error, true) . + "\n\n=== INFO ===\n" . var_export($info, true) . + "\n\n=== VERBOSE ===\n" . $debugOutput . + "\n\n=== PAYLOAD ===\n" . $payload . + "\n--------------------------------------------\n"; + + Exceptions::error_rep($log, "POST-API"); + } + + return $response; +} + + + + + +public function getData($name, $method = "DEFAULT") +{ + if ($method === "DEFAULT") { + $allowed = [ + "instance_uuid", + "api_calls_total", + "time_track_version", + "api_version", + "php_version", + "os_version_and_name", + "total_plugins", + "total_users", + "total_worktimes" + ]; + + if (!in_array($name, $allowed, true)) { + return null; + } + + $sql = "SELECT $name FROM telemetry LIMIT 1"; + + $stmt = $this->db->sendQuery($sql); + $stmt->execute(); + $data = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $data[$name] ?? null; + } + + if ($method === "VERSION") { + return $this->getTimeTrackVersion(); + } elseif ($method === "API_VERSION") { + return $this->getToilVersion(); + } elseif ($method === "PHP_VERSION") { + return phpversion(); + } elseif ($method === "PLUGINS") { + return $this->pb->countPlugins(); + } elseif ($method === "ALL_USERS") { + $stmt = $this->db->sendQuery("SELECT COUNT(id) as total FROM users"); + $stmt->execute(); + return $stmt->fetch(\PDO::FETCH_ASSOC)["total"]; + } elseif ($method === "ALL_WORKTIMES") { + $stmt = $this->db->sendQuery("SELECT COUNT(id) as total FROM arbeitszeiten"); + $stmt->execute(); + return $stmt->fetch(\PDO::FETCH_ASSOC)["total"]; + } elseif ($method === "OS_VERSION_AND_NAME") { + return php_uname(); + } elseif ($method === "API_CALLS_TOTAL") { + $stmt = $this->db->sendQuery("SELECT api_calls_total FROM telemetry LIMIT 1"); + $stmt->execute(); + return $stmt->fetch(\PDO::FETCH_ASSOC)["api_calls_total"]; + } +} + + + public function incrementAPICalls(): void + { + $sql = "UPDATE telemetry SET api_calls_total = api_calls_total + 1"; + $this->db->sendQuery($sql)->execute(); + } + + public function checkForInstanceUUID(): void + { + $stmt = $this->db->sendQuery("SELECT instance_uuid FROM telemetry"); + $stmt->execute(); + + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if (empty($rows)) { + $uuid = $this->generateUUID(); + $insert = $this->db->sendQuery( + "INSERT INTO telemetry (instance_uuid, api_calls_total) VALUES (?, 0)" + ); + $insert->execute([$uuid]); + return; + } + + if (count($rows) > 1) { + $keepUuid = $rows[0]["instance_uuid"]; + + $cleanup = $this->db->sendQuery( + "DELETE FROM telemetry WHERE instance_uuid != ?" + ); + $cleanup->execute([$keepUuid]); + } + + } + + + + private function generateUUID(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + } +} \ No newline at end of file diff --git a/api/v1/inc/app.json.sample b/api/v1/inc/app.json.sample index bf96ff3..785dbba 100644 --- a/api/v1/inc/app.json.sample +++ b/api/v1/inc/app.json.sample @@ -8,7 +8,9 @@ "timezone": "UTC", "theme_file": "/assets/css/v8.css", "force_theme": "false", - "demo": false + "demo": false, + "telemetry": "enabled", + "telemetry_server_url": "https://telemetry.openducks.org/timetrack/submit" }, "mysql": { "db_host": "db", diff --git a/api/v1/inc/arbeit.inc.php b/api/v1/inc/arbeit.inc.php index db59a69..d062293 100644 --- a/api/v1/inc/arbeit.inc.php +++ b/api/v1/inc/arbeit.inc.php @@ -27,6 +27,8 @@ require_once dirname(__DIR__, 1) . "/class/vacation/vacation.arbeit.inc.php"; require_once dirname(__DIR__, 1) . "/class/sickness/sickness.arbeit.inc.php"; require_once dirname(__DIR__, 1) . "/class/projects/projects.arbeit.inc.php"; +require_once dirname(__DIR__, 1) . "/class/telemetry/server/Server.telemetry.arbeit.inc.php"; +require_once dirname(__DIR__, 1) . "/class/telemetry/telemetry.arbeit.inc.php"; require_once dirname(__DIR__, 1) . "/class/exports/ExportModule.arbeit.inc.php"; require_once dirname(__DIR__, 1) . "/class/exports/modules/ExportModuleInterface.em.arbeit.inc.php"; diff --git a/api/v1/toil/Routes.toil.arbeit.inc.php b/api/v1/toil/Routes.toil.arbeit.inc.php index af109c3..ce5fdfd 100644 --- a/api/v1/toil/Routes.toil.arbeit.inc.php +++ b/api/v1/toil/Routes.toil.arbeit.inc.php @@ -15,6 +15,7 @@ use Toil\Controller; use Toil\Permissions; use Toil\Tokens; +use Arbeitszeit\Telemetry; class Routes extends Toil { @@ -147,6 +148,8 @@ public function routing($eventHandler) # Before letting user accessing the API endpoint, checking if authorized $permissions = new Permissions; + $telemetry = new Telemetry(); + $telemetry->incrementAPICalls(); preg_match("/\/([^\/?]+)(\?.*)?$/m", $_SERVER["REQUEST_URI"], $matches); if (!$permissions->checkPermissions($user, $matches[1])) { Exceptions::error_rep("[API] Failed checking permissions for expected endpoint: " . $matches[1]); diff --git a/migrations/migrations/20251129222418_add_telemetry_table.php b/migrations/migrations/20251129222418_add_telemetry_table.php new file mode 100644 index 0000000..b11d015 --- /dev/null +++ b/migrations/migrations/20251129222418_add_telemetry_table.php @@ -0,0 +1,18 @@ +hasTable('telemetry')) { + $table = $this->table('telemetry'); + $table->addColumn('instance_uuid', 'string', ['limit' => 36]) + ->addColumn("api_calls_total", "integer", ["default" => 0]) + ->create(); + } + } +} diff --git a/migrations/migrations/20251129230457_add_telemetry_server_table.php b/migrations/migrations/20251129230457_add_telemetry_server_table.php new file mode 100644 index 0000000..87bae31 --- /dev/null +++ b/migrations/migrations/20251129230457_add_telemetry_server_table.php @@ -0,0 +1,32 @@ +table('telemetry_server'); + + $table->addColumn('instance_uuid', 'string', ['limit' => 255]) + ->addColumn('time_track_version', 'string', ['limit' => 255]) + ->addColumn('api_version', 'string', ['limit' => 255]) + ->addColumn('php_version', 'string', ['default' => '', 'limit' => 255]) + ->addColumn('os_version_and_name', 'string', ['null' => true, 'default' => null]) + ->addColumn('total_plugins', 'integer', ['default' => 0]) + ->addColumn('total_users', 'integer', ['default' => 0]) + ->addColumn('total_worktimes', 'integer', ['default' => 0]) + ->addColumn('api_calls_total', 'integer', ['default' => 0]) + ->addColumn('created_at', 'datetime', [ + 'default' => 'CURRENT_TIMESTAMP' + ]) + ->addColumn('updated_at', 'datetime', [ + 'default' => 'CURRENT_TIMESTAMP', + 'update' => 'CURRENT_TIMESTAMP' + ]) + + ->create(); + } +} diff --git a/suite/admin/actions/users/telemetry.php b/suite/admin/actions/users/telemetry.php new file mode 100644 index 0000000..b372812 --- /dev/null +++ b/suite/admin/actions/users/telemetry.php @@ -0,0 +1,23 @@ +get_app_ini(); +$base_url = $ini["general"]["base_url"]; +$arbeit->auth()->login_validation(); +if($arbeit->benutzer()->is_admin($arbeit->benutzer()->get_user($_SESSION["username"]))){ + if($telemetry->isTelemetryEnabled()){ + echo "Sending telemetry data..."; + $telemetry->getAndSendTelemetryData(); + header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("telemetry_sent")); + } else { + header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("telemetry_disabled")); + } +} else { + header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("noperms")); +} +?> \ No newline at end of file diff --git a/suite/admin/users/settings.php b/suite/admin/users/settings.php index 1711a91..a9b039b 100644 --- a/suite/admin/users/settings.php +++ b/suite/admin/users/settings.php @@ -23,5 +23,26 @@
DAT; + ?> +
+

Telemetry

+

Here you can send anonymous telemetry data to help improve the application. + What data will be sent? Total user count, total API calls, version information, and general system information (PHP version, database type, etc.). + enabled."; + } else { + echo "Telemetry is currently disabled. This setting can only be changed by editing the configuration file manually."; + } ?> +

+ + +
+ +

+ +
+ +

Telemetry is disabled. To send telemetry data, please enable it in the configuration file.

+
\ No newline at end of file From 7bcf2a808992acda349f431bf4ef914cfb34256a Mon Sep 17 00:00:00 2001 From: Ente Date: Sun, 30 Nov 2025 19:31:48 +0100 Subject: [PATCH 08/24] versions --- VERSION | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 910bb54..f413137 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.3.3 \ No newline at end of file +8.4.1 \ No newline at end of file diff --git a/composer.json b/composer.json index 746fc36..40aee36 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.3.3", + "version": "8.4.1", "authors": [ { "name": "Bryan Boehnke-Avan", From a32e54ee8d6a29e9f256299b8cd1ae7445bfd1b8 Mon Sep 17 00:00:00 2001 From: Ente Date: Mon, 1 Dec 2025 21:34:23 +0100 Subject: [PATCH 09/24] v8.4.2: user-based permissions for plugins --- CHANGELOG.md | 5 + VERSION | 2 +- .../PluginBuilder.plugins.arbeit.inc.php | 267 ++++++++++++------ api/v1/class/plugins/docs/Plugins.md | 11 + .../plugins/plugins/codeclock/plugin.yml | 5 +- .../plugins/plugins/exportmanager/plugin.yml | 4 +- .../class/plugins/plugins/nfcclock/plugin.yml | 9 +- .../class/plugins/plugins/nfclogin/plugin.yml | 6 +- .../plugins/plugins/pluginmanager/plugin.yml | 4 +- .../class/plugins/plugins/qrclock/plugin.yml | 4 +- .../plugins/plugins/userdetail/plugin.yml | 4 +- .../class/plugins/plugins/utility/plugin.yml | 2 + assets/gui/standard_nav.php | 6 +- composer.json | 2 +- 14 files changed, 232 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e738995..d956e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## v8.4.2 + +* Added user based permissions for plugin views. +* Updated plugins to use new permission system. + ## v8.4.1 **This update requires DB migration** - see `README.md` section `Database` diff --git a/VERSION b/VERSION index f413137..7857a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.4.1 \ No newline at end of file +8.4.2 \ No newline at end of file diff --git a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php index 90055e5..3913762 100644 --- a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php +++ b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php @@ -1,7 +1,7 @@ set_basepath(); $this->set_testing(); $this->config = Arbeitszeit::get_app_ini()["plugins"]; @@ -68,7 +71,8 @@ public function __construct(){ * * @return void */ - public function set_basepath(): void{ + public function set_basepath(): void + { $this->basepath = Arbeitszeit::get_app_ini()["plugins"]["path"]; } @@ -79,7 +83,8 @@ public function set_basepath(): void{ * * @return string $this->basepath */ - public function get_basepath(): string{ + public function get_basepath(): string + { return (string) $this->basepath; } @@ -90,7 +95,8 @@ public function get_basepath(): string{ * * @return void */ - public function set_testing(): void{ + public function set_testing(): void + { $this->testing = (bool) Arbeitszeit::get_app_ini()["plugins"]["testing"]; } @@ -101,7 +107,8 @@ public function set_testing(): void{ * * @return bool */ - public static function get_testing(): bool{ + public static function get_testing(): bool + { return (bool) self::$testing; } @@ -113,11 +120,12 @@ public static function get_testing(): bool{ * @param string $class The class name * @param string $name Namespace of the class */ - final public function load_class($class, $name): void{ + final public function load_class($class, $name): void + { try { require_once $_SERVER["DOCUMENT_ROOT"] . $this->basepath . "/" . $name . "/" . $class . ".php"; - } catch (Exception $e){ - if($e == strpos($e->getMessage(), "require_once()")){ + } catch (Exception $e) { + if ($e == strpos($e->getMessage(), "require_once()")) { throw new Exception("Class could not be loaded!"); } else { throw new Exception("Unknown error."); @@ -130,14 +138,15 @@ final public function load_class($class, $name): void{ * * @return bool|void If everything went ok, void. If an error occurs false bool. */ - final public function initialize_plugins(): bool { + final public function initialize_plugins(): bool + { if ($this->testing == true) { $plugins = $this->get_plugins(); if ($plugins == false || $plugins == "false") { $this->logger("{$this->la} Could not get plugins. Please verify the plugin path given in the app.ini"); return false; } - + if (is_array($plugins["plugins"])) { // Hier wird überprüft, ob $plugins["plugins"] ein Array ist foreach ($plugins["plugins"] as $plugin => $keys) { $this->load_class($keys["main"], $plugin . "/src"); @@ -152,7 +161,7 @@ final public function initialize_plugins(): bool { $class->onLoad(); } } - + return true; } elseif ($this->testing == false) { } else { @@ -160,9 +169,10 @@ final public function initialize_plugins(): bool { } return false; } - - function platformSlashes($path) { + + function platformSlashes($path) + { if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') { $path = str_replace('/', '\\', $path); } @@ -177,28 +187,92 @@ function platformSlashes($path) { * @param bool $raw If set to true, the raw yaml is returned * @return array|bool|string Returns an array. False on failure */ - final public function read_plugin_configuration($name, $raw = false): array|string|bool{ - $la = $this->la; - $path = $_SERVER["DOCUMENT_ROOT"] . "". $this->basepath . "/" . $name . "/plugin.yml"; - if(file_exists($_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/" . $name . "/plugin.yml") == true){ + final public function read_plugin_configuration($name, $raw = false): array|string|bool + { + $la = $this->la; + $path = $_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/" . $name . "/plugin.yml"; + if (file_exists($_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/" . $name . "/plugin.yml") == true) { try { - if($raw == true){ + if ($raw == true) { $this->logger("{$la} Reading raw plugin configuration for plugin '{$name}'..."); return file_get_contents($this->platformSlashes($path)); } $this->logger("{$la} Reading plugin configuration for plugin '{$name}'..."); $yaml = Yaml::parseFile($this->platformSlashes($path)); - } catch(Exception $e){ + } catch (Exception $e) { Exceptions::error_rep($e); throw new \Exception($e->getMessage()); } $yaml["path"] = $path; - return (array)$yaml; - } else { + return (array) $yaml; + } else { Exceptions::error_rep("{$la} Could not read plugin configuration for plugin '{$name}' - Path: " . $_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/" . $name . "/plugin.yml"); return false; - - } + + } + } + + /** checkPluginPermissions() Checks the permissions for a view + * + * + * + * @param string $pluginName Name of the plugin + * @param string $view Name of the view + * @param string $user User Name + * @return bool Returns true if the user has permission, false otherwise + */ + final public function checkPluginPermissions($pluginName, $view, $user): bool + { + $la = $this->la; + $this->logger("{$la} Checking permissions for user '{$user}' on view '{$view}' of plugin '{$pluginName}'..."); + + $permissions = $this->read_plugin_configuration($pluginName); + $userPermissions = (int) Benutzer::get_user($user)["isAdmin"] ?? -1; + $adminLevel = 1; + $userLevel = 0; + $unauth = -1; + + if ($permissions === false) { + $this->logger("{$la} Could not read plugin configuration for plugin '{$pluginName}'"); + return false; + } + + $viewName = null; + if (isset($permissions['nav_links']) && is_array($permissions['nav_links'])) { + foreach ($permissions['nav_links'] as $linkName => $linkPath) { + if ($linkPath === $view || basename($linkPath) === basename($view)) { + $viewName = $linkName; + break; + } + } + } + + if ($viewName === null) { + $this->logger("{$la} Could not translate view path '{$view}' to nav_link name"); + return false; + } + + if (isset($permissions['nav_permissions'][$viewName])) { + $requiredPermission = $permissions['nav_permissions'][$viewName]; # either 0 or 1 + $this->logger("{$la} Required permission for view '{$viewName}': '{$requiredPermission}'"); + + if ($requiredPermission === $adminLevel && $userPermissions === $adminLevel) { + $this->logger("{$la} User '{$user}' has admin permissions for view '{$viewName}'. Access granted."); + return true; + } elseif ($requiredPermission === $userLevel) { + $this->logger("{$la} User '{$user}' has user permissions for view '{$viewName}'. Access granted."); + return true; + } else { + $this->logger("{$la} User '{$user}' does not have required permissions for view '{$viewName}'. Access denied."); + return false; + } + + + } else { + $this->logger("{$la} No specific permissions set for view '{$view}', allowing access by default."); + return false; # no default access + } + } /** @@ -206,21 +280,22 @@ final public function read_plugin_configuration($name, $raw = false): array|stri * * @return array|void Returns and array on success. Nothing otherwise */ - final public function get_plugins(): array|bool{ + final public function get_plugins(): array|bool + { $this->logger("{$this->la} Getting all plugins..."); - $dir = array_diff(scandir($_SERVER["DOCUMENT_ROOT"]. "" . $this->get_basepath()), array(".", "..", "data")); - if($dir == false){ + $dir = array_diff(scandir($_SERVER["DOCUMENT_ROOT"] . "" . $this->get_basepath()), array(".", "..", "data")); + if ($dir == false) { $this->logger("{$this->la} Could not scan directory for plugins!"); return false; } else { - foreach($dir as $plugin){ + foreach ($dir as $plugin) { $configuration = $this->read_plugin_configuration($plugin); $data["plugins"][$plugin] = $configuration; } $data_json = json_encode($data); - if($data_json != false){ + if ($data_json != false) { $this->logger("{$this->la} Returning all plugins..."); return $data; } @@ -238,14 +313,15 @@ final public function get_plugins(): array|bool{ * * @return void|Exception Void on success, Exception on failure */ - final public function memorize_plugins(): void{ + final public function memorize_plugins(): void + { Exceptions::deprecated(__FUNCTION__, "This function is not supported anymore."); $this->logger("{$this->la} Memorizing all plugins..."); $plugins = $this->get_plugins(); - foreach($plugins["plugins"] as $plugin => $data){ - try{ + foreach ($plugins["plugins"] as $plugin => $data) { + try { $this->load_class($data["main"], $plugin . "/src"); - + $class = $data["namespace"] . "\\" . $data["main"]; $cl = new $class; $cl = serialize($cl); @@ -253,11 +329,11 @@ final public function memorize_plugins(): void{ $handle = fopen($_SERVER["DOCUMENT_ROOT"] . "/" . $this->basepath . "/" . "data/" . $data["main"] . ".tp1", "w+"); fwrite($handle, $cl, strlen($cl) + 5); fclose($handle); - - } catch(Exception $e){ + + } catch (Exception $e) { Exceptions::error_rep($e); } - + } } @@ -270,15 +346,16 @@ final public function memorize_plugins(): void{ * @param array $additional_payload Additional data to save * @return bool|Exception Return true on success. Exception on failure */ - final public function memorize_plugin($name, $additional_payload = null): bool{ + final public function memorize_plugin($name, $additional_payload = null): bool + { Exceptions::deprecated(__FUNCTION__, "This function is not supported anymore."); $this->logger("{$this->la} Memorizing plugin '{$name}'..."); $plugin = $this->read_plugin_configuration($name); - try{ + try { $this->load_class($plugin["main"], $plugin["namespace"] . "/src"); $class = $plugin["namespace"] . "\\" . $plugin["main"]; $c1 = new $class; - if($additional_payload != null){ + if ($additional_payload != null) { $c1->additional_payload = $additional_payload; } $cl = serialize($c1); @@ -286,9 +363,9 @@ final public function memorize_plugin($name, $additional_payload = null): bool{ fwrite($handle, $cl, strlen($cl) + 5); $this->logger("{$this->la} File '{$plugin["main"]}.tp1' created!"); return true; - } catch(Exception $e){ - Exceptions::error_rep($e); - return false; + } catch (Exception $e) { + Exceptions::error_rep($e); + return false; } } @@ -301,24 +378,25 @@ final public function memorize_plugin($name, $additional_payload = null): bool{ * @param string $name Class name of the plugin * @return object|bool|Exception Returns the class on success and either false or an Exception on failure */ - final public function unmemorize_plugin($name): object|bool{ + final public function unmemorize_plugin($name): object|bool + { Exceptions::deprecated(__FUNCTION__, "This function is not supported anymore."); $this->logger("{$this->la} Unmemorizing plugin '{$name}'..."); $plugin = $this->read_plugin_configuration($name); - try{ + try { $this->load_class($plugin["main"], $plugin["namespace"] . "/src"); $class = $plugin["namespace"] . "\\" . $plugin["main"]; $c1 = new $class; $class = file_get_contents($_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/" . "data/" . $plugin["main"] . ".tp1"); $cl = unserialize($class, array("allowed_classes" => true)); - if($cl instanceof $c1){ + if ($cl instanceof $c1) { return $cl; } else { $this->logger("{$this->la} Could not unmemorize plugin '{$name}'"); return false; } - } catch(Exception $e){ + } catch (Exception $e) { Exceptions::error_rep($e); return false; } @@ -332,14 +410,15 @@ final public function unmemorize_plugin($name): object|bool{ * * @return bool */ - final public function check_persistance(): bool{ + final public function check_persistance(): bool + { Exceptions::deprecated(__FUNCTION__, "This function is not supported anymore."); $this->logger("{$this->la} Checking persistance for all plugins..."); $plugins = $this->get_plugins(); - foreach($plugins["plugins"] as $plugin => $data){ + foreach ($plugins["plugins"] as $plugin => $data) { $dir = array_diff(scandir($_SERVER["DOCUMENT_ROOT"] . "" . $this->basepath . "/"), array(".", "..", "_data")); - if(!in_array($plugin, $dir)){ - if(!$this->memorize_plugin($data["main"])){ + if (!in_array($plugin, $dir)) { + if (!$this->memorize_plugin($data["main"])) { $this->logger("{$this->la} Could not create persistance for plugin '{$plugin}'"); return false; } @@ -357,7 +436,8 @@ final public function check_persistance(): bool{ * * @param string $name */ - final static public function create_skeletton($name){ + final static public function create_skeletton($name) + { self::logger("[PluginBuilder] Creating plugin skeletton '{$name}'"); $path = $_SERVER["DOCUMENT_ROOT"] . "/" . self::$basepath . "/" . $name; mkdir($path); @@ -371,81 +451,95 @@ final static public function create_skeletton($name){ return true; } - final public static function logger($message): void{ + final public static function logger($message): void + { Exceptions::error_rep($message); } - final public static function check_plugins_enabled(){ - if(Arbeitszeit::get_app_ini()["plugins"]["plugins"] == "true" || Arbeitszeit::get_app_ini()["plugins"]["plugins"] == true){ + final public static function check_plugins_enabled() + { + if (Arbeitszeit::get_app_ini()["plugins"]["plugins"] == "true" || Arbeitszeit::get_app_ini()["plugins"]["plugins"] == true) { return true; } else { return false; } } - final public static function redirect_if_disabled(){ - if(!self::check_plugins_enabled()){ + final public static function redirect_if_disabled() + { + if (!self::check_plugins_enabled()) { StatusMessages::redirect("plugins_disabled"); exit(); } } - final public function get_plugin_nav($name) { + final public function get_plugin_nav($name) + { $this->logger("{$this->la} Getting nav links for plugin '{$name}'"); $conf = $this->read_plugin_configuration($name); - if (isset($conf["nav_links"]) && is_array($conf["nav_links"])) { + if (isset($conf["nav_links"]) && is_array($conf["nav_links"])) { return $conf["nav_links"]; } $this->logger("{$this->la} Plugin '{$name}' has no nav links"); - return []; + return []; } - final public function get_plugin_nav_html($plugin_name) { + final public function get_plugin_nav_html($plugin_name) + { $links = $this->get_plugin_nav($plugin_name); $html = ""; $conf = $this->read_plugin_configuration($plugin_name); - - if (isset($conf["enabled"]) && !$conf["enabled"]) { + + if (isset($conf["enabled"]) && !$conf["enabled"]) { $this->logger("{$this->la} Plugin '{$plugin_name}' is disabled"); return null; } - - if (is_array($links)) { + + if (is_array($links)) { foreach ($links as $n => $v) { - $html .= "
  • [{$plugin_name}] $n
  • "; + if ($this->checkPluginPermissions($plugin_name, $v, $_SESSION["username"])) { + $html .= "
  • [{$plugin_name}] {$n}
  • "; + } } } - $this->logger("{$this->la} Plugin '{$plugin_name}' has no nav links"); + return $html; } - - final public function load_plugin_view($plugin_name, $view) { + + + final public function load_plugin_view($plugin_name, $view) + { try { $this->logger("{$this->la} Loading view '{$view}' for plugin '{$plugin_name}'"); - + + if (!$this->checkPluginPermissions($plugin_name, $view, $_SESSION["username"])) { + throw new \Exception("User '" . $_SESSION["username"] . "' does not have permission to access view '{$view}' of plugin '{$plugin_name}'"); + } + $plugin_base_path = realpath($_SERVER["DOCUMENT_ROOT"] . $this->get_basepath() . "/" . basename($plugin_name)); $view_path = realpath($plugin_base_path . "/" . ltrim($view, "/")); - + $this->logger("Expected plugin base path: {$plugin_base_path}"); $this->logger("Computed view path: {$view_path}"); - + if (!$plugin_base_path || !$view_path || !file_exists($view_path) || strpos($view_path, $plugin_base_path) !== 0) { throw new \Exception("View '{$view}' for plugin '{$plugin_name}' not found or invalid."); } - + require $view_path; - + } catch (\Throwable $e) { Exceptions::error_rep("An error occurred while loading view '{$view}' for plugin '{$plugin_name}' - Message: {$e->getMessage()}"); return false; } - + $this->logger("{$this->la} Loaded view '{$view}' for plugin '{$plugin_name}'"); return true; } - public function getPluginClassPath($pluginName) { + public function getPluginClassPath($pluginName) + { $this->logger("{$this->la} Getting plugin class path for '{$pluginName}'..."); $config = $this->read_plugin_configuration($pluginName); $srcDir = $config['src'] ?? 'src'; @@ -456,14 +550,15 @@ public function getPluginClassPath($pluginName) { $this->logger("{$this->la} Main class not found in plugin configuration for '{$pluginName}'"); return ''; } - + /** * Lädt die Plugin-Klasse basierend auf der `plugin.yml`. * * @param string $pluginName Der Name des Plugins. * @return void */ - public function loadPluginClass($pluginName) { + public function loadPluginClass($pluginName) + { $this->logger("{$this->la} Loading plugin class for '{$pluginName}'..."); $classPath = $this->getPluginClassPath($pluginName); if (file_exists($classPath)) { @@ -474,7 +569,8 @@ public function loadPluginClass($pluginName) { } } - public function countPlugins(): int { + public function countPlugins(): int + { $this->logger("{$this->la} Counting plugins..."); $plugins = $this->get_plugins(); if (is_array($plugins) && isset($plugins['plugins'])) { @@ -488,16 +584,19 @@ public function countPlugins(): int { /* Plugin section */ - protected function onLoad(): void{ + protected function onLoad(): void + { } - protected function onDisable(): void{ + protected function onDisable(): void + { } - protected function onEnable(): void{ - + protected function onEnable(): void + { + } } } diff --git a/api/v1/class/plugins/docs/Plugins.md b/api/v1/class/plugins/docs/Plugins.md index f4aae69..512c1a7 100644 --- a/api/v1/class/plugins/docs/Plugins.md +++ b/api/v1/class/plugins/docs/Plugins.md @@ -44,6 +44,17 @@ These are optional values you can add, which might make things easier: - `build.instructions`: Allows you to add parameters to change the behaviour of the PluginBuilder. - `required`: An array containing relative paths to the required files, they will then get included within the archive (PHAR) - `nav_links`: If your plugin has a front-end, please specify this attribute. It is stored in key-value pairs, e.g. "Send Message": "views/send-message.php" - Views have to be always in the `/views` folder within your plugin folder +- `nav_permissions`: Key-value pairs to restrict certain navigation links to certian permissions. E.g.: + + ```yaml + + nav_links: + Admin View: views/admin.php + User View: views/user.php + nav_permissions: + Admin View: 1 + User View: 0 + ``` ### Permissions diff --git a/api/v1/class/plugins/plugins/codeclock/plugin.yml b/api/v1/class/plugins/plugins/codeclock/plugin.yml index 402ff57..9f61384 100644 --- a/api/v1/class/plugins/plugins/codeclock/plugin.yml +++ b/api/v1/class/plugins/plugins/codeclock/plugin.yml @@ -12,4 +12,7 @@ custom.values: license: "LIC" nav_links: View PIN: views/index.php - Admin View: views/admin.php \ No newline at end of file + Admin View: views/admin.php +nav_permissions: + View PIN: 0 + Admin View: 1 diff --git a/api/v1/class/plugins/plugins/exportmanager/plugin.yml b/api/v1/class/plugins/plugins/exportmanager/plugin.yml index c6f63bd..d0cbc74 100644 --- a/api/v1/class/plugins/plugins/exportmanager/plugin.yml +++ b/api/v1/class/plugins/plugins/exportmanager/plugin.yml @@ -11,4 +11,6 @@ enabled: false custom.values: license: "LIC" nav_links: - Open ExportManager: views/redirect.php \ No newline at end of file + Open ExportManager: views/redirect.php +nav_permissions: + Open ExportManager: 1 diff --git a/api/v1/class/plugins/plugins/nfcclock/plugin.yml b/api/v1/class/plugins/plugins/nfcclock/plugin.yml index 86e7572..3d9976c 100644 --- a/api/v1/class/plugins/plugins/nfcclock/plugin.yml +++ b/api/v1/class/plugins/plugins/nfcclock/plugin.yml @@ -9,7 +9,10 @@ api: 0.1 permissions: none enabled: true custom.values: - license: LIC + license: LIC nav_links: - 'Open NFCClock': views/open.php - 'Open About Page': views/index.php \ No newline at end of file + 'Open NFCClock': views/open.php + 'Open About Page': views/index.php +nav_permissions: + 'Open NFCClock': 0 + 'Open About Page': 0 diff --git a/api/v1/class/plugins/plugins/nfclogin/plugin.yml b/api/v1/class/plugins/plugins/nfclogin/plugin.yml index 2c271b1..42f3c79 100644 --- a/api/v1/class/plugins/plugins/nfclogin/plugin.yml +++ b/api/v1/class/plugins/plugins/nfclogin/plugin.yml @@ -9,6 +9,8 @@ api: 0.1 permissions: none enabled: true custom.values: - license: LIC + license: LIC nav_links: - 'Manage Cards': views/cards.php \ No newline at end of file + 'Manage Cards': views/cards.php +nav_permissions: + 'Manage Cards': 1 \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/pluginmanager/plugin.yml b/api/v1/class/plugins/plugins/pluginmanager/plugin.yml index 4547dcd..ade6182 100644 --- a/api/v1/class/plugins/plugins/pluginmanager/plugin.yml +++ b/api/v1/class/plugins/plugins/pluginmanager/plugin.yml @@ -11,4 +11,6 @@ enabled: true custom.values: license: "LIC" nav_links: - Open PluginManager: views/redirect.php \ No newline at end of file + Open PluginManager: views/redirect.php +nav_permissions: + Open PluginManager: 1 diff --git a/api/v1/class/plugins/plugins/qrclock/plugin.yml b/api/v1/class/plugins/plugins/qrclock/plugin.yml index 84f6f37..1a0a0cd 100644 --- a/api/v1/class/plugins/plugins/qrclock/plugin.yml +++ b/api/v1/class/plugins/plugins/qrclock/plugin.yml @@ -11,4 +11,6 @@ enabled: false custom.values: license: "LIC" nav_links: - Generate QR code: views/index.php \ No newline at end of file + Generate QR code: views/index.php +nav_permissions: + Generate QR code: 0 \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/userdetail/plugin.yml b/api/v1/class/plugins/plugins/userdetail/plugin.yml index 2a4f72a..68ed62b 100644 --- a/api/v1/class/plugins/plugins/userdetail/plugin.yml +++ b/api/v1/class/plugins/plugins/userdetail/plugin.yml @@ -11,4 +11,6 @@ enabled: true custom.values: license: "LIC" nav_links: - Open Userdetail: views/overview.php \ No newline at end of file + Open Userdetail: views/overview.php +nav_permissions: + Open Userdetail: 1 \ No newline at end of file diff --git a/api/v1/class/plugins/plugins/utility/plugin.yml b/api/v1/class/plugins/plugins/utility/plugin.yml index 26d0e15..23d7670 100644 --- a/api/v1/class/plugins/plugins/utility/plugin.yml +++ b/api/v1/class/plugins/plugins/utility/plugin.yml @@ -12,3 +12,5 @@ custom.values: license: LIC nav_links: 'Open Utility Plugin': views/overview.php +nav_permissions: + 'Open Utility Plugin': 1 diff --git a/assets/gui/standard_nav.php b/assets/gui/standard_nav.php index 9e5dd82..4b17fe0 100644 --- a/assets/gui/standard_nav.php +++ b/assets/gui/standard_nav.php @@ -21,6 +21,9 @@ + + + is_Admin($user->get_user(@$_SESSION["username"]))) : $v = file_get_contents($_SERVER["DOCUMENT_ROOT"] . "/VERSION"); ?> @@ -29,9 +32,6 @@ - - - ADMIN | diff --git a/composer.json b/composer.json index 40aee36..10e32b3 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.4.1", + "version": "8.4.2", "authors": [ { "name": "Bryan Boehnke-Avan", From 58e2f5290567232c60499be6c309a0f7d080feb4 Mon Sep 17 00:00:00 2001 From: Ente Date: Tue, 2 Dec 2025 00:06:16 +0100 Subject: [PATCH 10/24] v8.5 --- CHANGELOG.md | 12 + VERSION | 2 +- api/v1/class/arbeitszeit.inc.php | 30 ++ api/v1/class/benutzer/benutzer.arbeit.inc.php | 79 ++-- .../admin/projects/admin/snippets_DE.json | 1 + .../i18n/suite/projects/item/snippets_EN.json | 7 +- .../class/i18n/suite/status/snippets_DE.json | 3 +- .../class/i18n/suite/status/snippets_EN.json | 3 +- .../class/i18n/suite/status/snippets_NL.json | 3 +- .../PluginBuilder.plugins.arbeit.inc.php | 8 +- .../plugins/plugins/userdetail/plugin.yml | 4 +- api/v1/class/projects/projects.arbeit.inc.php | 97 ++++- assets/css/aurora.css | 197 ++++++++++ assets/css/holofuseui.css | 356 ++++++++++++++++++ composer.json | 2 +- suite/actions/projects/delete_item.php | 20 + suite/actions/projects/edit_item.php | 44 +++ suite/admin/actions/projects/userEdit.php | 6 +- suite/admin/projects/addUser.php | 3 +- suite/projects/edit_item.php | 55 +++ suite/projects/item.php | 8 +- suite/projects/mapWorktimeToItem.php | 9 +- suite/projects/overview.php | 4 +- 23 files changed, 898 insertions(+), 55 deletions(-) create mode 100644 assets/css/aurora.css create mode 100644 assets/css/holofuseui.css create mode 100644 suite/actions/projects/delete_item.php create mode 100644 suite/actions/projects/edit_item.php create mode 100644 suite/projects/edit_item.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d956e13..42c00df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## v8.5 +* Fixed an issue with IDs not generated correctly for project items. +* Added functionality to delete and edit project items. +* Adding users to a project has been made easier. +* Internal changes +* Added additional plugin permission level + +## v8.4.3 + +* Now displaying the instance uuid within the settings page. +* Added ability to reset the instance uuid via the settings page. + ## v8.4.2 * Added user based permissions for plugin views. diff --git a/VERSION b/VERSION index 7857a94..188c409 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.4.2 \ No newline at end of file +8.5 diff --git a/api/v1/class/arbeitszeit.inc.php b/api/v1/class/arbeitszeit.inc.php index 1eed762..ee17df3 100644 --- a/api/v1/class/arbeitszeit.inc.php +++ b/api/v1/class/arbeitszeit.inc.php @@ -223,6 +223,36 @@ public function end_easymode_pause_worktime($username, $id) } } + public function renderUserWorktimeSelect( + string $name, + int $userId, + ?int $selectedWorktime = null, + string $placeholder = "—", + string $class = "" + ): void + { + $userId = $this->benutzer()->get_user_from_id($userId)["username"]; + $sql = "SELECT * FROM arbeitszeiten WHERE username = ?"; + $stmt = $this->db->sendQuery($sql); + $stmt->execute([$userId]); + $times = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + echo ''; + } + + public function toggle_easymode($username) { if (!$this->nodes()->checkNode("arbeitszeit.inc", "toggle_easymode")) { diff --git a/api/v1/class/benutzer/benutzer.arbeit.inc.php b/api/v1/class/benutzer/benutzer.arbeit.inc.php index 36b62eb..7b567a0 100644 --- a/api/v1/class/benutzer/benutzer.arbeit.inc.php +++ b/api/v1/class/benutzer/benutzer.arbeit.inc.php @@ -25,7 +25,7 @@ public function __construct() */ public function create_user($username, $name, $email, $password, $isAdmin = 0) { - if($this->nodes()->checkNode("benutzer.inc", "create_user") == false){ + if ($this->nodes()->checkNode("benutzer.inc", "create_user") == false) { return false; } Exceptions::error_rep("Creating user '$username'..."); @@ -47,20 +47,44 @@ public function create_user($username, $name, $email, $password, $isAdmin = 0) } } - public function user_active($username){ + public function renderUserSelect(string $name, ?int $selectedUser = null, string $noAssigneeText = "—"): void + { + $users = $this->get_all_users(); + + echo ''; + } + + + public function user_active($username) + { $user = $this->get_user($username); - if($user["active"] == true || $user["active"] == 1){ + if ($user["active"] == true || $user["active"] == 1) { return true; } else { return false; } } - public function activate_user($username){ + public function activate_user($username) + { return $this->editUserProperties($username, "active", 1); } - public function deactivate_user($username){ + public function deactivate_user($username) + { return $this->editUserProperties($username, "active", 0); } @@ -74,10 +98,10 @@ public function deactivate_user($username){ */ public function delete_user($id) { - if($this->nodes()->checkNode("benutzer.inc", "delete_user") == false){ + if ($this->nodes()->checkNode("benutzer.inc", "delete_user") == false) { return false; } - $user = $this->get_user_from_id($id); + $user = $this->get_user_from_id($id); $username = $user["username"]; $email = $user["email"]; Exceptions::error_rep("Deleting user with id '$id'..."); @@ -217,7 +241,7 @@ public function get_all_users() */ public function get_all_users_html() { - if($this->nodes()->checkNode("benutzer.inc", "get_all_users_html") == false){ + if ($this->nodes()->checkNode("benutzer.inc", "get_all_users_html") == false) { return false; } Exceptions::error_rep("Getting all users..."); @@ -264,7 +288,7 @@ public function get_all_users_html() */ public function get_user_html($username) { - if($this->nodes()->checkNode("benutzer.inc", "get_user_html") == false){ + if ($this->nodes()->checkNode("benutzer.inc", "get_user_html") == false) { return false; } Exceptions::error_rep("Getting user '$username'..."); @@ -314,25 +338,28 @@ public static function is_admin($user) } } - public static function current_user_is_admin(){ - if(self::get_current_user()["isAdmin"] == true){ + public static function current_user_is_admin() + { + if (self::get_current_user()["isAdmin"] == true) { return true; } else { return false; } } - public static function get_current_user(){ + public static function get_current_user() + { return self::get_user($_SESSION["username"]); } - public static function get_name_from_id($id){ + public static function get_name_from_id($id) + { return self::get_user_from_id($id)["name"]; } public function editUserProperties(mixed $username_or_id, string $name, mixed $value): bool { - if($this->nodes()->checkNode("benutzer.inc", "editUserProperties") == false){ + if ($this->nodes()->checkNode("benutzer.inc", "editUserProperties") == false) { return false; } if ( @@ -378,28 +405,30 @@ public function editUserProperties(mixed $username_or_id, string $name, mixed $v } } - public function loadUserTheme(){ + public function loadUserTheme() + { $themes = scandir($_SERVER["DOCUMENT_ROOT"] . "/assets/css"); $themes = array_diff($themes, [".", ".."]); $check = in_array($_COOKIE["theme"], $themes); - if($this->get_app_ini()["general"]["force_theme"] == "true"){ + if ($this->get_app_ini()["general"]["force_theme"] == "true") { return $this->get_app_ini()["general"]["theme_file"]; } - if(!isset($_COOKIE["theme"]) || !$check){ + if (!isset($_COOKIE["theme"]) || !$check) { return "/assets/css/v8.css"; } else { return "/assets/css/" . $_COOKIE["theme"]; } } - public function computeUserThemes(){ + public function computeUserThemes() + { $themes = scandir($_SERVER["DOCUMENT_ROOT"] . "/assets/css"); $themes = array_diff($themes, [".", ".."]); $currentTheme = basename($this->loadUserTheme()); - foreach($themes as $theme){ - if($currentTheme == $theme){ + foreach ($themes as $theme) { + if ($currentTheme == $theme) { echo ""; } else { echo ""; @@ -409,13 +438,15 @@ public function computeUserThemes(){ return true; } - public function setUserTheme($theme){ - setcookie("theme", $theme, time()+60*60*24*30, "/"); + public function setUserTheme($theme) + { + setcookie("theme", $theme, time() + 60 * 60 * 24 * 30, "/"); return true; } - public function checkThemeForce(){ - if($this->get_app_ini()["general"]["force_theme"] == "true" || $this->get_app_ini()["general"]["force_theme"] == true){ + public function checkThemeForce() + { + if ($this->get_app_ini()["general"]["force_theme"] == "true" || $this->get_app_ini()["general"]["force_theme"] == true) { return true; } else { return false; diff --git a/api/v1/class/i18n/admin/projects/admin/snippets_DE.json b/api/v1/class/i18n/admin/projects/admin/snippets_DE.json index 36fb66f..b9f7dc1 100644 --- a/api/v1/class/i18n/admin/projects/admin/snippets_DE.json +++ b/api/v1/class/i18n/admin/projects/admin/snippets_DE.json @@ -14,6 +14,7 @@ "label_description": "Beschreibung", "label_deadline": "Deadline", "label_owner": "Besitzer", + "label_assoc": "Kurzname", "btn_add": "Projekt hinzufügen", "delete_confirm": "Bist du sicher, dass du das Projekt löschen möchtest?" } \ No newline at end of file diff --git a/api/v1/class/i18n/suite/projects/item/snippets_EN.json b/api/v1/class/i18n/suite/projects/item/snippets_EN.json index 89fabea..b63fe88 100644 --- a/api/v1/class/i18n/suite/projects/item/snippets_EN.json +++ b/api/v1/class/i18n/suite/projects/item/snippets_EN.json @@ -11,5 +11,10 @@ "no_worktimes": "No worktimes...", "btn_edit": "Edit", "btn_delete": "Delete", - "id": "ID" + "id": "ID", + "edit_title": "Edit Item", + "title_label": "Title", + "description_label": "Description", + "no_assignee": "No assignee", + "btn_save": "Save Changes" } \ No newline at end of file diff --git a/api/v1/class/i18n/suite/status/snippets_DE.json b/api/v1/class/i18n/suite/status/snippets_DE.json index f32ebe4..27407d4 100644 --- a/api/v1/class/i18n/suite/status/snippets_DE.json +++ b/api/v1/class/i18n/suite/status/snippets_DE.json @@ -51,5 +51,6 @@ "userinactive": "Fehler: Dein Konto wurde deaktiviert. Bitte wende dich an deinen Administrator!", "plugins_disabled": "Fehler: Das Plugin-System ist deaktiviert. Bitte wende dich an deinen Administrator!", "telemetry_sent": "Hinweis: Telemetriedaten wurden erfolgreich gesendet!", - "telemetry_disabled": "Hinweis: Telemetriedaten wurden nicht gesendet!" + "telemetry_disabled": "Hinweis: Telemetriedaten wurden nicht gesendet!", + "success": "Hinweis: Die Aktion wurde erfolgreich ausgeführt!" } diff --git a/api/v1/class/i18n/suite/status/snippets_EN.json b/api/v1/class/i18n/suite/status/snippets_EN.json index 6120fdb..56dcb95 100644 --- a/api/v1/class/i18n/suite/status/snippets_EN.json +++ b/api/v1/class/i18n/suite/status/snippets_EN.json @@ -51,5 +51,6 @@ "userinactive": "Error: Your account has been disabled. Please contact your administrator!", "plugins_disabled": "Error: The plugin system is disabled. Please contact your administrator!", "telemetry_sent": "Note: Telemetry data sent successfully!", - "telemetry_disabled": "Note: Telemetry data not sent!" + "telemetry_disabled": "Note: Telemetry data not sent!", + "success": "Note: The action was completed successfully!" } diff --git a/api/v1/class/i18n/suite/status/snippets_NL.json b/api/v1/class/i18n/suite/status/snippets_NL.json index d8b9f19..2852ca7 100644 --- a/api/v1/class/i18n/suite/status/snippets_NL.json +++ b/api/v1/class/i18n/suite/status/snippets_NL.json @@ -51,5 +51,6 @@ "userinactive": "Fout: Je account is uitgeschakeld. Neem contact op met je beheerder!", "plugins_disabled": "Fout: Het plugin-systeem is uitgeschakeld. Neem contact op met je beheerder!", "telemetry_sent": "Opmerking: Telemetriegegevens succesvol verzonden!", - "telemetry_disabled": "Opmerking: Telemetriegegevens niet verzonden!" + "telemetry_disabled": "Opmerking: Telemetriegegevens niet verzonden!", + "success": "Opmerking: De actie is succesvol uitgevoerd!" } diff --git a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php index 3913762..772a853 100644 --- a/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php +++ b/api/v1/class/plugins/PluginBuilder.plugins.arbeit.inc.php @@ -253,8 +253,12 @@ final public function checkPluginPermissions($pluginName, $view, $user): bool } if (isset($permissions['nav_permissions'][$viewName])) { - $requiredPermission = $permissions['nav_permissions'][$viewName]; # either 0 or 1 + $requiredPermission = $permissions['nav_permissions'][$viewName]; $this->logger("{$la} Required permission for view '{$viewName}': '{$requiredPermission}'"); + if ($requiredPermission === 5 && $userPermissions === $adminLevel) { + $this->logger("{$la} View '{$viewName}' is marked as internal placeholder. Skipping."); + return true; + } if ($requiredPermission === $adminLevel && $userPermissions === $adminLevel) { $this->logger("{$la} User '{$user}' has admin permissions for view '{$viewName}'. Access granted."); @@ -268,6 +272,8 @@ final public function checkPluginPermissions($pluginName, $view, $user): bool } + + } else { $this->logger("{$la} No specific permissions set for view '{$view}', allowing access by default."); return false; # no default access diff --git a/api/v1/class/plugins/plugins/userdetail/plugin.yml b/api/v1/class/plugins/plugins/userdetail/plugin.yml index 68ed62b..9d4015a 100644 --- a/api/v1/class/plugins/plugins/userdetail/plugin.yml +++ b/api/v1/class/plugins/plugins/userdetail/plugin.yml @@ -12,5 +12,7 @@ custom.values: license: "LIC" nav_links: Open Userdetail: views/overview.php + Userview: views/user.php nav_permissions: - Open Userdetail: 1 \ No newline at end of file + Open Userdetail: 1 + Userview: 5 \ No newline at end of file diff --git a/api/v1/class/projects/projects.arbeit.inc.php b/api/v1/class/projects/projects.arbeit.inc.php index edb248d..6a131a7 100644 --- a/api/v1/class/projects/projects.arbeit.inc.php +++ b/api/v1/class/projects/projects.arbeit.inc.php @@ -163,8 +163,9 @@ public function getCurrentUserProjects() public function addProjectItem($project_id, $title, $description, $assignee = null) { - $sql = "INSERT INTO `projects_items` (pid, title, description, assignee) VALUES (?, ?, ?, ?)"; - $res = $this->db->sendQuery($sql)->execute([$project_id, $title, $description, $assignee]); + $rand = rand(10000000, 99999999); + $sql = "INSERT INTO `projects_items` (pid, title, description, assignee, id) VALUES (?, ?, ?, ?, ?)"; + $res = $this->db->sendQuery($sql)->execute([$project_id, $title, $description, $assignee, $rand]); if (!$res) { return false; @@ -175,7 +176,7 @@ public function addProjectItem($project_id, $title, $description, $assignee = nu public function mapWorktimeToItem($worktime_id, $item_id, $user_id = null) { - if ($user_id = null) { + if ($user_id == null) { if (!$this->benutzer()->get_current_user()) { return false; } @@ -233,6 +234,7 @@ public function checkUserisOwner($project_id) } } + public function checkUserHasProjectAccess($user_id, $project_id, $required = 0) { if (isset($project_id)) { @@ -336,19 +338,18 @@ public function getUserProjectItems($project_id, $user) public function getItem($id): array|bool { - $sql = "SELECT * FROM projects_items WHERE pid = ?"; + $sql = "SELECT * FROM projects_items WHERE id = ?"; $stmt = $this->db->sendQuery($sql); - $res = $stmt->execute([$id]); - if (!$res) { + if (!$stmt->execute([$id])) { return false; } $row = $stmt->fetch(\PDO::FETCH_ASSOC); - return $row ?: false; } + public function getUserProjectWorktimes($item_id): array|bool { Exceptions::error_rep("[PROJECTS] Fetching worktimes for item '{$item_id}'..."); @@ -395,6 +396,88 @@ public function getProjectWorktimes($project_id): array|bool return $rows; } + public function deleteItem($item_id) + { + Exceptions::error_rep("[PROJECTS] Deleting item '{$item_id}'..."); + + if (!$this->checkUserisOwner($this->getItem($item_id)["pid"]) && !$this->benutzer()->current_user_is_admin()) { + Exceptions::error_rep("[PROJECTS] User does not have permission to delete item '{$item_id}'."); + return false; + } + + $sql = "DELETE FROM projects_items WHERE id = ?"; + $res = $this->db->sendQuery($sql)->execute([$item_id]); + if (!$res) { + Exceptions::error_rep("[PROJECTS] An error occurred while deleting item '{$item_id}'. See previous message for more information."); + return false; + } else { + Exceptions::error_rep("[PROJECTS] Successfully deleted item '{$item_id}'."); + return true; + } + } + + public function editItem($itemId, $changes) + { + Exceptions::error_rep("[PROJECTS] Editing item '{$itemId}'..."); + + $allowed = ["title", "description", "assignee", "status"]; + + $setParts = []; + $values = []; + + foreach ($changes as $key => $value) { + if (!in_array($key, $allowed)) + continue; + + $setParts[] = "`$key` = ?"; + $values[] = $value; + } + + if (empty($setParts)) { + Exceptions::error_rep("[PROJECTS] No valid changes for item '{$itemId}'."); + return false; + } + + $values[] = $itemId; + + $sql = "UPDATE `projects_items` SET " . implode(", ", $setParts) . " WHERE id = ?"; + $stmt = $this->db->sendQuery($sql); + $res = $stmt->execute($values); + + if (!$res) { + Exceptions::error_rep("[PROJECTS] Error updating item '{$itemId}'."); + return false; + } + + Exceptions::error_rep("[PROJECTS] Successfully updated item '{$itemId}'."); + return true; + } + + public function renderUserItemSelect( + string $name, + int $userId, + ?int $selectedItem = null, + string $placeholder = "—", + string $class = "" + ): void { + $sql = "SELECT * FROM projects_items WHERE assignee = ?"; + $stmt = $this->db->sendQuery($sql); + $stmt->execute([$userId]); + $items = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + echo ''; + } + } diff --git a/assets/css/aurora.css b/assets/css/aurora.css new file mode 100644 index 0000000..a89a5c5 --- /dev/null +++ b/assets/css/aurora.css @@ -0,0 +1,197 @@ +:root { + --primary: #6bc7ff; + --secondary: #9cffdb; + + --bg: #0f141b; + --bg-soft: #151c25; + + --text: #e7eef5; + --card: #12181f; + + --radius: 14px; + --font-main: "Inter", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +html, body { + margin: 0; + padding: 0; + background: linear-gradient(120deg, #0f141b, #121821, #0f141b); + background-size: 300% 300%; + animation: auroraFlow 18s ease-in-out infinite; + color: var(--text); + font-family: var(--font-main); + min-height: 100vh; +} + +@keyframes auroraFlow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +a { + color: var(--secondary); + text-decoration: none; + transition: 200ms ease; +} +a:hover { + color: var(--primary); + text-shadow: 0 0 10px var(--primary); +} + +h1, h2, h3 { + font-weight: 600; + text-transform: uppercase; + background: linear-gradient(90deg, var(--secondary), var(--primary)); + background-clip: text; + color: transparent; + animation: fadeSlideUp 0.7s cubic-bezier(0.24,1,0.32,1); +} + +input, button, textarea, select { + border-radius: var(--radius); + border: 1px solid rgba(255,255,255,0.08); + padding: .85rem 1rem; + margin-top: .5rem; + font-family: var(--font-main); + background: var(--bg-soft); + color: var(--text); + transition: 180ms ease; +} + +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(107,199,255,0.25); + transform: translateY(-1px) scale(1.01); +} + +button { + background: linear-gradient(135deg, var(--primary), var(--secondary)); + border: none; + color: #000; + font-weight: 600; + cursor: pointer; + letter-spacing: 0.5px; + transition: 200ms ease; + position: relative; + overflow: hidden; +} + +button::after { + content: ""; + position: absolute; + top: -100%; + left: 0; + width: 100%; + height: 250%; + background: linear-gradient(to bottom, rgba(255,255,255,0.25), rgba(255,255,255,0)); + transform: rotate(20deg); + transition: .45s ease; +} +button:hover::after { top: -20%; } + +button:hover { + transform: translateY(-2px) scale(1.03); + box-shadow: 0 6px 20px rgba(107,199,255,0.25); +} + +.card { + background: var(--card); + padding: 2rem; + border-radius: var(--radius); + border: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 0 18px rgba(0,0,0,0.5); + transition: 200ms ease; + animation: fadeSlideUp 0.6s ease-out; +} +.card:hover { + transform: translateY(-3px); + box-shadow: 0 0 22px rgba(107,199,255,0.2); +} + +.status-message { + border-left: 4px solid var(--primary); + padding: 1rem 1.5rem; + margin-bottom: 1.5rem; + border-radius: var(--radius); + background: rgba(255,255,255,0.06); + font-family: var(--font-mono); + animation: fadeSlideUp 0.4s ease-out; +} +.status-message.error { border-color: #ff6b88; color: #ff9db0; } +.status-message.warn { border-color: #ffd978; color: #ffefbd; } +.status-message.info { border-color: var(--secondary); } + +.v8-table { + width: 100%; + border-collapse: collapse; + font-size: .95rem; + background: #151c25; + border: 1px solid rgba(255,255,255,0.08); +} +.v8-table th { + padding: .75rem 1rem; + background: rgba(255,255,255,0.06); + color: var(--primary); +} +.v8-table td { + padding: .75rem 1rem; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.v8-table tr:hover td { + background: rgba(107,199,255,0.05); +} + +.log-output { + font-family: var(--font-mono); + background: #0c1117; + padding: 1.2rem; + border-radius: var(--radius); + border: 1px solid rgba(255,255,255,0.08); + color: var(--secondary); + max-height: 300px; + overflow-y: auto; +} +.log-output .error { color: #ff6b88; } +.log-output .warn { color: #ffd978; } +.log-output .info { color: var(--secondary); } + +footer { + padding: 2rem; + text-align: center; + font-size: .85rem; + background: rgba(20,25,30,0.35); + border-top: 1px solid rgba(255,255,255,0.12); + animation: glowPulse 4s ease-in-out infinite; +} + +.topnav { + display: flex; + justify-content: space-between; + background: rgba(20,30,40,.5); + padding: .75rem 1.5rem; + border-bottom: 2px solid var(--primary); + backdrop-filter: blur(6px); + animation: borderGlow 8s linear infinite; +} + +.topnav a { + margin-right: 1rem; + color: #eee; + transition: 180ms ease; +} +.topnav a:hover { + color: var(--primary); + text-shadow: 0 0 6px var(--primary); +} + +@keyframes glowPulse { + 50% { box-shadow: 0 0 12px rgba(107,199,255,0.25); } +} +@keyframes borderGlow { + 0% { border-bottom-color: var(--primary); } + 50% { border-bottom-color: var(--secondary); } + 100% { border-bottom-color: var(--primary); } +} diff --git a/assets/css/holofuseui.css b/assets/css/holofuseui.css new file mode 100644 index 0000000..b4507dc --- /dev/null +++ b/assets/css/holofuseui.css @@ -0,0 +1,356 @@ +:root { + --hf-bg: #050509; + --hf-bg-soft: #0b0b14; + --hf-surface: rgba(255,255,255,0.04); + --hf-surface-hover: rgba(255,255,255,0.08); + + --hf-primary: #6f5bff; + --hf-accent: #00d2ff; + --hf-danger: #ff4e6a; + --hf-success: #3cffb7; + + --hf-radius: 12px; + --hf-font: "Inter", sans-serif; + --hf-mono: "JetBrains Mono", monospace; + + --hf-transition: 180ms cubic-bezier(0.23, 1, 0.32, 1); +} + +html, body { + margin: 0; + padding: 0; + background: var(--hf-bg); + color: #e7e7e7; + font-family: var(--hf-font); + overflow-x: hidden; + animation: bgFloat 10s ease-in-out infinite alternate; + min-height: 100vh; +} + +@keyframes bgFloat { + 0% { background: radial-gradient(circle at 20% 10%, #090919, #050509 80%); } + 100% { background: radial-gradient(circle at 80% 90%, #0d0d1f, #050509 80%); } +} + +h1, h2, h3 { + font-weight: 600; + letter-spacing: 0.5px; + background: linear-gradient(90deg, var(--hf-accent), var(--hf-primary)); + background-clip: text; + color: transparent; + animation: fadeSlideUp 0.6s ease-out both; +} + +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +a { + color: var(--hf-accent); + text-decoration: none; + transition: var(--hf-transition); +} +a:hover { + color: var(--hf-primary); + text-shadow: 0 0 10px var(--hf-primary); +} + +.topnav { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + background: rgba(20,20,40,0.5); + padding: 0.75rem 1.5rem; + border-bottom: 2px solid var(--hf-primary); + backdrop-filter: blur(8px); + animation: navGlow 6s linear infinite; +} + +@keyframes navGlow { + 0% { border-bottom-color: var(--hf-primary); } + 50% { border-bottom-color: var(--hf-accent); } + 100% { border-bottom-color: var(--hf-primary); } +} + +.topnav a { + color: #e7e7e7; + margin-right: 1rem; + padding: 0.3rem 0.5rem; + transition: var(--hf-transition); +} +.topnav a:hover { + color: var(--hf-accent); +} + +.user-label { + font-family: var(--hf-mono); + color: var(--hf-accent); +} + +.nav-version { + font-family: var(--hf-mono); + font-size: 0.8rem; + color: var(--hf-primary); +} + +input, textarea, select { + width: 100%; + padding: 0.85rem 1rem; + font-family: var(--hf-font); + background: var(--hf-bg-soft); + border: 1px solid rgba(255,255,255,0.06); + border-radius: var(--hf-radius); + color: #e7e7e7; + transition: var(--hf-transition); + backdrop-filter: blur(6px); +} + +input:hover, textarea:hover, select:hover { + border-color: var(--hf-accent); + background: rgba(255,255,255,0.06); +} + +input:focus, textarea:focus, select:focus { + border-color: var(--hf-primary); + box-shadow: 0 0 0 4px rgba(111,91,255,0.25); + transform: translateY(-1px) scale(1.01); + outline: none; +} + +input::placeholder, textarea::placeholder { + color: rgba(255,255,255,0.35); + transition: var(--hf-transition); +} +input:focus::placeholder, +textarea:focus::placeholder { + opacity: 0.45; + letter-spacing: 0.3px; +} + +button { + padding: 0.85rem 1.4rem; + border-radius: var(--hf-radius); + border: none; + font-weight: 600; + color: white; + cursor: pointer; + background: linear-gradient(135deg, var(--hf-primary), var(--hf-accent)); + transition: var(--hf-transition); + position: relative; + overflow: hidden; +} + +button:hover { + transform: translateY(-2px) scale(1.03); + box-shadow: 0 0 15px rgba(0,210,255,0.4); +} + +button::after { + content: ""; + position: absolute; + top: -100%; + left: 0; + width: 100%; + height: 300%; + background: linear-gradient( + rgba(255,255,255,0.18), + rgba(255,255,255,0.02) + ); + transform: rotate(25deg); + transition: 0.4s ease; +} +button:hover::after { + top: -30%; +} + +.card { + background: var(--hf-surface); + padding: 2rem; + border-radius: var(--hf-radius); + border: 1px solid rgba(255,255,255,0.06); + backdrop-filter: blur(12px); + transition: var(--hf-transition); +} +.card:hover { + transform: translateY(-4px); + box-shadow: 0 0 20px rgba(111,91,255,0.25), + 0 0 35px rgba(0,210,255,0.15); +} +.card h2 { + color: var(--hf-accent); +} + +.status-message { + padding: 1rem 1.4rem; + border-left: 4px solid var(--hf-primary); + background: rgba(255,255,255,0.05); + border-radius: var(--hf-radius); + margin-bottom: 1rem; + font-family: var(--hf-mono); + animation: fadeSlideUp 0.4s ease; +} +.status-message.error { border-color: var(--hf-danger); color: #ff8d9d; } +.status-message.warn { border-color: #ffd95c; color: #ffeaa6; } +.status-message.info { border-color: var(--hf-accent); } + +.v8-table { + width: 100%; + border-collapse: collapse; + background: var(--hf-bg-soft); + border-radius: var(--hf-radius); + overflow: hidden; + font-family: var(--hf-mono); + font-size: 0.95rem; +} + +.v8-table thead { + background: rgba(255,255,255,0.06); + color: var(--hf-accent); +} + +.v8-table th, +.v8-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.v8-table tr:hover td { + background: rgba(255,255,255,0.06); + transition: var(--hf-transition); +} + +.log-output { + font-family: var(--hf-mono); + font-size: 0.85rem; + background: #0c0c10; + padding: 1rem; + border-radius: var(--hf-radius); + border: 1px solid rgba(255,255,255,0.06); + color: var(--hf-accent); + max-height: 300px; + overflow-y: auto; +} + +.log-output .error { color: var(--hf-danger); } +.log-output .warn { color: #ffd95c; } +.log-output .info { color: var(--hf-accent); } + +footer { + padding: 2rem; + text-align: center; + font-size: 0.85rem; + color: #aaa; + background: rgba(20,20,40,0.3); + backdrop-filter: blur(8px); + border-top: 1px solid rgba(255,255,255,0.1); + animation: pulseFooter 4s infinite; +} + +@keyframes pulseFooter { + 0%,100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +body { + position: relative; + overflow-x: hidden; +} + +.hf-bg { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + overflow: hidden; +} + +.hf-bg .nebula { + position: absolute; + inset: 0; + background: radial-gradient(circle at 30% 20%, #1b1b3a, transparent 60%), + radial-gradient(circle at 70% 80%, #181832, transparent 65%), + radial-gradient(circle at 60% 30%, rgba(111,91,255,0.25), transparent 55%), + radial-gradient(circle at 20% 80%, rgba(0,210,255,0.25), transparent 60%); + filter: blur(40px); + animation: nebulaShift 35s ease-in-out infinite alternate; +} + +@keyframes nebulaShift { + 0% { + transform: scale(1) translate(0,0); + filter: blur(40px); + } + 50% { + transform: scale(1.15) translate(20px,-20px); + filter: blur(60px); + } + 100% { + transform: scale(1.05) translate(-15px,25px); + } +} + +.hf-bg .particles { + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgba(255,255,255,0.12) 2px, transparent 2px); + background-size: 3px 3px; + opacity: 0.45; + animation: particleDrift 120s linear infinite; + filter: blur(1.4px); + mix-blend-mode: screen; +} + +@keyframes particleDrift { + 0% { transform: translate(0,0) scale(1); } + 50% { transform: translate(-80px, 40px) scale(1.05); } + 100% { transform: translate(40px, -60px) scale(1); } +} + +.hf-bg .grid { + position: absolute; + inset: 0; + background-image: + linear-gradient( + rgba(255,255,255,0.035) 1px, + transparent 1px + ), + linear-gradient( + 90deg, + rgba(255,255,255,0.035) 1px, + transparent 1px + ); + background-size: 80px 80px; + opacity: 0.25; + animation: gridPulse 16s ease-in-out infinite alternate; +} + +@keyframes gridPulse { + 0% { + transform: translateY(0) scale(1); + opacity: 0.22; + } + 100% { + transform: translateY(-10px) scale(1.03); + opacity: 0.30; + } +} + +.hf-bg .scanline { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + to bottom, + rgba(255,255,255,0.02) 0px, + rgba(255,255,255,0.02) 1px, + transparent 3px + ); + opacity: 0.05; + animation: scanMove 12s linear infinite; +} + +@keyframes scanMove { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } +} diff --git a/composer.json b/composer.json index 10e32b3..f518736 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.4.2", + "version": "8.5", "authors": [ { "name": "Bryan Boehnke-Avan", diff --git a/suite/actions/projects/delete_item.php b/suite/actions/projects/delete_item.php new file mode 100644 index 0000000..b3ce84f --- /dev/null +++ b/suite/actions/projects/delete_item.php @@ -0,0 +1,20 @@ +get_app_ini()["general"]["base_url"]; +$arbeit->auth()->login_validation(); + +if(!isset($_POST["item_id"])){ + header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("error")); +} + +if($arbeit->benutzer()->current_user_is_admin()){ + if($arbeit->projects()->deleteItem($_GET["id"])){ + $arbeit->statusMessages()->redirect("success"); + } else { + $arbeit->statusMessages()->redirect("error"); + } +} diff --git a/suite/actions/projects/edit_item.php b/suite/actions/projects/edit_item.php new file mode 100644 index 0000000..f1c4d52 --- /dev/null +++ b/suite/actions/projects/edit_item.php @@ -0,0 +1,44 @@ +get_app_ini()["general"]["base_url"]; +$arbeit->auth()->login_validation(); + +// Check required POST param +if(!isset($_POST["id"])) { + header("Location: http://{$base_url}/suite/?" . + $arbeit->statusMessages()->URIBuilder("error")); + exit; +} + +$item_id = $_POST["id"]; + +if (!$arbeit->benutzer()->current_user_is_admin() && + !$arbeit->projects()->checkUserisOwner($arbeit->projects()->getItem($item_id)["pid"])) +{ + header("Location: http://{$base_url}/suite/?" . + $arbeit->statusMessages()->URIBuilder("forbidden")); + exit; +} + +$changes = [ + "title" => $_POST["title"] ?? "", + "description" => $_POST["description"] ?? "", + "assignee" => $_POST["assignee"] ?? null, +]; + +$res = $arbeit->projects()->editItem($item_id, $changes); + +if ($res) { + header("Location: http://{$base_url}/suite/projects/item.php?id={$item_id}&" . + $arbeit->statusMessages()->URIBuilder("success")); + exit; +} else { + header("Location: http://{$base_url}/suite/projects/item.php?id={$item_id}&" . + $arbeit->statusMessages()->URIBuilder("error")); + exit; +} diff --git a/suite/admin/actions/projects/userEdit.php b/suite/admin/actions/projects/userEdit.php index cb684f0..f5112bf 100644 --- a/suite/admin/actions/projects/userEdit.php +++ b/suite/admin/actions/projects/userEdit.php @@ -6,12 +6,12 @@ $base_url = $arbeit->get_app_ini()["general"]["base_url"]; $arbeit->auth()->login_validation(); -if(!isset($_POST["projectId"], $_POST["userId"])){ +if(!isset($_POST["project"], $_POST["userid"])){ header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("error")); } -if($arbeit->projects()->checkUserisOwner($_POST["projectId"])){ - if($arbeit->projects()->addProjectMember($_POST["projectId"], $_POST["userId"], $_POST["permissions"], $_POST["role"])){ +if($arbeit->projects()->checkUserisOwner($_POST["project"])){ + if($arbeit->projects()->addProjectMember($_POST["project"], $_POST["userid"], $_POST["permissions"], $_POST["role"])){ header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("project_userAdded")); } else { header("Location: http://{$base_url}/suite/?" . $arbeit->statusMessages()->URIBuilder("project_userAdded_failed")); diff --git a/suite/admin/projects/addUser.php b/suite/admin/projects/addUser.php index bbcf30f..3e4586e 100644 --- a/suite/admin/projects/addUser.php +++ b/suite/admin/projects/addUser.php @@ -31,8 +31,7 @@
    - " hidden> - + benutzer()->renderUserSelect("userid"); ?>


    diff --git a/suite/projects/edit_item.php b/suite/projects/edit_item.php new file mode 100644 index 0000000..a14cb7f --- /dev/null +++ b/suite/projects/edit_item.php @@ -0,0 +1,55 @@ +get_app_ini(); +$language = $arbeit->i18n()->loadLanguage(null, "projects/item"); +$arbeit->auth()->login_validation(); + +$itemId = $_GET["id"] ?? null; +$item = $arbeit->projects()->getItem($itemId); + +if (!$item) { + die("Item not found."); +} + +$project = $arbeit->projects()->getProject($item["pid"]); + +?> + + + + + <?= $language["edit_title"]; ?> | <?= $ini["general"]["app_name"]; ?> + + + + + +
    + +

    #

    +

    + +
    + + + + " required> + + + + + + benutzer()->renderUserSelect("assignee", $item["assignee"], $language["no_assignee"]); ?> + +
    + +
    + + + + diff --git a/suite/projects/item.php b/suite/projects/item.php index 14b1a7d..3e0fac8 100644 --- a/suite/projects/item.php +++ b/suite/projects/item.php @@ -20,7 +20,7 @@ $itemId = $_GET["id"] ?? null; $item = $arbeit->projects()->getItem($itemId); -$project = $arbeit->projects()->getProject($item["id"]); +$project = $arbeit->projects()->getProject($item["pid"]); $worktimes = $arbeit->projects()->getUserProjectWorktimes($project["id"]); ?> @@ -41,7 +41,7 @@

    : i18n()->sanitizeOutput($project["name"] ?? "-"); ?>

    : benutzer()->get_user_from_id($item["assignee"])["name"] ?? "-"; ?>

    : i18n()->sanitizeOutput($item["status"] ?? "Open"); ?>

    -

    : i18n()->sanitizeOutput($item["itemid"] ?? ""); ?>

    +

    : i18n()->sanitizeOutput($item["id"] ?? ""); ?>

    @@ -69,8 +69,8 @@
    - - + | +
    diff --git a/suite/projects/mapWorktimeToItem.php b/suite/projects/mapWorktimeToItem.php index 03d90c9..c3c232d 100644 --- a/suite/projects/mapWorktimeToItem.php +++ b/suite/projects/mapWorktimeToItem.php @@ -24,16 +24,15 @@

    Map Worktime to Item

    - +
    - -

    + renderUserWorktimeSelect("worktime_id", $arbeit->benutzer()->get_current_user()["id"]); ?>
    - + projects()->renderUserItemSelect("item_id", $arbeit->benutzer()->get_current_user()["id"]); ?>


    - + benutzer()->renderUserSelect("user_id"); ?>


    Cancel diff --git a/suite/projects/overview.php b/suite/projects/overview.php index 3613024..3573f4b 100644 --- a/suite/projects/overview.php +++ b/suite/projects/overview.php @@ -74,11 +74,11 @@ - projects()->getProject($item["id"])["name"]; ?> + projects()->getProject($item["pid"])["name"]; ?> - " class="v8-button"> + " class="v8-button"> From a7bd088aab438e344efe68f3e942a0a155b3e1b0 Mon Sep 17 00:00:00 2001 From: Ente Date: Tue, 2 Dec 2025 00:06:36 +0100 Subject: [PATCH 11/24] mentioning themes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c00df..4ebf627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Adding users to a project has been made easier. * Internal changes * Added additional plugin permission level +* Added 2 new themes ## v8.4.3 From 291e72b9a0514afd41679bcae997b5583bedede3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bryan=20B=C3=B6hnke-Avan?= Date: Tue, 2 Dec 2025 00:12:36 +0100 Subject: [PATCH 12/24] Update README with telemetry settings Added telemetry configuration options to README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6c8e267..e9172e7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ In step 2, you need to configure the `app.json.sample` within the `api/v1/inc` f - `force_theme`: Force a theme for all users, this disables the feature allowing users to set their own theme. - `theme_file`: If `force_theme` is true, the specified theme is used (default: `/assets/css/v8.css`) - `demo`: If set to `true`, demo credentials are shown on the login page. Useful for demo installations. +- `telemetry`: Enable/disable telemetry (Default: `enabled` - **PLEASE DISABLE IF NEEDED**) +- `telemetry_server_url`: Full server url to telemetry upload #### **SMTP section** From cd7201e5ac4d8287419ec0649d14f7a5c3c1534e Mon Sep 17 00:00:00 2001 From: Ente Date: Thu, 4 Dec 2025 23:15:47 +0100 Subject: [PATCH 13/24] TT-207: v8.5.1 --- CHANGELOG.md | 7 ++++ README.md | 36 +++++++++++++++++-- VERSION | 2 +- api/v1/class/benutzer/benutzer.arbeit.inc.php | 7 ++-- api/v1/inc/app.json.sample | 4 +-- composer.json | 2 +- suite/users/settings.php | 3 +- 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebf627..9f288f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # CHANGELOG +## v8.5.1 + +* Fixed undefined variable warning message +* Changed app.json.sample default values +* Updated README.md + ## v8.5 + * Fixed an issue with IDs not generated correctly for project items. * Added functionality to delete and edit project items. * Adding users to a project has been made easier. diff --git a/README.md b/README.md index e9172e7..76ef680 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,41 @@ Simply install the software by following these steps: - Create a new database, e.g. with the name `ab` and create a dedicated user, login (`mysql -u root -p`) then e.g. `timetool`: `CREATE DATABASE ab;` and `CREATE USER 'timetool'@'localhost' IDENTIFIED BY 'yourpassword';` and `GRANT ALL PRIVILEGES ON ab.* TO 'timetool'@'localhost';` don't forget to `FLUSH PRIVILEGES;`! - Configure `app.json` (see below - required changes: `base_url`, `db_user`, `db_password`, `smtp` section and any other if your installation is different) then `mv api/v1/inc/app.json.sample app.json && cd /var/www/timetrack` - Run DB migrations: `vendor/bin/phinx migrate` -- Start webserver e.g. `service apache2 stop && php -S 0.0.0.0:80` or using apache2 (then you have to configure the `sites-available` conf yourself) -- You can then access TimeTrack in your browser at `http://localhost`, default login is `admin` with password `admin`. Create yourself a new admin account, login and delete the default account afterwards. +- Follow "Use with ..." guides + +#### Use with apache2.4 + +- Create a new virtual host: `sudo nano /etc/apache2/sites-available/timetrack.conf` +- Content: + +```conf + + ServerName timetrack.yourdomain.de + DocumentRoot /var/www/timetrack + + + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + +``` + +- Enable site and module: `sudo a2ensite timetrack && a2enmod rewrite` + +#### Use with PHP development server + +- Start server: `cd /var/www/timetrack && php -S 0.0.0.0:80` + +#### Finalize + +You can now access TimeTrack in your browser at `http://localhost`, default login is `admin` with password `admin`. Create yourself a new admin account, login and delete the default account afterwards. To save log files, please create the subfolder `data/logs` and make it writeable to the web server (e.g. `chown www-data:www-data data/logs && chmod 775 data/logs`). -Please also make sure that the `/data` directory is writable by the webserver, aswell as the plugins directory (default: `api/v1/class/plugins/plugins`). +Please also make sure that the `/data` directory is writable by the webserver, aswell as the plugins directory (default: `api/v1/class/plugins/plugins`). The `/api/v1/toil/permissions.json` also needs to be writeable by the webserver. ### Configure app.json diff --git a/VERSION b/VERSION index 188c409..f9c71a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -8.5 +8.5.1 diff --git a/api/v1/class/benutzer/benutzer.arbeit.inc.php b/api/v1/class/benutzer/benutzer.arbeit.inc.php index 7b567a0..d7dd5df 100644 --- a/api/v1/class/benutzer/benutzer.arbeit.inc.php +++ b/api/v1/class/benutzer/benutzer.arbeit.inc.php @@ -410,12 +410,15 @@ public function loadUserTheme() $themes = scandir($_SERVER["DOCUMENT_ROOT"] . "/assets/css"); $themes = array_diff($themes, [".", ".."]); + if(!isset($_COOKIE["theme"])){ + return "/assets/css/v8.css"; + } $check = in_array($_COOKIE["theme"], $themes); if ($this->get_app_ini()["general"]["force_theme"] == "true") { return $this->get_app_ini()["general"]["theme_file"]; } - if (!isset($_COOKIE["theme"]) || !$check) { + if (!$check) { return "/assets/css/v8.css"; } else { return "/assets/css/" . $_COOKIE["theme"]; @@ -446,7 +449,7 @@ public function setUserTheme($theme) public function checkThemeForce() { - if ($this->get_app_ini()["general"]["force_theme"] == "true" || $this->get_app_ini()["general"]["force_theme"] == true) { + if ($this->get_app_ini()["general"]["force_theme"] == true) { return true; } else { return false; diff --git a/api/v1/inc/app.json.sample b/api/v1/inc/app.json.sample index 785dbba..e4b05bf 100644 --- a/api/v1/inc/app.json.sample +++ b/api/v1/inc/app.json.sample @@ -7,9 +7,9 @@ "auto_update": "false", "timezone": "UTC", "theme_file": "/assets/css/v8.css", - "force_theme": "false", + "force_theme": false, "demo": false, - "telemetry": "enabled", + "telemetry": "disabled", "telemetry_server_url": "https://telemetry.openducks.org/timetrack/submit" }, "mysql": { diff --git a/composer.json b/composer.json index f518736..4e0b92d 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.5", + "version": "8.5.1", "authors": [ { "name": "Bryan Boehnke-Avan", diff --git a/suite/users/settings.php b/suite/users/settings.php index 94d8e0a..a8c9b3c 100644 --- a/suite/users/settings.php +++ b/suite/users/settings.php @@ -64,10 +64,11 @@ '; + echo ''; } @@ -404,7 +403,7 @@ public function add_worktime($start, $end, $location, $date, $username, $type, $ public function update_worktime($id, $array) { - if(!$this->check_if_for_review($id)){ + if (!$this->check_if_for_review($id)) { return false; } $allowed = [ @@ -839,6 +838,92 @@ public function blockIfNotAdmin() return false; } + public function checkForUpdate() + { + $currentVersion = $this->getTimeTrackVersion(); + $latestVersion = file_get_contents("https://raw.githubusercontent.com/Ente/timetrack/refs/heads/develop/VERSION"); + + $currentVersion = trim($currentVersion); + $latestVersion = trim($latestVersion); + + if (version_compare($currentVersion, $latestVersion, '<')) { + return $latestVersion; + } else { + return false; + } + } + + public function getChanges(string $version_tag = "latest") + { + if ($version_tag !== "latest") { + $url = "https://api.github.com/repos/ente/timetrack/releases/tags/v{$version_tag}"; + } else { + $url = "https://api.github.com/repos/ente/timetrack/releases/latest"; + } + + $context = stream_context_create([ + "http" => [ + "method" => "GET", + "header" => [ + "User-Agent: TimeTrack-Updater", + "Accept: application/vnd.github+json" + ], + "timeout" => 10 + ] + ]); + + $json = @file_get_contents($url, false, $context); + + if ($json === false) { + return null; + #throw new \RuntimeException("GitHub API request failed"); + } + + return json_decode($json, true); + } + + + public function renderGUIUpdateCheck() + { + + $text = ""; + $current = $this->getTimeTrackVersion(); + $latest = $this->checkForUpdate(); + + + if ($this->checkForUpdate() != false) { + $latestChanges = $this->getChanges($latest) ?? "NULL"; + $fullChangelogUrl = "https://github.com/ente/timetrack/compare/v{$current}...v{$latest}"; + $latestVersionLink = "https://github.com/ente/timetrack/releases/tag/v{$latest}"; + $text .= "
    "; + $text .= "

    Update available!


    "; + $text .= "You are currently using TimeTrack version {$current}, the latest version is {$latest}.
    "; + $text .= "Please check the changelog for more information about the changes.
    "; + $text .= "It is recommended to update as soon as possible to benefit from the latest features and security improvements."; + ## changelog + parsedown + $text .= "
    "; + $text .= "Changelog for version {$latest}:
    "; + $parsedown = new \Parsedown(); + $text .= $parsedown->text($latestChanges["body"]); + $text .= "
    "; + return $text; + } else { + $text .= "
    "; + $text .= "You are using the latest version of TimeTrack ({$current}). No update is required."; + ## current changelog + $text .= "
    "; + $text .= "Changelog for version {$current}:
    "; + $parsedown = new \Parsedown(); + $currentChanges = $this->getChanges($current); + if($currentChanges == null) { + $currentChanges["body"] = "**No changelogs found. Either you are using a custom build or something went wrong while fetching the changelogs.**"; + } + $text .= $parsedown->text($currentChanges["body"]); + $text .= "
    "; + return $text; + } + } + public function global_dispatcher(): \Symfony\Component\EventDispatcher\EventDispatcher { return \Arbeitszeit\Events\EventDispatcherService::get(); diff --git a/composer.json b/composer.json index 9f2a6b6..41bccd5 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "TimeTrack is a PHP-written time recording tool for small businesses", "type": "software", "license": "GNU GPL", - "version": "8.6", + "version": "8.7", "authors": [ { "name": "Bryan Boehnke-Avan", diff --git a/suite/admin/users/settings.php b/suite/admin/users/settings.php index a9b039b..a44fa47 100644 --- a/suite/admin/users/settings.php +++ b/suite/admin/users/settings.php @@ -24,7 +24,8 @@ DAT; -?> +?>

    +renderGUIUpdateCheck(); ?>

    Telemetry

    Here you can send anonymous telemetry data to help improve the application. From 49fa208860a6e39b602c4176e23854f7d0d19178 Mon Sep 17 00:00:00 2001 From: Ente Date: Sat, 3 Jan 2026 12:54:23 +0100 Subject: [PATCH 22/24] TT-221 --- CHANGELOG.md | 2 ++ api/v1/class/arbeitszeit.inc.php | 43 +++++++++++++++++++++++++-- api/v1/class/mode/mode.arbeit.inc.php | 9 +++++- api/v1/inc/app.json.sample | 3 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3158740..6c26bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Added automatic update check within the Settings page allowing to see the changelogs and a link to the new Release. * Updated `README.md` +* Admins can now select the default worktime type to be selected in the form within the app.json `config` section via the `default_worktime_type` key. +* Added function to automatically add keys to app.json after update ## v8.6 diff --git a/api/v1/class/arbeitszeit.inc.php b/api/v1/class/arbeitszeit.inc.php index 201cd2e..db611a2 100644 --- a/api/v1/class/arbeitszeit.inc.php +++ b/api/v1/class/arbeitszeit.inc.php @@ -60,6 +60,7 @@ public function __construct() if (isset($this->get_app_ini()["general"]["timezone"])) { try { date_default_timezone_set($this->get_app_ini()["general"]["timezone"]); + $this->app_ini_check(); } catch (\Exception $e) { Exceptions::error_rep("Error setting timezone: " . $e->getMessage()); } @@ -73,6 +74,42 @@ public function __destruct() } } + public function app_ini_check() + { + $base = dirname(__DIR__, 3) . "/api/v1/inc/"; + $sample = json_decode(file_get_contents($base . "app.json.sample"), true); + $current = json_decode(file_get_contents($base . "app.json"), true); + + $updated = false; + + foreach ($sample as $section => $values) { + + if (!isset($current[$section]) || !is_array($current[$section])) { + $current[$section] = []; + $updated = true; + Exceptions::error_rep("App config section '{$section}' was missing and has been created."); + } + + foreach ($values as $key => $value) { + if (!array_key_exists($key, $current[$section])) { + $current[$section][$key] = $value; + $updated = true; + Exceptions::error_rep( + "App config key '{$key}' in section '{$section}' was missing and added with default value." + ); + } + } + } + + if ($updated) { + file_put_contents( + $base . "app.json", + json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ); + } + } + + public function init_lang() { Exceptions::error_rep("Initializing language for Arbeitszeit class"); @@ -128,8 +165,8 @@ public static function add_easymode_worktime($username) return false; } else { Exceptions::error_rep("Creating easymode worktime entry for user '{$username}'..."); - $sql = "INSERT INTO `arbeitszeiten` (`name`, `id`, `email`, `username`, `schicht_tag`, `schicht_anfang`, `schicht_ende`, `ort`, `active`, `review`) VALUES ( ?, '0', ?, ?, ?, ?, '00:00', '-', '1', '0');"; - $data = $conn->sendQuery($sql)->execute([$usr["name"], $usr["email"], $username, $date, $time]); + $sql = "INSERT INTO `arbeitszeiten` (`name`, `id`, `email`, `username`, `schicht_tag`, `schicht_anfang`, `schicht_ende`, `ort`, `active`, `review`, `wtype`) VALUES ( ?, '0', ?, ?, ?, ?, '00:00', '-', '1', '0', ?);"; + $data = $conn->sendQuery($sql)->execute([$usr["name"], $usr["email"], $username, $date, $time, Arbeitszeit::get_app_ini()["config"]["default_worktime_type"]]); if ($data == false) { Exceptions::error_rep("An error occurred while creating easymode worktime entry. See previous message for more information"); return false; @@ -915,7 +952,7 @@ public function renderGUIUpdateCheck() $text .= "Changelog for version {$current}:
    "; $parsedown = new \Parsedown(); $currentChanges = $this->getChanges($current); - if($currentChanges == null) { + if ($currentChanges == null) { $currentChanges["body"] = "**No changelogs found. Either you are using a custom build or something went wrong while fetching the changelogs.**"; } $text .= $parsedown->text($currentChanges["body"]); diff --git a/api/v1/class/mode/mode.arbeit.inc.php b/api/v1/class/mode/mode.arbeit.inc.php index ee6e749..1bd29e8 100644 --- a/api/v1/class/mode/mode.arbeit.inc.php +++ b/api/v1/class/mode/mode.arbeit.inc.php @@ -21,8 +21,15 @@ public static function check($username) public static function compute_html_worktime_types() { $data = ""; + $selected = ""; foreach (Arbeitszeit::get_all_types() as $type => $value) { - $data .= ""; + $selected = ""; + + if((int)$type == Arbeitszeit::get_app_ini()["config"]["default_worktime_type"]) { + $selected = " selected"; + } + print_r($type); + $data .= ""; } return $data; } diff --git a/api/v1/inc/app.json.sample b/api/v1/inc/app.json.sample index 7504155..8dcc416 100644 --- a/api/v1/inc/app.json.sample +++ b/api/v1/inc/app.json.sample @@ -55,7 +55,8 @@ "config": { "worktime_types": "/api/v1/inc/config/worktime_types.json", "vacation_types": "/api/v1/inc/config/vacation_types.json", - "sickness_types": "/api/v1/inc/config/sickness_types.json" + "sickness_types": "/api/v1/inc/config/sickness_types.json", + "default_worktime_type": 1 }, "mobile": { "allow_app_use": false, From 47e801d3bcb7c140c40a41a68c340939cf9e9b09 Mon Sep 17 00:00:00 2001 From: Ente Date: Sat, 3 Jan 2026 13:21:31 +0100 Subject: [PATCH 23/24] TT-217 --- CHANGELOG.md | 1 + README.md | 11 +++++++++++ .../exports/modules/PDFExportModule/php/.gitkeep | 0 3 files changed, 12 insertions(+) create mode 100644 api/v1/class/exports/modules/PDFExportModule/php/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 3158740..fd50551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added automatic update check within the Settings page allowing to see the changelogs and a link to the new Release. * Updated `README.md` +* Admins can now customize the look and feel of the PDF exports. Please check `README.md` `Exports` section for more information. ## v8.6 diff --git a/README.md b/README.md index 6852fea..3d81b39 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ A demo is available here: [https://tt-demo.openducks.org](https://tt-demo.opendu **The demo is available with limited features only, e.g. the plugin system disabled.** **The demo currently does not work as intended...** +> You would like to support the project? Consider helping out with the documentation at [https://timetrackd.openducks.org](https://timetrackd.openducks.org) or by contributing to the code. + ## Installation ### Quick Install with Docker @@ -222,6 +224,15 @@ $arbeit->exportModule()->getExportModule("MyExportExportModule")->export($data); All existing export modules can be accessed with the `ExportManager` Plugin. You can specify your own CSS file within the `app.json` `exports -> pdf -> css` setting (full path) - the default is `api/v1/class/exports/modules/PDFExportModule/css/index.css` +### Custom contents + +You can use custom contents within your PDF exports by placing HTML/PHP files into `api/v1/class/exports/modules/PDFExportModule/php/`. +Two files can be placed there: + +- `user_content_ending.php`: This file is included at the end of the PDF export, e.g. for signatures or custom footers +- `user_content_starting.php`: This file is included at the beginning of the PDF export, e.g. for custom headers. +You can put normal PHP and HTML code into these files. + ## QR codes You can use the plugin `QRClock` to generate QR codes for yourself to either clock in or out. The QR code generated can be saved for later use, e.g. print it out. diff --git a/api/v1/class/exports/modules/PDFExportModule/php/.gitkeep b/api/v1/class/exports/modules/PDFExportModule/php/.gitkeep new file mode 100644 index 0000000..e69de29 From a4159122656b4478e97d8cc7f3d55e1c17e69958 Mon Sep 17 00:00:00 2001 From: Ente Date: Mon, 5 Jan 2026 22:00:16 +0100 Subject: [PATCH 24/24] Fix missing feature for user created content for PDF exports --- api/v1/class/arbeitszeit.inc.php | 4 +- .../PDFExportModule.em.arbeit.inc.php | 200 +++++++++++------- api/v1/class/telemetry/server/README.md | 2 +- 3 files changed, 121 insertions(+), 85 deletions(-) diff --git a/api/v1/class/arbeitszeit.inc.php b/api/v1/class/arbeitszeit.inc.php index db611a2..73477bb 100644 --- a/api/v1/class/arbeitszeit.inc.php +++ b/api/v1/class/arbeitszeit.inc.php @@ -924,8 +924,8 @@ public function renderGUIUpdateCheck() { $text = ""; - $current = $this->getTimeTrackVersion(); - $latest = $this->checkForUpdate(); + $current = trim($this->getTimeTrackVersion()); + $latest = trim($this->checkForUpdate()); if ($this->checkForUpdate() != false) { diff --git a/api/v1/class/exports/modules/PDFExportModule/PDFExportModule.em.arbeit.inc.php b/api/v1/class/exports/modules/PDFExportModule/PDFExportModule.em.arbeit.inc.php index 2fe1675..3de0d85 100644 --- a/api/v1/class/exports/modules/PDFExportModule/PDFExportModule.em.arbeit.inc.php +++ b/api/v1/class/exports/modules/PDFExportModule/PDFExportModule.em.arbeit.inc.php @@ -8,36 +8,54 @@ /** * PDFExportModule - Allows you to export worktime sheets */ -class PDFExportModule implements ExportModuleInterface { - public function export($args) { - $i18n = new i18n; - $arbeit = new Arbeitszeit; - $user = $args["user"]; - $month = $args["month"]; - $year = $args["year"]; - $i18nn = $i18n->loadLanguage(null, "class/pdf"); - $ini = Arbeitszeit::get_app_ini(); - $hours = $arbeit->calculate_hours_specific_time($user, $month, $year); - if(is_string($year) != true){ - $year = date("Y"); - } - $sql = "SELECT * FROM `arbeitszeiten` WHERE YEAR(schicht_tag) = ? AND MONTH(schicht_tag) = ? AND username = ? ORDER BY schicht_tag DESC;"; - $statement = $arbeit->db()->sendQuery($sql); - $userdata = $statement->execute([$year, $month, $user]); - if($userdata == false){ - Exceptions::error_rep("An error occurred while generating worktime pdf. See previous message for more information"); - die("An error occurred!"); - } - $user_data = Benutzer::get_user($user); - $user_data["name"] ?? $statement->fetch(\PDO::FETCH_ASSOC)[0]["name"]; # Bug 14 Fix -> http://bugzilla.openducks.org/show_bug.cgi?id=14 - try { - $css = @file_get_contents($ini["exports"]["pdf"]["css"]); - } catch (\ValueError $e) { - $css = file_get_contents(__DIR__ . "/css/index.css"); - } - $data = <<< DATA +class PDFExportModule implements ExportModuleInterface +{ + + function loadUserContent(string $file): string + { + if (!is_file($file)) { + return ''; + } + + ob_start(); + include $file; + return ob_get_clean(); + } + + + public function export($args) + { + $i18n = new i18n; + $arbeit = new Arbeitszeit; + $user = $args["user"]; + $month = $args["month"]; + $year = $args["year"]; + $i18nn = $i18n->loadLanguage(null, "class/pdf"); + $ini = Arbeitszeit::get_app_ini(); + $hours = $arbeit->calculate_hours_specific_time($user, $month, $year); + if (is_string($year) != true) { + $year = date("Y"); + } + $userGeneratedBeginning = $this->loadUserContent(__DIR__ . '/php/user_content_beginning.php') ?? ""; + $userGeneratedEnding = $this->loadUserContent(__DIR__ . '/php/user_content_ending.php') ?? ""; + $sql = "SELECT * FROM `arbeitszeiten` WHERE YEAR(schicht_tag) = ? AND MONTH(schicht_tag) = ? AND username = ? ORDER BY schicht_tag DESC;"; + $statement = $arbeit->db()->sendQuery($sql); + $userdata = $statement->execute([$year, $month, $user]); + if ($userdata == false) { + Exceptions::error_rep("An error occurred while generating worktime pdf. See previous message for more information"); + die("An error occurred!"); + } + $user_data = Benutzer::get_user($user); + $user_data["name"] ?? $statement->fetch(\PDO::FETCH_ASSOC)[0]["name"]; # Bug 14 Fix -> http://bugzilla.openducks.org/show_bug.cgi?id=14 + try { + $css = @file_get_contents($ini["exports"]["pdf"]["css"]); + } catch (\ValueError $e) { + $css = file_get_contents(__DIR__ . "/css/index.css"); + } + $data = << + {$userGeneratedBeginning}

    {$i18nn["worktime_note"]} {$user_data["name"]}