From bed611cd8b53fd47db5fe0e2a79d496803fb5346 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Wed, 8 Apr 2026 22:40:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202.3=20+=202.4=20=E2=80=94=20RES?= =?UTF-8?q?T-API=20mit=20JWT-Auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/_jwt.php: HS256 JWT encode/decode/from_request, Secret aus dolibarr_main_instance_unique_id, 7 Tage TTL - api/_inc.php: gemeinsamer API-Init mit CORS, JSON-Helpers, api_authenticate() lädt User aus JWT und prüft bericht/read - api/auth.php: POST { login, password } → JWT mit user + perms - api/orders.php: - GET /api/orders.php — Liste der Aufträge des Users (Multi-User Filter über fk_user_*, Admin sieht alle) - GET /api/orders.php?id=X — Auftrags-Detail mit Kunde + Berichten - GET /api/orders.php?id=X&action=photos — Anhänge - POST /api/orders.php?id=X&action=upload_photo — Foto hochladen, Bericht wird automatisch angelegt falls nicht vorhanden - api/reports.php: - GET /api/reports.php?id=X — Bericht-Detail + Seiten - POST /api/reports.php?id=X&action=finalize — Status auf final Co-Authored-By: Claude Opus 4.6 (1M context) [deploy] --- api/_inc.php | 102 +++++++++++++++++++++++++++ api/_jwt.php | 66 ++++++++++++++++++ api/auth.php | 61 ++++++++++++++++ api/orders.php | 180 ++++++++++++++++++++++++++++++++++++++++++++++++ api/reports.php | 58 ++++++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 api/_inc.php create mode 100644 api/_jwt.php create mode 100644 api/auth.php create mode 100644 api/orders.php create mode 100644 api/reports.php diff --git a/api/_inc.php b/api/_inc.php new file mode 100644 index 0000000..a8a84c6 --- /dev/null +++ b/api/_inc.php @@ -0,0 +1,102 @@ + 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) die("Include of main fails"); + +require_once __DIR__.'/_jwt.php'; +require_once __DIR__.'/../class/bericht.class.php'; +require_once __DIR__.'/../lib/bericht.lib.php'; + +// CORS — die PWA läuft auf der gleichen Domain (subpfad), aber wir sind defensiv +$allowed_origin = '*'; // bei Bedarf in Konstante BERICHT_API_CORS_ORIGIN packen +if (getDolGlobalString('BERICHT_API_CORS_ORIGIN')) { + $allowed_origin = getDolGlobalString('BERICHT_API_CORS_ORIGIN'); +} +header('Access-Control-Allow-Origin: '.$allowed_origin); +header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type, Authorization'); +header('Access-Control-Max-Age: 86400'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +header('Content-Type: application/json; charset=utf-8'); + +function api_send($data, $code = 200) +{ + http_response_code($code); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +} + +function api_fail($msg, $code = 400) +{ + api_send(array('error' => $msg), $code); +} + +function api_ok($data = array()) +{ + api_send(array_merge(array('ok' => true), $data)); +} + +function api_input() +{ + $body = file_get_contents('php://input'); + if (!$body) return $_POST; + $json = json_decode($body, true); + return is_array($json) ? $json : $_POST; +} + +/** + * Lädt den User aus dem JWT und liefert das User-Objekt zurück. + * Beendet bei ungültigem/fehlendem Token. + */ +function api_authenticate($db_param = null) +{ + global $db, $user, $conf; + if ($db_param) $db = $db_param; + + $payload = bericht_jwt_from_request(); + if (!$payload || empty($payload['sub'])) { + api_fail('Token ungültig oder fehlt', 401); + } + + require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; + $u = new User($db); + if ($u->fetch((int) $payload['sub']) <= 0) { + api_fail('User nicht gefunden', 401); + } + if (empty($u->statut)) { + api_fail('User deaktiviert', 401); + } + $u->loadRights(); + $user = $u; + + if (!$user->hasRight('bericht', 'read')) { + api_fail('Keine Bericht-Rechte', 403); + } + return $user; +} diff --git a/api/_jwt.php b/api/_jwt.php new file mode 100644 index 0000000..0b50daa --- /dev/null +++ b/api/_jwt.php @@ -0,0 +1,66 @@ + 'HS256', 'typ' => 'JWT'); + $h = bericht_b64url_encode(json_encode($header)); + $p = bericht_b64url_encode(json_encode($payload)); + $sig = hash_hmac('sha256', $h.'.'.$p, bericht_jwt_secret(), true); + return $h.'.'.$p.'.'.bericht_b64url_encode($sig); +} + +function bericht_jwt_decode($token) +{ + $parts = explode('.', $token); + if (count($parts) !== 3) return null; + list($h, $p, $s) = $parts; + $expected = bericht_b64url_encode(hash_hmac('sha256', $h.'.'.$p, bericht_jwt_secret(), true)); + if (!hash_equals($expected, $s)) return null; + $payload = json_decode(bericht_b64url_decode($p), true); + if (!is_array($payload)) return null; + if (isset($payload['exp']) && $payload['exp'] < time()) return null; + return $payload; +} + +/** + * Liest und validiert das Authorization: Bearer Header. + * @return array|null decoded payload + */ +function bericht_jwt_from_request() +{ + $hdr = ''; + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $hdr = $_SERVER['HTTP_AUTHORIZATION']; + } elseif (function_exists('apache_request_headers')) { + $h = apache_request_headers(); + if (isset($h['Authorization'])) $hdr = $h['Authorization']; + } + if (!$hdr || stripos($hdr, 'bearer ') !== 0) return null; + $token = trim(substr($hdr, 7)); + return bericht_jwt_decode($token); +} diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..5a4e909 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,61 @@ + } + */ +require_once __DIR__.'/_inc.php'; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') api_fail('POST erforderlich', 405); + +$in = api_input(); +$login = trim($in['login'] ?? ''); +$pass = (string) ($in['password'] ?? ''); +if (empty($login) || empty($pass)) api_fail('login + password erforderlich'); + +require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; + +$u = new User($db); +if ($u->fetch('', $login) <= 0) api_fail('Login fehlgeschlagen', 401); + +// Passwort prüfen — Dolibarr's checkPassword braucht den schon geladenen User +if (!dol_verifyHash($pass, $u->pass_indatabase_crypted ?: $u->pass_indatabase)) { + // Fallback: alter Hash-Vergleich + if (md5($pass) !== $u->pass_indatabase) { + api_fail('Login fehlgeschlagen', 401); + } +} + +if (empty($u->statut)) api_fail('User deaktiviert', 403); + +$u->loadRights(); +if (!$u->hasRight('bericht', 'read')) api_fail('Keine Bericht-Rechte', 403); + +// JWT erstellen +$exp = time() + BERICHT_JWT_TTL; +$payload = array( + 'sub' => (int) $u->id, + 'login' => $u->login, + 'name' => method_exists($u, 'getFullName') ? $u->getFullName($langs ?? null) : $u->login, + 'iat' => time(), + 'exp' => $exp, + 'iss' => 'bericht-api', + 'perms' => array( + 'read' => (bool) $u->hasRight('bericht', 'read'), + 'write' => (bool) $u->hasRight('bericht', 'write'), + 'delete' => (bool) $u->hasRight('bericht', 'delete'), + 'admin' => (bool) $u->hasRight('bericht', 'admin'), + ), +); +$token = bericht_jwt_encode($payload); + +api_ok(array( + 'token' => $token, + 'expires' => $exp, + 'user' => array( + 'id' => (int) $u->id, + 'login' => $u->login, + 'name' => $payload['name'], + 'admin' => (bool) ($u->admin ?? false), + ), + 'perms' => $payload['perms'], +)); diff --git a/api/orders.php b/api/orders.php new file mode 100644 index 0000000..3086258 --- /dev/null +++ b/api/orders.php @@ -0,0 +1,180 @@ + + * Liefert Detail eines einzelnen Auftrags. + * GET /api/orders.php?id=&action=photos + * Liefert die Anhang-Bilder/PDFs eines Auftrags. + * POST /api/orders.php?id=&action=upload_photo + * multipart: file= — fügt ein Foto zum Bericht des Auftrags hinzu + * (legt automatisch einen Bericht an wenn keiner existiert) + */ +require_once __DIR__.'/_inc.php'; + +api_authenticate(); +global $db, $user, $conf; + +require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; + +$id = (int) ($_GET['id'] ?? 0); +$action = $_GET['action'] ?? ''; + +/* ----- LISTE ----- */ +if (!$id) { + // Filter: nur Aufträge des aktuellen Users (oder Admin sieht alle) + $where = "c.entity IN (".getEntity('commande').")"; + if (empty($user->admin)) { + $where .= " AND (c.fk_user_author = ".((int) $user->id) + ." OR c.fk_user_valid = ".((int) $user->id) + ." OR c.fk_user_modif = ".((int) $user->id).")"; + } + + // Optional: nur offene + if (!empty($_GET['open'])) { + $where .= " AND c.fk_statut IN (1, 2)"; // validiert + in Bearbeitung + } + // Suchterm + if (!empty($_GET['q'])) { + $q = $db->escape($_GET['q']); + $where .= " AND (c.ref LIKE '%$q%' OR s.nom LIKE '%$q%')"; + } + + $sql = "SELECT c.rowid, c.ref, c.date_commande, c.fk_statut, c.total_ttc," + ." s.rowid AS soc_id, s.nom AS soc_name, s.zip, s.town, s.address," + ." (SELECT COUNT(*) FROM ".$db->prefix()."bericht b WHERE b.element_type='order' AND b.fk_element=c.rowid) AS bericht_count" + ." FROM ".$db->prefix()."commande c" + ." LEFT JOIN ".$db->prefix()."societe s ON s.rowid = c.fk_soc" + ." WHERE ".$where + ." ORDER BY c.date_commande DESC, c.rowid DESC" + ." LIMIT 200"; + + $r = $db->query($sql); + if (!$r) api_fail('DB-Fehler: '.$db->lasterror(), 500); + $orders = array(); + while ($o = $db->fetch_object($r)) { + $orders[] = array( + 'id' => (int) $o->rowid, + 'ref' => $o->ref, + 'date' => $db->jdate($o->date_commande), + 'status'=> (int) $o->fk_statut, + 'total' => (float) $o->total_ttc, + 'customer' => array( + 'id' => (int) $o->soc_id, + 'name' => $o->soc_name, + 'zip' => $o->zip, + 'town' => $o->town, + 'address' => $o->address, + ), + 'bericht_count' => (int) $o->bericht_count, + ); + } + api_ok(array('orders' => $orders, 'count' => count($orders))); +} + +/* ----- DETAIL eines Auftrags ----- */ +$cmd = new Commande($db); +if ($cmd->fetch($id) <= 0) api_fail('Auftrag nicht gefunden', 404); +$cmd->fetch_thirdparty(); +if (method_exists($cmd, 'fetch_optionals')) $cmd->fetch_optionals(); + +if ($action === 'photos') { + // Anhänge des Auftrags + $upload_dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref); + $files = is_dir($upload_dir) + ? dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$') + : array(); + $out = array(); + foreach ($files as $f) { + $out[] = array( + 'filename' => $f['name'], + 'size' => (int) $f['size'], + 'mime' => dol_mimetype($f['name']), + 'date' => (int) $f['date'], + 'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']), + ); + } + api_ok(array('photos' => $out, 'count' => count($out))); +} + +if ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') { + if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403); + if (empty($_FILES['file']['tmp_name'])) api_fail('file fehlt'); + + // Bericht zum Auftrag suchen oder neu anlegen + $list = Bericht::fetchAllForElement($db, 'order', $cmd->id); + $bericht = !empty($list) ? $list[0] : null; + if (!$bericht) { + $bericht = new Bericht($db); + $bericht->element_type = 'order'; + $bericht->fk_element = $cmd->id; + $bericht->titel = 'Bericht '.$cmd->ref; + $bericht->auftragsnummer = $cmd->ref; + $bericht->template_odt = getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', ''); + if ($bericht->create($user) <= 0) api_fail('Bericht-Anlage fehlgeschlagen', 500); + } + + // Datei speichern + $orig = dol_sanitizeFileName($_FILES['file']['name']); + $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); + if (!in_array($ext, array('jpg', 'jpeg', 'png'))) api_fail('Dateityp nicht unterstützt'); + + $workdir = DOL_DATA_ROOT.'/bericht/work/'.$bericht->id; + if (!is_dir($workdir)) dol_mkdir($workdir); + $target = $workdir.'/api_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.uniqid().'.'.$ext; + if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) api_fail('Upload fehlgeschlagen', 500); + + $relpath = str_replace(DOL_DATA_ROOT.'/', '', $target); + + // Als Page anlegen + $resm = $db->query("SELECT COALESCE(MAX(page_order),0) AS m FROM ".$db->prefix()."bericht_page WHERE fk_bericht = ".((int) $bericht->id)); + $next = ($resm && ($o = $db->fetch_object($resm))) ? ((int) $o->m) + 1 : 1; + $page = new BerichtPage($db); + $page->fk_bericht = $bericht->id; + $page->page_order = $next; + $page->source_type = 'upload'; + $page->source_path = $relpath; + if ($page->create() <= 0) api_fail('Page-Insert fehlgeschlagen', 500); + + api_ok(array( + 'bericht_id' => (int) $bericht->id, + 'page_id' => (int) $page->id, + 'filename' => basename($target), + )); +} + +// Default: Auftrags-Detail +$berichte = Bericht::fetchAllForElement($db, 'order', $cmd->id); +$berichte_out = array(); +foreach ($berichte as $b) { + $berichte_out[] = array( + 'id' => (int) $b->id, + 'ref' => $b->ref, + 'titel' => $b->titel, + 'status' => (int) $b->status, + 'datec' => (int) $b->datec, + ); +} + +api_ok(array( + 'order' => array( + 'id' => (int) $cmd->id, + 'ref' => $cmd->ref, + 'date' => $cmd->date_commande, + 'status'=> (int) $cmd->statut, + 'total' => (float) $cmd->total_ttc, + 'note_private' => $cmd->note_private, + 'auftragsbeschreibung' => $cmd->array_options['options_auftragsbeschreibung'] ?? '', + ), + 'customer' => array( + 'id' => (int) ($cmd->thirdparty->id ?? 0), + 'name' => $cmd->thirdparty->name ?? '', + 'address' => $cmd->thirdparty->address ?? '', + 'zip' => $cmd->thirdparty->zip ?? '', + 'town' => $cmd->thirdparty->town ?? '', + 'phone' => $cmd->thirdparty->phone ?? '', + 'email' => $cmd->thirdparty->email ?? '', + ), + 'berichte' => $berichte_out, +)); diff --git a/api/reports.php b/api/reports.php new file mode 100644 index 0000000..328e4f7 --- /dev/null +++ b/api/reports.php @@ -0,0 +1,58 @@ + — Detail eines Berichts + * POST /api/reports.php?id=&action=finalize — Finalisierung anstoßen + * + * Listing aller Berichte läuft über orders.php (pro Auftrag). + */ +require_once __DIR__.'/_inc.php'; + +api_authenticate(); +global $db, $user; + +$id = (int) ($_GET['id'] ?? 0); +$action = $_GET['action'] ?? ''; + +if (!$id) api_fail('id erforderlich'); + +$bericht = new Bericht($db); +if ($bericht->fetch($id) <= 0) api_fail('Bericht nicht gefunden', 404); + +if ($action === 'finalize') { + if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403); + // Wir rufen generate_pdf.php intern auf, indem wir die Logik laden — einfacher: redirect + // Hier simpler Ansatz: setze Status auf Final (echte PDF-Generierung sollte separat triggern) + $bericht->status = Bericht::STATUS_FINAL; + $bericht->update($user); + api_ok(array('status' => 'final')); +} + +// Detail +$pages = BerichtPage::fetchAllForBericht($db, $bericht->id); +$pages_out = array(); +foreach ($pages as $p) { + $pages_out[] = array( + 'id' => (int) $p->id, + 'page_order' => (int) $p->page_order, + 'source_type'=> $p->source_type, + 'source_path'=> $p->source_path, + 'rotation' => (int) $p->rotation, + 'note' => $p->note, + 'layout' => $p->layout, + ); +} + +api_ok(array( + 'report' => array( + 'id' => (int) $bericht->id, + 'ref' => $bericht->ref, + 'titel' => $bericht->titel, + 'auftragsnummer' => $bericht->auftragsnummer, + 'element_type' => $bericht->element_type, + 'fk_element' => (int) $bericht->fk_element, + 'page_format' => $bericht->page_format, + 'page_orientation'=> $bericht->page_orientation, + 'status' => (int) $bericht->status, + 'datec' => (int) $bericht->datec, + ), + 'pages' => $pages_out, +));