*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
/**
* \file netdiag/api/netdiag_api.lib.php
* \ingroup netdiag
* \brief Gemeinsame Funktionen der JSON-API: Bootstrap, JWT, Antworten.
*
* Wird von jedem API-Endpunkt eingebunden. Lädt die Dolibarr-Umgebung
* ohne Web-Session und authentifiziert die mobile App per JWT.
*/
// Konstanten setzen BEVOR Dolibarr geladen wird (kein Menü, kein HTML, kein Login)
if (!defined('NOLOGIN')) {
define('NOLOGIN', '1');
}
if (!defined('NOCSRFCHECK')) {
define('NOCSRFCHECK', '1');
}
if (!defined('NOTOKENRENEWAL')) {
define('NOTOKENRENEWAL', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', '1');
}
if (!defined('NOREQUIREAJAX')) {
define('NOREQUIREAJAX', '1');
}
if (!defined('NOREQUIRESOC')) {
define('NOREQUIRESOC', '1');
}
// ===================================================================
// Dolibarr-Umgebung laden — MUSS im globalen Scope passieren.
// master.inc.php innerhalb einer Funktion zu includen würde $conf,
// $db, $langs, $user ... in den Funktions-Scope legen; nach return
// wären sie weg und jeder DB-Zugriff liefe gegen null -> HTTP 500.
// Dieser Block läuft, sobald ein Endpunkt die Lib per require_once
// einbindet, also im File-Scope des Endpunkts = global.
// ===================================================================
$res = 0;
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/master.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/master.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/master.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/master.inc.php";
}
if (!$res && file_exists("../../../master.inc.php")) {
$res = @include "../../../master.inc.php";
}
if (!$res && file_exists("../../../../master.inc.php")) {
$res = @include "../../../../master.inc.php";
}
if (!$res) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
echo json_encode(array('error' => 'Dolibarr environment not found'));
exit;
}
unset($res, $tmp, $tmp2, $i, $j);
/**
* Herkünfte, die die API im Browser-Sinne aufrufen dürfen.
*
* Die App läuft im Capacitor-WebView auf einem eigenen Origin — je nach
* `androidScheme` in capacitor.config.ts ist das `https://localhost` (Release)
* oder `http://localhost` (lokaler Debug-Build gegen ein Test-Dolibarr).
* Dazu der Vite-Dev-Server für die Entwicklung im Browser.
*
* @return string[] Erlaubte Origins
*/
function netdiag_api_allowed_origins()
{
return array(
'https://localhost',
'http://localhost',
'capacitor://localhost',
'ionic://localhost',
'http://localhost:5173',
);
}
/**
* CORS-Header setzen und Preflight (OPTIONS) sofort beantworten.
*
* Dolibarr selbst ist zu diesem Zeitpunkt bereits geladen (siehe Block
* oben, der beim require_once dieser Lib im globalen Scope läuft).
*
* Kein Wildcard-Origin mehr: die API liefert Kundendaten aus und wird per
* Bearer-Token authentifiziert. Ein `*` erlaubt jeder beliebigen Webseite,
* die Antwort auszulesen, sobald sie an ein Token kommt. Fehlt der
* Origin-Header ganz (native HTTP-Clients, curl, der APK-Downloader im
* Kotlin-Plugin), wird kein CORS-Header gesetzt — dort greift die Same-Origin-
* Policy des Browsers ohnehin nicht.
*
* @return void
*/
function netdiag_api_bootstrap()
{
$origin = isset($_SERVER['HTTP_ORIGIN']) ? (string) $_SERVER['HTTP_ORIGIN'] : '';
// Antwort hängt vom Origin ab -> Caches/Proxys müssen das wissen
header('Vary: Origin');
if ($origin !== '' && in_array($origin, netdiag_api_allowed_origins(), true)) {
header('Access-Control-Allow-Origin: '.$origin);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 86400');
}
// Preflight sofort beantworten
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
}
/**
* Zentrales Auth-Modul einbinden, sofern vorhanden und aktiv.
*
* NetDiag läuft auch ohne awlauth weiter (Fallback auf das modul-eigene JWT) —
* so, wie es die awlauth-Doku für Fremdmodule vorschreibt. Damit lässt sich das
* Modul auch in einer Instanz betreiben, in der awlauth nicht installiert ist.
*
* @return bool True wenn awlauth benutzbar ist
*/
function netdiag_awlauth_available()
{
static $ok = null;
if ($ok !== null) {
return $ok;
}
$ok = false;
if (function_exists('dol_include_once')) {
dol_include_once('/awlauth/lib/awlauth.lib.php');
}
if (function_exists('awlauth_is_enabled') && function_exists('awlauth_verify_token')) {
$ok = awlauth_is_enabled();
}
return $ok;
}
/**
* JSON-Antwort senden und Skript beenden.
*
* @param mixed $data Antwortdaten
* @param int $httpstatus HTTP-Statuscode
* @return void
*/
function netdiag_api_respond($data, $httpstatus = 200)
{
header('Content-Type: application/json; charset=utf-8');
http_response_code($httpstatus);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Fehler-Antwort senden und Skript beenden.
*
* @param string $message Fehlermeldung
* @param int $httpstatus HTTP-Statuscode
* @return void
*/
function netdiag_api_error($message, $httpstatus = 400)
{
netdiag_api_respond(array('error' => $message), $httpstatus);
}
/**
* Token aus Request lesen.
*
* Regelfall ist ausschliesslich der `Authorization: Bearer`-Header. Ein Token in
* der Adresszeile landet in Zugriffs- und Proxy-Logs und im Verlauf jedes
* Zwischensystems — bei einem Langzeit-Token (Standard 7 Tage) ist das ein
* Nachschluessel zur gesamten Kunden-API.
*
* Genau eine Ausnahme, bewusst und befristet: der APK-Download in `update.php`.
* Die im Feld installierten Fassungen rufen ihn mit `?jwt=` auf. Wer den Zweig
* jetzt schon entfernt, sperrt diese Geraete vom Update aus — und die einzige
* Quelle der neuen APK ist genau dieser Endpunkt. Deshalb bleibt er, bis Eddy
* den Rollout bestaetigt; er gilt dann nur noch fuer eine einzige, lesende
* Datei-Auslieferung statt fuer die ganze API.
*
* @param bool $allowQuery Query-Parameter `?jwt=` zusaetzlich zulassen
* @return string Token oder leerer String
*/
function netdiag_api_read_token($allowQuery = false)
{
$auth = '';
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
$auth = $_SERVER['HTTP_AUTHORIZATION'];
} elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
$auth = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
} elseif (function_exists('apache_request_headers')) {
$headers = apache_request_headers();
if (!empty($headers['Authorization'])) {
$auth = $headers['Authorization'];
}
}
if (stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
if ($allowQuery && !empty($_GET['jwt'])) {
return (string) $_GET['jwt'];
}
return '';
}
/**
* Aktuellen Request authentifizieren. Bricht mit 401 ab, wenn ungültig.
*
* Reihenfolge: zuerst das zentrale awlauth-Token (Signatur + serverseitig
* widerrufbare Sitzung), danach als Übergangslösung das alte modul-eigene JWT.
* Der Fallback hält bereits ausgestellte App-Tokens bis zu ihrem Ablauf gültig,
* damit die Umstellung niemanden mitten im Einsatz aussperrt. Er entfällt,
* sobald alle Geräte einmal neu angemeldet sind (siehe ROADMAP_UMSETZUNG L5).
*
* @param DoliDB $db Datenbank-Handler
* @param bool $allowQuery Token auch aus `?jwt=` annehmen (nur APK-Download)
* @return User Geladenes Benutzer-Objekt
*/
function netdiag_api_authenticate($db, $allowQuery = false)
{
$token = netdiag_api_read_token($allowQuery);
if (empty($token)) {
netdiag_api_error('Kein Token übermittelt', 401);
}
/*
* Ausschließlich über das zentrale Auth-Modul. Der Übergangspfad mit dem
* eigenen netdiag-JWT ist am 17.08.2026 entfallen, nachdem Eddy den
* APK-Rollout als abgeschlossen bestätigt hat.
*
* Warum das ein Gewinn ist und nicht nur Aufräumen: Der alte Pfad prüfte
* die Signatur eines selbst ausgestellten Tokens und holte damit einen
* Benutzer aus der Datenbank — an der Sitzungsliste von awlauth vorbei.
* „Gerät abmelden" dort hatte auf ein solches Token keine Wirkung; ein
* verlorenes Handy blieb bis zum Ablauf der TTL angemeldet. Jetzt gibt es
* genau eine Stelle, an der Sitzungen enden.
*
* Ohne aktives awlauth verweigert die API bewusst den Dienst, statt auf
* einen zweiten Weg auszuweichen.
*/
if (!netdiag_awlauth_available()) {
netdiag_api_error('Anmeldung nicht möglich: das Modul AWL-Auth ist nicht aktiv', 503);
}
$user = awlauth_verify_token($token);
if ($user === null || empty($user->id)) {
netdiag_api_error('Token ungültig oder abgelaufen', 401);
}
if (isset($user->statut) && $user->statut == 0) {
netdiag_api_error('Benutzer deaktiviert', 403);
}
if (!$user->hasRight('netdiag', 'protocol', 'read')) {
netdiag_api_error('Keine Berechtigung für NetDiag', 403);
}
return $user;
}
/**
* JSON-Body eines POST-Requests einlesen.
*
* @return array Dekodierte Daten (leer bei Fehler)
*/
function netdiag_api_read_body()
{
$raw = file_get_contents('php://input');
if (empty($raw)) {
return array();
}
$data = json_decode($raw, true);
return is_array($data) ? $data : array();
}
/**
* Zeitstempel der App in einen Unix-Zeitstempel (Sekunden) umrechnen.
*
* Die App (JavaScript) liefert Zeitstempel in Millisekunden (Date.now()).
* Dolibarr/`idate()` erwartet Sekunden — sonst: "Bad value ... for date".
*
* @param mixed $value Zeitstempel aus dem Request (ms, s oder leer)
* @return int Unix-Zeitstempel in Sekunden
*/
function netdiag_api_timestamp($value)
{
$v = (int) $value;
if ($v <= 0) {
return dol_now();
}
// 13-stellig (> ~Jahr 5138 in Sekunden) = Millisekunden -> auf Sekunden
if ($v > 100000000000) {
$v = (int) ($v / 1000);
}
return $v;
}
/**
* Liste von Diagnose-Protokollen als Array zurückgeben (für API-Antworten).
*
* @param DoliDB $db Datenbank-Handler
* @param string $filtersql Zusätzlicher SQL-Filter, beginnend mit ' AND ...'
* @return array> Liste der Protokolle
*/
function netdiag_api_protocol_list($db, $filtersql = '')
{
$prefix = $db->prefix();
$sql = "SELECT p.rowid, p.ref, p.label, p.client_uuid, p.fk_soc, p.fk_commande,";
$sql .= " p.date_diag, p.standort, p.subnet, p.status,";
$sql .= " (SELECT COUNT(*) FROM ".$prefix."netdiag_device d WHERE d.fk_protocol = p.rowid) as devcount,";
$sql .= " (SELECT COUNT(*) FROM ".$prefix."netdiag_measurement m WHERE m.fk_protocol = p.rowid) as meascount";
$sql .= " FROM ".$prefix."netdiag_protocol as p";
$sql .= " WHERE p.entity IN (".getEntity('netdiagprotocol').")";
$sql .= $filtersql;
$sql .= " ORDER BY p.date_diag DESC, p.rowid DESC";
$list = array();
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$list[] = array(
'id' => (int) $obj->rowid,
'ref' => $obj->ref,
'label' => $obj->label,
'clientUuid' => $obj->client_uuid,
'socId' => $obj->fk_soc ? (int) $obj->fk_soc : null,
'orderId' => $obj->fk_commande ? (int) $obj->fk_commande : null,
'dateDiag' => $db->jdate($obj->date_diag),
'location' => $obj->standort,
'subnet' => $obj->subnet,
'status' => (int) $obj->status,
'deviceCount' => (int) $obj->devcount,
'measureCount' => (int) $obj->meascount,
);
}
}
return $list;
}
/**
* Liste (Array aus der App) für die Datenbank zu einer Komma-Zeichenkette
* zusammenfassen.
*
* Ports und mDNS-Dienste sind in der App Arrays; in der Tabelle liegen sie als
* Textspalte. Bewusst mit Längenbegrenzung: ein Gerät mit sehr vielen Diensten
* darf den INSERT nicht scheitern lassen (MariaDB kürzt im strict mode nicht,
* sondern lehnt ab).
*
* @param mixed $val Array oder Skalar aus dem JSON-Payload
* @param int $maxLen Maximale Länge der Zielspalte
* @return string Komma-Liste, ggf. gekürzt
*/
function netdiag_join_list($val, $maxLen)
{
if ($val === null || $val === '') {
return '';
}
if (!is_array($val)) {
$val = array($val);
}
$teile = array();
foreach ($val as $v) {
if (is_array($v)) {
continue; // verschachtelte Strukturen gehören nicht in eine Textspalte
}
$s = trim((string) $v);
if ($s !== '') {
$teile[] = str_replace(',', ' ', $s);
}
}
$out = implode(',', $teile);
return strlen($out) > $maxLen ? substr($out, 0, $maxLen) : $out;
}
/**
* Komma-Zeichenkette aus der Datenbank wieder als Liste liefern.
*
* @param string|null $val Spaltenwert
* @return array
*/
function netdiag_split_list($val)
{
if ($val === null || trim((string) $val) === '') {
return array();
}
$teile = array_map('trim', explode(',', (string) $val));
return array_values(array_filter($teile, static function ($t) {
return $t !== '';
}));
}