diff --git a/CLAUDE.md b/CLAUDE.md index 8a5d147..db40c03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,9 +165,12 @@ API-Endpoints (neue): - GET /api/orders.php — Aufträge des Users (filter: q, open) - GET /api/orders.php?id= — Order-Detail - GET /api/orders.php?id=&action=photos — Order-Dateien (Bilder/Audio/PDFs/Dokumente) +- POST /api/orders.php?id=&action=upload_photo — Foto ODER Video (1.8.0: mp4/m4v/mov/webm, Inhalt geprüft, `video_…`, + optional taken_at; zu groß → 413). Videos: Aufnahmezeit aus mvhd (`bericht_video_taken_at`), sonst Dateidatum +- GET /api/config.php — photo_maxside, photo_quality, upload_max (Bytes, 1.8.0) - POST /api/orders.php?action=create — Neuer Order (socid, title, ref_client, date); haengt seit 1.7.3 KEINE Kontakte selbst an (Standards kommen vom Kern-Trigger ContactRoles, DeliveryInvoiceAddress 1.8.0 ordnet sie) -- GET /api/photo.php?relpath= — Datei-Serving (Whitelist: facture|commande|propal|bericht) +- GET /api/photo.php?relpath= — Datei-Serving (Whitelist: facture|commande|propal|bericht), seit 1.8.0 mit Range (206) für Videos - GET /api/photo.php?relpath=&size=thumb&w= — Thumbnail (1.5.1, GD + Cache + ETag) - POST /api/pages.php?action=signature&bericht_id= — Touch-Unterschrift - DELETE /api/pages.php?id= — Seite löschen diff --git a/ChangeLog.md b/ChangeLog.md index 619eb2a..add57c8 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,31 @@ # Changelog +## 1.8.0 — 2026-10-19 + +### Videos aus der Baustellen-App +„Bei Fotos aufnehmen auch Videos unterstützen bitte" (Eddy, 19.10.2026). Die Baustellen-App filmt jetzt +(Kamera mit Umschalter Foto | Video, Galerie, Teilen); das Modul nimmt die Videos an und liefert sie aus. + +- **`api/orders.php?action=upload_photo`** nimmt neben jpg/png auch **mp4, m4v, mov, webm**. Geprüft wird am + Inhalt (`bericht_ist_video()`): MP4/MOV brauchen den Kasten `ftyp` ohne Bildmarke (AVIF/HEIC als „.mp4" + werden abgelehnt), WebM den EBML-Kopf. Name `video__.`, Ablage und ECM-Index wie Fotos + (Beilage → Reiter). Die AVIF/WebP-Umwandlung läuft für Videos nicht. +- **Aufnahmezeit**: optional `taken_at` (Unix-Zeit) wird bei Videos zum Dateidatum — ein im Funkloch gefilmtes + Video sortiert sich sonst unter der späteren Sendezeit ein. Gelesen wird die Zeit beim Auflisten zuerst aus + dem MP4 selbst (`bericht_video_taken_at()`: moov → mvhd → creation_time, `moov` auch hinter den Videodaten); + die Android-Fotoauswahl liefert als Dateidatum nur den Moment der Auswahl. +- **Zu groß für PHP** (upload_max_filesize/post_max_size) → **HTTP 413** mit „Datei zu groß — höchstens N MB" + statt „file fehlt". Die App wertet 413 als endgültig und schickt das Video nicht immer wieder. +- **`api/config.php`** meldet `upload_max` (Bytes, `bericht_upload_max_bytes()`); die App richtet danach die + Höchstlänge eines Videos aus und lehnt zu große Galerie-Videos gleich ab. +- **`api/photo.php`** liefert Teilbereiche (**Range → 206**, `Accept-Ranges: bytes`): Videos spielen sofort und + lassen sich spulen, ohne vorher komplett geladen zu werden. Mehrfach-Bereiche → ganze Datei (zulässig). +- **Bericht-Editor**: Video-Anhänge zeigen 🎬 statt 📎. In einen Bericht übernehmen lässt sich ein Video nicht — + `ajax/add_attachment.php` nimmt weiterhin nur Bild/PDF und meldet das; das PDF kann an Videos nicht scheitern. +- Getestet im Android-Emulator (Chrome 153) über die App: MP4 H.264/AAC kommt an, Range-Anfragen 206, + im Funkloch gefilmtes Video nach Netz-Rückkehr hochgeladen und nach Aufnahmezeit sortiert, 124-MB-Video + bei 100-MB-Grenze abgelehnt. Inhaltsprüfung per CLI: MP4 ✓, AVIF als .mp4 ✗, WebM ✓, GIF als .mp4 ✗, altes MOV ✓. + ## 1.7.5 — 2026-10-19 ### WebP-Bilder werden beim Hochladen umgewandelt statt abgelehnt diff --git a/README.md b/README.md index 599a0cd..683623b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Bilder und PDFs lassen sich im Browser annotieren (Pfeile, Kreise, Rechtecke, Te - **Bild einer Seite austauschen** ohne sie zu löschen — Reihenfolge, Notiz und Anmerkungen bleiben erhalten. Bei Raster-Layouts lässt sich jeder Platz einzeln tauschen, direkt auf der Seite anklickbar. - **Ziehen und Ablegen**: Kachel auf die Seitenliste legt neue Seiten an, Kachel auf die Arbeitsfläche ersetzt das Bild der offenen Seite, Kachel auf einen Raster-Platz setzt genau diesen. Dateien vom Rechner auf die Anhänge-Spalte werden hochgeladen. - **Mobiler Upload per QR-Code** — Handy scannt, lädt Fotos direkt in den offenen Bericht (zeitlich begrenzte Token) +- **Videos aus der Baustellen-App (1.8.0)** — `orders.php?action=upload_photo` nimmt mp4/m4v/mov/webm (Inhalt geprüft, Name `video_…`, Aufnahmezeit aus `taken_at` bzw. dem MP4-Kopf), `photo.php` liefert Teilbereiche (Range) zum Abspielen und Spulen, `config.php` meldet `upload_max`, zu groß → 413. Im Editor 🎬-Kachel, nicht in Berichte übernehmbar. - **AVIF- und WebP-Bilder (1.7.4/1.7.5)** — beim Hochladen (Editor, PWA, Handy-Upload) am Inhalt erkannt und in JPG bzw. PNG (mit Transparenz) umgewandelt (`lib/bericht_avif.lib.php`, `bericht_upload_bild_umwandeln()`, KB #1698, #447). Bewegte WebP werden mit Meldung abgelehnt. - **Ablage am Beleg als Beilage (1.7.2)** — Fotos, Notizen, Sprachnotizen, Scans und Bericht-PDFs, die im Belegordner landen, stehen im ECM-Index als `uploaded` (`lib/bericht_index.lib.php`): nur im Reiter „Verknüpfte Dokumente", nie vorne im Kasten der Belegkarte (KB #1641) - **Notizen pro Seite** — werden im finalen PDF unten auf der Seite gedruckt diff --git a/api/config.php b/api/config.php index 2c66ef8..a9d9f4b 100644 --- a/api/config.php +++ b/api/config.php @@ -8,6 +8,9 @@ * anpasst. 0 = Originalgroesse (1:1), das ist die Vorgabe. * photo_quality — JPEG-Qualitaet in Prozent; greift nur, wenn tatsaechlich * umgerechnet wird (also bei photo_maxside > 0). + * upload_max — groesste Datei in Bytes, die der Server annimmt (PHP-Grenzen, + * seit 1.8.0). Die PWA richtet die Hoechstlaenge eines Videos danach + * aus und lehnt zu grosse Videos aus der Galerie gleich ab. */ require_once __DIR__.'/_inc.php'; @@ -16,4 +19,5 @@ api_authenticate(); api_ok(array( 'photo_maxside' => getDolGlobalInt('BERICHT_PHOTO_MAXSIDE', 0), 'photo_quality' => getDolGlobalInt('BERICHT_PHOTO_QUALITY', 100), + 'upload_max' => bericht_upload_max_bytes(), )); diff --git a/api/orders.php b/api/orders.php index dce073a..bca6464 100644 --- a/api/orders.php +++ b/api/orders.php @@ -6,8 +6,10 @@ * 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) + * multipart: file= — legt ein Foto oder Video im Auftragsordner ab + * (Fotos jpg/png, AVIF/WebP werden umgewandelt; Videos mp4/m4v/mov/webm seit 1.8.0, + * Inhalt geprüft, Name video_…). Optional taken_at= = Aufnahmezeit eines + * Videos, wird zum Dateidatum. Zu groß für PHP → 413. * POST /api/orders.php?action=create * JSON-Body: { socid, ref_client, title?, note_private?, date?, date_livraison?, validate? } * - ref_client ("Ihr Zeichen") ist PFLICHT (Geschäftsregel; Dolibarr selbst verlangt es nicht). @@ -225,17 +227,37 @@ if ($action === 'photos') { 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'); + if (empty($_FILES['file']['tmp_name'])) { + // Zu groß für PHP: bei upload_max_filesize meldet $_FILES den Fehler, bei post_max_size + // kommt gar nichts an. 413 ist für die App ein dauerhafter Fehler — sonst schickte sie + // ein zu großes Video immer wieder über den Mobilfunk. + $max = bericht_upload_max_bytes(); + $php_fehler = (int) ($_FILES['file']['error'] ?? 0); + if (in_array($php_fehler, array(UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE), true) + || (empty($_FILES) && $max > 0 && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max)) { + api_fail('Datei zu groß — höchstens '.round($max / 1048576).' MB', 413); + } + api_fail('file fehlt'); + } - // AVIF/WebP (Galerie/Teilen aus dem Netz, auch unter „.jpg") vor der Prüfung in JPG/PNG umwandeln - require_once __DIR__.'/../lib/bericht_avif.lib.php'; - $bild_fehler = bericht_upload_bild_umwandeln($_FILES['file']); - if ($bild_fehler !== '') api_fail($bild_fehler); - - // Datei validieren $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'); + $ist_video = in_array($ext, bericht_video_endungen(), true); + + if ($ist_video) { + // Videos aus der Kamera der App, der Galerie oder per Teilen (seit 1.8.0) + if (!bericht_ist_video($_FILES['file']['tmp_name'], $ext)) api_fail('Kein gültiges Video'); + } else { + // AVIF/WebP (Galerie/Teilen aus dem Netz, auch unter „.jpg") vor der Prüfung in JPG/PNG umwandeln + require_once __DIR__.'/../lib/bericht_avif.lib.php'; + $bild_fehler = bericht_upload_bild_umwandeln($_FILES['file']); + if ($bild_fehler !== '') api_fail($bild_fehler); + + // Datei validieren + $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'); + } // Foto direkt in den Dolibarr-Standard-Auftragsordner speichern (kein Bericht nötig) $upload_dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref); @@ -255,9 +277,20 @@ if ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') { )); } - $filename = 'foto_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.uniqid().'.'.$ext; + $filename = ($ist_video ? 'video_' : 'foto_').dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.uniqid().'.'.$ext; $target = $upload_dir.'/'.$filename; if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) api_fail('Upload fehlgeschlagen', 500); + + // Videos tragen keine EXIF-Zeit; die Liste sortiert sie nach dem Dateidatum. Im Funkloch + // gefilmt und Stunden später gesendet, stünde dort die Sendezeit — die App schickt die + // Aufnahmezeit mit (Galerie: Dateidatum des Videos), sie wird zum Dateidatum. Nur plausible Werte. + if ($ist_video) { + $aufgenommen = (int) GETPOST('taken_at', 'int'); + if ($aufgenommen > dol_now() - 400 * 86400 && $aufgenommen < dol_now() + 300) { + @touch($target, $aufgenommen); + clearstatcache(true, $target); + } + } bericht_als_beilage_indexieren($target, $cmd); // Beilage → nur im Reiter (KB #1641) $relpath = str_replace(DOL_DATA_ROOT.'/', '', $target); diff --git a/api/photo.php b/api/photo.php index c530e82..e11610c 100644 --- a/api/photo.php +++ b/api/photo.php @@ -123,10 +123,50 @@ $download = !empty($_GET['download']); $filename = $download ? basename($full) : ''; header('Content-Type: '.$mime); -header('Content-Length: '.filesize($full)); +header('Accept-Ranges: bytes'); header('Cache-Control: private, max-age=3600'); if ($download) { header('Content-Disposition: attachment; filename="'.addslashes($filename).'"'); } -readfile($full); + +// Teilbereiche (Range, seit 1.8.0): Ein Video aus der Baustellen-App spielt sonst erst, wenn +// es komplett geladen ist, und lässt sich nicht spulen. Nur ein einzelner Bereich — bei +// mehreren (selten) geht wie bisher die ganze Datei raus, das ist erlaubt. +$groesse = (int) filesize($full); +$von = 0; +$bis = $groesse - 1; +if (!empty($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m) + && ($m[1] !== '' || $m[2] !== '')) { + if ($m[1] === '') { + $von = max(0, $groesse - (int) $m[2]); // „bytes=-500": die letzten 500 Bytes + } else { + $von = (int) $m[1]; + if ($m[2] !== '') $bis = min((int) $m[2], $groesse - 1); + } + if ($von > $bis || $von >= $groesse) { + http_response_code(416); + header('Content-Range: bytes */'.$groesse); + exit; + } + http_response_code(206); + header('Content-Range: bytes '.$von.'-'.$bis.'/'.$groesse); +} +$laenge = $bis - $von + 1; +header('Content-Length: '.$laenge); + +if ($von === 0 && $laenge === $groesse) { + readfile($full); + exit; +} +$fh = fopen($full, 'rb'); +if ($fh === false) exit; +fseek($fh, $von); +$rest = $laenge; +while ($rest > 0 && !feof($fh)) { + $stueck = fread($fh, min(1048576, $rest)); + if ($stueck === false || $stueck === '') break; + echo $stueck; + $rest -= strlen($stueck); +} +fclose($fh); exit; diff --git a/core/modules/modBericht.class.php b/core/modules/modBericht.class.php index 19a2598..5ff40fb 100644 --- a/core/modules/modBericht.class.php +++ b/core/modules/modBericht.class.php @@ -27,7 +27,7 @@ class modBericht extends DolibarrModules $this->editor_name = 'Alles Watt läuft'; $this->editor_url = ''; - $this->version = '1.7.5'; + $this->version = '1.8.0'; $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); $this->picto = 'fa-file-pdf'; diff --git a/lib/bericht.lib.php b/lib/bericht.lib.php index 695420e..e89d1c6 100644 --- a/lib/bericht.lib.php +++ b/lib/bericht.lib.php @@ -698,7 +698,9 @@ function bericht_print_attachment_list(array $by_source, array $used_paths) print ''; print 'PDF'; } else { - print '📎'; + // Videos aus der Baustellen-App: kommen nicht in den Bericht (add_attachment nimmt nur Bild/PDF) + $ist_video = strpos($f['mime'], 'video') === 0 || preg_match('/\.(mov|m4v)$/i', $f['filename']); // .mov kennt dol_mimetype nicht + print ''.($ist_video ? '🎬' : '📎').''; } if ($is_used) print '✓'; print ''; @@ -964,6 +966,8 @@ function bericht_file_taken_at($fullpath, $fallback_date = null) $ts = null; if (preg_match('/\.jpe?g$/i', $fullpath)) { $ts = bericht_jpeg_taken_at($fullpath); + } elseif (preg_match('/\.(mp4|m4v|mov)$/i', $fullpath)) { + $ts = bericht_video_taken_at($fullpath); } if (!$ts) $ts = $fallback_date ? (int) $fallback_date : @filemtime($fullpath); @@ -1644,3 +1648,129 @@ function bericht_find_identical_file($dir, $tmpfile) return null; } + +/** + * Videos, die die Baustellen-App annimmt (Kamera der App, Galerie, Teilen). + * MP4 kommt aus der App-Kamera (Chrome/Android), MOV vom iPhone, WebM als Rückfall älterer Browser. + */ +function bericht_video_endungen() +{ + return array('mp4', 'm4v', 'mov', 'webm'); +} + +/** + * Prüft am Inhalt, ob eine hochgeladene Datei wirklich ein Video der genannten Art ist — + * die Endung allein sagt nichts. + * + * MP4/M4V/MOV (ISO-Basisformat): ab Byte 4 steht der Kasten „ftyp"; ältere QuickTime-Dateien + * beginnen auch mit „moov", „mdat", „wide", „free" oder „skip". Bild-Marken im ftyp (AVIF, HEIC) + * sind keine Videos. WebM (Matroska): EBML-Kopf 1A 45 DF A3. + * + * @param string $datei Pfad der hochgeladenen Datei + * @param string $ext Endung in Kleinbuchstaben + * @return bool + */ +function bericht_ist_video($datei, $ext) +{ + $kopf = @file_get_contents($datei, false, null, 0, 12); + if ($kopf === false || strlen($kopf) < 12) return false; + if ($ext === 'webm') return substr($kopf, 0, 4) === "\x1A\x45\xDF\xA3"; + + $kasten = substr($kopf, 4, 4); + if ($kasten === 'ftyp') { + $marke = strtolower(substr($kopf, 8, 4)); + return !in_array($marke, array('avif', 'avis', 'heic', 'heix', 'mif1', 'msf1'), true); + } + return $ext === 'mov' && in_array($kasten, array('moov', 'mdat', 'wide', 'free', 'skip'), true); +} + +/** + * Größte Datei, die PHP über einen Upload annimmt (in Bytes): das Kleinere aus + * upload_max_filesize und post_max_size. Die Baustellen-App richtet danach die + * Höchstlänge ihrer Videos aus (api/config.php). + * + * @return int 0 = unbekannt + */ +function bericht_upload_max_bytes() +{ + $werte = array(); + foreach (array('upload_max_filesize', 'post_max_size') as $schluessel) { + $wert = trim((string) ini_get($schluessel)); + if ($wert === '') continue; + $zahl = (float) $wert; + switch (strtolower(substr($wert, -1))) { + case 'g': $zahl *= 1024; + // no break + case 'm': $zahl *= 1024; + // no break + case 'k': $zahl *= 1024; + } + if ($zahl > 0) $werte[] = (int) $zahl; + } + return $werte ? min($werte) : 0; +} + +/** + * Aufnahmezeit eines MP4/MOV-Videos aus dem Film-Kopf (moov → mvhd, creation_time). + * + * Gegenstück zu bericht_jpeg_taken_at(): Kamera-Apps und Chrome (App-Kamera) schreiben die + * Zeit dorthin. Ein Video aus der Galerie trägt sonst als Datum nur den Moment der Auswahl — + * die Android-Fotoauswahl liefert nicht das Aufnahmedatum (Emulator, 19.10.2026). + * moov steht bei Kamera-Apps oft HINTER den Videodaten; die obersten Kästen werden deshalb + * per Längenangabe übersprungen, nicht gelesen. + * + * @param string $file + * @return int|null Unix-Timestamp oder null (kein mvhd, Zeit 0 oder unplausibel) + */ +function bericht_video_taken_at($file) +{ + $fh = @fopen($file, 'rb'); + if (!$fh) return null; + $groesse = (int) @filesize($file); + + // Kasten an $pos lesen: [Typ, Gesamtlänge, Kopflänge] oder null + $kasten = function ($pos, $ende) use ($fh) { + if ($pos + 8 > $ende) return null; + fseek($fh, $pos); + $kopf = fread($fh, 16); + if ($kopf === false || strlen($kopf) < 8) return null; + $laenge = unpack('N', substr($kopf, 0, 4))[1]; + $kopflaenge = 8; + if ($laenge === 1 && strlen($kopf) >= 16) { // 64-Bit-Länge + $laenge = unpack('N', substr($kopf, 8, 4))[1] * 4294967296 + unpack('N', substr($kopf, 12, 4))[1]; + $kopflaenge = 16; + } elseif ($laenge === 0) { // bis zum Ende + $laenge = $ende - $pos; + } + if ($laenge < $kopflaenge) return null; + return array(substr($kopf, 4, 4), $laenge, $kopflaenge); + }; + + $ts = null; + $pos = 0; + while (($k = $kasten($pos, $groesse)) !== null) { + if ($k[0] === 'moov') { + $ende = $pos + $k[1]; + $kind = $pos + $k[2]; + while (($m = $kasten($kind, $ende)) !== null) { + if ($m[0] === 'mvhd') { + fseek($fh, $kind + $m[2]); + $daten = fread($fh, 12); + if ($daten !== false && strlen($daten) >= 8) { + $sek = (ord($daten[0]) === 1 && strlen($daten) >= 12) + ? unpack('N', substr($daten, 4, 4))[1] * 4294967296 + unpack('N', substr($daten, 8, 4))[1] + : unpack('N', substr($daten, 4, 4))[1]; + $unix = $sek - 2082844800; // Sekunden seit 1.1.1904 → Unix-Zeit + if ($unix > 946684800 && $unix < time() + 86400) $ts = (int) $unix; // nach 2000, nicht in der Zukunft + } + break; + } + $kind += $m[1]; + } + break; + } + $pos += $k[1]; + } + fclose($fh); + return $ts; +}