From 593c93f37721208e5f611d502ddf6c67c455c79a Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sun, 19 Apr 2026 11:02:27 +0200 Subject: [PATCH] feat: PWA-Token-Auth statt Dolibarr-REST-API-Key [deploy] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das ElektroPlan-Backend spricht nicht mehr die Dolibarr-REST-API an (API-Key-Flow kaputt, REST-Modul nicht zuverlässig), sondern einen eigenen NOLOGIN-Endpoint im Eplan-Modul. - ajax/pwa_api.php: NOLOGIN-Endpoint, Auth via X-Eplan-Token-Header gegen EPLAN_PWA_SECRET (hash_equals, timing-safe) Actions: ping, auftraege_listen, auftrag_details, kunden_suchen, dokument_upload (multipart POST) - lib/eplan_token.lib.php: Lazy-init Token, Rotation, Verifikation - admin/setup.php: Token-Feld (readonly, anklickbar zum Markieren), Endpoint-URL zum Kopieren, Button "Neuen Token erzeugen" - de/en-Sprachdateien: EplanApiToken* Keys Muster aus KB #354 (Bericht/Baustelle-PWA). Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/setup.php | 34 ++++++++ ajax/pwa_api.php | 175 ++++++++++++++++++++++++++++++++++++++++ langs/de_DE/eplan.lang | 10 +++ langs/en_US/eplan.lang | 10 +++ lib/eplan_token.lib.php | 63 +++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 ajax/pwa_api.php create mode 100644 lib/eplan_token.lib.php diff --git a/admin/setup.php b/admin/setup.php index 9532c4b..1ae1527 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -15,6 +15,7 @@ if (!$res) die("Include of main fails"); require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; dol_include_once('/eplan/lib/eplan.lib.php'); +dol_include_once('/eplan/lib/eplan_token.lib.php'); $langs->loadLangs(array('admin', 'eplan@eplan')); @@ -33,6 +34,16 @@ if ($action == 'save' && $user->admin) { exit; } +if ($action == 'token_rotieren' && $user->admin) { + eplanTokenRotieren(); + setEventMessages($langs->trans("EplanTokenRotiert"), null, 'mesgs'); + header("Location: ".$_SERVER['PHP_SELF']); + exit; +} + +// Token beim ersten Aufruf automatisch erzeugen +$pwa_token = eplanTokenHolen(); + $page_name = "EplanSetup"; llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-eplan page-admin-setup'); @@ -83,6 +94,29 @@ print ''; print ''; print ''; +// --- PWA-Token --- +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print '
'.$langs->trans("EplanApiToken").'
'; +print $langs->trans("EplanApiTokenDesc"); +print '
'.$langs->trans("EplanApiTokenValue").''; +print ''; +print '
'.$langs->trans("EplanApiTokenHint").''; +print '
'.$langs->trans("EplanApiEndpoint").''; +print ''.dol_escape_htmltag(DOL_MAIN_URL_ROOT.'/custom/eplan/ajax/pwa_api.php').''; +print '
'; +print ''; +print '
'; + print '
'; print ''; print ''; diff --git a/ajax/pwa_api.php b/ajax/pwa_api.php new file mode 100644 index 0000000..b71e7bb --- /dev/null +++ b/ajax/pwa_api.php @@ -0,0 +1,175 @@ + + * GPL v3+ + * + * NOLOGIN-Endpoint für die ElektroPlan-PWA / FastAPI-Backend. + * Auth: Header "X-Eplan-Token: " gegen Const EPLAN_PWA_SECRET (hash_equals). + * + * Actions (?action=...): + * - ping → {aktiv: true, version: "1.1.0"} + * - auftraege_listen → Liste offener Aufträge (Commande) + * - auftrag_details&id= → Einzelner Auftrag mit Positionen + * - kunden_suchen&such= → Third-Party-Suche + * - dokument_upload (POST) → Plan-PDF an Auftrag/Kunde anhängen + */ + +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('NOLOGIN')) define('NOLOGIN', '1'); + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +$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))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php"; +if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php"; +if (!$res) { http_response_code(500); echo json_encode(['error' => 'main.inc.php not found']); exit; } + +dol_include_once('/eplan/lib/eplan_token.lib.php'); + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +// --- Auth --- +$token = eplanTokenAusRequest(); +if (!eplanTokenPruefen($token)) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized', 'detail' => 'Ungültiger oder fehlender Token']); + exit; +} + +if (!isModEnabled('eplan')) { + http_response_code(503); + echo json_encode(['error' => 'Eplan-Modul deaktiviert']); + exit; +} + +$action = GETPOST('action', 'aZ09'); + +try { + switch ($action) { + case 'ping': + echo json_encode([ + 'aktiv' => true, + 'modul' => 'eplan', + 'version' => '1.2.0', + 'dolibarr' => DOL_VERSION, + ]); + break; + + case 'auftraege_listen': + $such = GETPOST('such', 'alphanohtml'); + $limit = max(1, min(200, (int) GETPOST('limit', 'int') ?: 50)); + $sql = "SELECT c.rowid as id, c.ref, c.ref_client, c.date_commande, c.total_ttc, c.fk_statut, + t.nom as kunde + FROM ".MAIN_DB_PREFIX."commande c + LEFT JOIN ".MAIN_DB_PREFIX."societe t ON t.rowid = c.fk_soc + WHERE c.entity IN (".getEntity('commande').")"; + if (!empty($such)) { + $such_esc = $db->escape($such); + $sql .= " AND (c.ref LIKE '%".$such_esc."%' + OR c.ref_client LIKE '%".$such_esc."%' + OR t.nom LIKE '%".$such_esc."%')"; + } + $sql .= " ORDER BY c.date_commande DESC LIMIT ".((int) $limit); + $resql = $db->query($sql); + $liste = []; + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $liste[] = [ + 'id' => (int) $obj->id, + 'ref' => $obj->ref, + 'ref_client' => $obj->ref_client, + 'kunde' => $obj->kunde, + 'status' => (int) $obj->fk_statut, + 'datum' => $obj->date_commande, + 'total_ttc' => $obj->total_ttc !== null ? (float) $obj->total_ttc : null, + ]; + } + $db->free($resql); + } + echo json_encode($liste); + break; + + case 'auftrag_details': + $id = (int) GETPOST('id', 'int'); + if ($id <= 0) { http_response_code(400); echo json_encode(['error' => 'id fehlt']); break; } + require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; + $c = new Commande($db); + if ($c->fetch($id) <= 0) { http_response_code(404); echo json_encode(['error' => 'nicht gefunden']); break; } + $c->fetch_thirdparty(); + echo json_encode([ + 'id' => (int) $c->id, + 'ref' => $c->ref, + 'ref_client' => $c->ref_client, + 'kunde' => $c->thirdparty ? $c->thirdparty->name : null, + 'kunde_id' => $c->thirdparty ? (int) $c->thirdparty->id : null, + 'datum' => $c->date_commande, + 'status' => (int) $c->statut, + 'total_ttc' => (float) $c->total_ttc, + ]); + break; + + case 'kunden_suchen': + $such = GETPOST('such', 'alphanohtml'); + if (empty($such) || strlen($such) < 2) { echo json_encode([]); break; } + $such_esc = $db->escape($such); + $sql = "SELECT rowid as id, nom, code_client FROM ".MAIN_DB_PREFIX."societe + WHERE entity IN (".getEntity('societe').") + AND (nom LIKE '%".$such_esc."%' OR code_client LIKE '%".$such_esc."%') + ORDER BY nom LIMIT 30"; + $resql = $db->query($sql); + $liste = []; + if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $liste[] = ['id' => (int) $obj->id, 'name' => $obj->nom, 'code' => $obj->code_client]; + } + $db->free($resql); + } + echo json_encode($liste); + break; + + case 'dokument_upload': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); echo json_encode(['error' => 'POST benötigt']); break; + } + $auftrag_id = (int) GETPOST('auftrag_id', 'int'); + if ($auftrag_id <= 0) { http_response_code(400); echo json_encode(['error' => 'auftrag_id fehlt']); break; } + if (empty($_FILES['datei'])) { http_response_code(400); echo json_encode(['error' => 'Keine Datei']); break; } + + require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; + require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + $c = new Commande($db); + if ($c->fetch($auftrag_id) <= 0) { http_response_code(404); echo json_encode(['error' => 'Auftrag nicht gefunden']); break; } + + $ref_sanitiert = dol_sanitizeFileName($c->ref); + $ziel_dir = $conf->commande->dir_output.'/'.$ref_sanitiert; + dol_mkdir($ziel_dir); + + $dateiname = dol_sanitizeFileName($_FILES['datei']['name']); + $ziel = $ziel_dir.'/'.$dateiname; + $erfolg = dol_move_uploaded_file($_FILES['datei']['tmp_name'], $ziel, 1, 0, 0); + if ($erfolg > 0) { + echo json_encode(['erfolg' => true, 'pfad' => $ref_sanitiert.'/'.$dateiname]); + } else { + http_response_code(500); + echo json_encode(['error' => 'Upload fehlgeschlagen', 'code' => $erfolg]); + } + break; + + default: + http_response_code(400); + echo json_encode(['error' => 'Unbekannte Action', 'action' => $action]); + } +} catch (Throwable $e) { + dol_syslog('eplan/pwa_api: '.$e->getMessage(), LOG_ERR); + http_response_code(500); + echo json_encode(['error' => 'Server-Fehler', 'detail' => $e->getMessage()]); +} + +$db->close(); diff --git a/langs/de_DE/eplan.lang b/langs/de_DE/eplan.lang index 98ea193..ef31d83 100644 --- a/langs/de_DE/eplan.lang +++ b/langs/de_DE/eplan.lang @@ -8,6 +8,16 @@ EplanRightRead = Aufmaß einsehen EplanRightWrite = Aufmaß bearbeiten EplanRightAdmin = Eplan-Einstellungen verwalten +# PWA-Token +EplanApiToken = PWA-Anbindung (Token) +EplanApiTokenDesc = Der Token authentifiziert die ElektroPlan-PWA gegen diesen Dolibarr. Trage URL + Token in den PWA-Einstellungen (⚙) ein. +EplanApiTokenValue = Token +EplanApiTokenHint = Einmal anklicken zum Markieren, dann Strg+C zum Kopieren. +EplanApiEndpoint = API-Endpoint +EplanTokenRotieren = Neuen Token erzeugen +EplanTokenRotierenConfirm = Neuen Token erzeugen? Der alte wird sofort ungültig und die PWA muss neu konfiguriert werden. +EplanTokenRotiert = Neuer Token erzeugt. PWA mit dem neuen Token aktualisieren. + EplanSetup = Eplan Einstellungen EplanPwaUrl = PWA-URL EplanPwaUrlDesc = Vollständige URL zur ElektroPlan PWA (z.B. https://elektroplan.example.de) diff --git a/langs/en_US/eplan.lang b/langs/en_US/eplan.lang index cd35282..cd78687 100644 --- a/langs/en_US/eplan.lang +++ b/langs/en_US/eplan.lang @@ -8,6 +8,16 @@ EplanRightRead = View measurement EplanRightWrite = Edit measurement EplanRightAdmin = Manage Eplan settings +# PWA token +EplanApiToken = PWA connection (token) +EplanApiTokenDesc = The token authenticates the ElektroPlan PWA against this Dolibarr. Enter URL + token in the PWA settings (gear icon). +EplanApiTokenValue = Token +EplanApiTokenHint = Click once to select, then Ctrl+C to copy. +EplanApiEndpoint = API endpoint +EplanTokenRotieren = Generate new token +EplanTokenRotierenConfirm = Generate new token? The old one is immediately invalid and the PWA must be reconfigured. +EplanTokenRotiert = New token generated. Update the PWA with the new token. + EplanSetup = Eplan Settings EplanPwaUrl = PWA URL EplanPwaUrlDesc = Full URL to the ElektroPlan PWA (e.g. https://elektroplan.example.com) diff --git a/lib/eplan_token.lib.php b/lib/eplan_token.lib.php new file mode 100644 index 0000000..7e5e7f7 --- /dev/null +++ b/lib/eplan_token.lib.php @@ -0,0 +1,63 @@ + + * GPL v3+ + * + * Token-Handling für die Eplan-PWA-Anbindung. + * Kein JWT — ein simpler Shared-Secret-Token reicht, weil nur der + * ElektroPlan-Backend-Container den Endpoint anspricht (Server-zu-Server + * über HTTPS). Kein User-Kontext, keine Rotation per Request. + */ + +/** + * Lazy-init: Token aus llx_const laden, falls leer frisch erzeugen. + * @return string 64-Zeichen hex Token + */ +function eplanTokenHolen() +{ + global $db, $conf; + + $token = getDolGlobalString('EPLAN_PWA_SECRET'); + if (!empty($token)) { + return $token; + } + + $token = bin2hex(random_bytes(32)); + dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity); + return $token; +} + +/** + * Neuen Token erzeugen (überschreibt den alten). + * @return string neuer Token + */ +function eplanTokenRotieren() +{ + global $db, $conf; + $token = bin2hex(random_bytes(32)); + dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity); + return $token; +} + +/** + * Vergleicht einen übergebenen Token mit dem gespeicherten (timing-safe). + * @param string $eingang Token aus dem Request + * @return bool + */ +function eplanTokenPruefen($eingang) +{ + if (empty($eingang)) return false; + $gespeichert = getDolGlobalString('EPLAN_PWA_SECRET'); + if (empty($gespeichert)) return false; + return hash_equals($gespeichert, (string) $eingang); +} + +/** + * Token aus Request holen: bevorzugt Header X-Eplan-Token, sonst Query-Param `token`. + * @return string|null + */ +function eplanTokenAusRequest() +{ + $header = $_SERVER['HTTP_X_EPLAN_TOKEN'] ?? ''; + if (!empty($header)) return $header; + return $_GET['token'] ?? $_POST['token'] ?? null; +}
'.$langs->trans("EplanAbout").'