+
+
+
+
+
\ 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 .= "