1.8.0: Videos aus der Baustellen-App annehmen und abspielbar ausliefern [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 13s

Eddy: „Bei Fotos aufnehmen auch Videos unterstützen bitte".

- orders.php upload_photo nimmt mp4/m4v/mov/webm, am Inhalt geprüft
  (ftyp ohne Bildmarke bzw. EBML), Name video_…; taken_at wird bei
  Videos zum Dateidatum. Zu groß für PHP → 413 mit Meldung.
- Aufnahmezeit eines Videos aus dem MP4-Kopf (moov → mvhd), auch wenn
  moov hinter den Videodaten steht.
- photo.php liefert Teilbereiche (Range → 206): Videos spielen sofort
  und lassen sich spulen.
- config.php meldet upload_max für die Höchstlänge in der App.
- Bericht-Editor: 🎬-Kachel für Videos; in Berichte übernehmen geht
  weiterhin nur mit Bild/PDF.

Im Android-Emulator über die App geprüft (Aufnahme, Funkloch, Galerie,
Abspielen, Löschen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-10-19 23:54:19 +02:00
parent 1d3070e6e2
commit c4c4aba891
8 changed files with 253 additions and 16 deletions

View file

@ -165,9 +165,12 @@ API-Endpoints (neue):
- GET /api/orders.php — Aufträge des Users (filter: q, open)
- GET /api/orders.php?id=<id> — Order-Detail
- GET /api/orders.php?id=<id>&action=photos — Order-Dateien (Bilder/Audio/PDFs/Dokumente)
- POST /api/orders.php?id=<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=<path> — Datei-Serving (Whitelist: facture|commande|propal|bericht)
- GET /api/photo.php?relpath=<path> — Datei-Serving (Whitelist: facture|commande|propal|bericht), seit 1.8.0 mit Range (206) für Videos
- GET /api/photo.php?relpath=<path>&size=thumb&w=<px> — Thumbnail (1.5.1, GD + Cache + ETag)
- POST /api/pages.php?action=signature&bericht_id=<id> — Touch-Unterschrift
- DELETE /api/pages.php?id=<id> — Seite löschen

View file

@ -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_<Datum>_<uniqid>.<ext>`, 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

View file

@ -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

View file

@ -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(),
));

View file

@ -6,8 +6,10 @@
* GET /api/orders.php?id=<id>&action=photos
* Liefert die Anhang-Bilder/PDFs eines Auftrags.
* POST /api/orders.php?id=<id>&action=upload_photo
* multipart: file=<binary> — fügt ein Foto zum Bericht des Auftrags hinzu
* (legt automatisch einen Bericht an wenn keiner existiert)
* multipart: file=<binary> — 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=<Unix-Zeit> = 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,8 +227,27 @@ 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');
}
$orig = dol_sanitizeFileName($_FILES['file']['name']);
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$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']);
@ -236,6 +257,7 @@ if ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$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);

View file

@ -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;

View file

@ -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';

View file

@ -698,7 +698,9 @@ function bericht_print_attachment_list(array $by_source, array $used_paths)
print '<canvas class="att-thumb-canvas" data-pdf-url="'.dol_escape_htmltag($urls['view']).'"></canvas>';
print '<span class="att-type-badge">PDF</span>';
} else {
print '<span class="att-thumb-fallback">📎</span>';
// 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 '<span class="att-thumb-fallback">'.($ist_video ? '🎬' : '📎').'</span>';
}
if ($is_used) print '<span class="att-used-badge" title="Diese Datei ist bereits im Bericht">✓</span>';
print '</div>';
@ -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;
}