1.8.0: Videos aus der Baustellen-App annehmen und abspielbar ausliefern [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
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:
parent
1d3070e6e2
commit
c4c4aba891
8 changed files with 253 additions and 16 deletions
|
|
@ -165,9 +165,12 @@ API-Endpoints (neue):
|
||||||
- GET /api/orders.php — Aufträge des Users (filter: q, open)
|
- 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> — Order-Detail
|
||||||
- GET /api/orders.php?id=<id>&action=photos — Order-Dateien (Bilder/Audio/PDFs/Dokumente)
|
- 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
|
- 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)
|
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)
|
- 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
|
- POST /api/pages.php?action=signature&bericht_id=<id> — Touch-Unterschrift
|
||||||
- DELETE /api/pages.php?id=<id> — Seite löschen
|
- DELETE /api/pages.php?id=<id> — Seite löschen
|
||||||
|
|
|
||||||
26
ChangeLog.md
26
ChangeLog.md
|
|
@ -1,5 +1,31 @@
|
||||||
# Changelog
|
# 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
|
## 1.7.5 — 2026-10-19
|
||||||
|
|
||||||
### WebP-Bilder werden beim Hochladen umgewandelt statt abgelehnt
|
### WebP-Bilder werden beim Hochladen umgewandelt statt abgelehnt
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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)
|
- **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.
|
- **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)
|
- **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
|
- **Notizen pro Seite** — werden im finalen PDF unten auf der Seite gedruckt
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
* anpasst. 0 = Originalgroesse (1:1), das ist die Vorgabe.
|
* anpasst. 0 = Originalgroesse (1:1), das ist die Vorgabe.
|
||||||
* photo_quality — JPEG-Qualitaet in Prozent; greift nur, wenn tatsaechlich
|
* photo_quality — JPEG-Qualitaet in Prozent; greift nur, wenn tatsaechlich
|
||||||
* umgerechnet wird (also bei photo_maxside > 0).
|
* 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';
|
require_once __DIR__.'/_inc.php';
|
||||||
|
|
||||||
|
|
@ -16,4 +19,5 @@ api_authenticate();
|
||||||
api_ok(array(
|
api_ok(array(
|
||||||
'photo_maxside' => getDolGlobalInt('BERICHT_PHOTO_MAXSIDE', 0),
|
'photo_maxside' => getDolGlobalInt('BERICHT_PHOTO_MAXSIDE', 0),
|
||||||
'photo_quality' => getDolGlobalInt('BERICHT_PHOTO_QUALITY', 100),
|
'photo_quality' => getDolGlobalInt('BERICHT_PHOTO_QUALITY', 100),
|
||||||
|
'upload_max' => bericht_upload_max_bytes(),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
* GET /api/orders.php?id=<id>&action=photos
|
* GET /api/orders.php?id=<id>&action=photos
|
||||||
* Liefert die Anhang-Bilder/PDFs eines Auftrags.
|
* Liefert die Anhang-Bilder/PDFs eines Auftrags.
|
||||||
* POST /api/orders.php?id=<id>&action=upload_photo
|
* POST /api/orders.php?id=<id>&action=upload_photo
|
||||||
* multipart: file=<binary> — fügt ein Foto zum Bericht des Auftrags hinzu
|
* multipart: file=<binary> — legt ein Foto oder Video im Auftragsordner ab
|
||||||
* (legt automatisch einen Bericht an wenn keiner existiert)
|
* (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
|
* POST /api/orders.php?action=create
|
||||||
* JSON-Body: { socid, ref_client, title?, note_private?, date?, date_livraison?, validate? }
|
* 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).
|
* - 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 ($action === 'upload_photo' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if (!$user->hasRight('bericht', 'write')) api_fail('Schreibrechte fehlen', 403);
|
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']);
|
$orig = dol_sanitizeFileName($_FILES['file']['name']);
|
||||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
$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)
|
// Foto direkt in den Dolibarr-Standard-Auftragsordner speichern (kein Bericht nötig)
|
||||||
$upload_dir = $conf->commande->multidir_output[$cmd->entity].'/'.dol_sanitizeFileName($cmd->ref);
|
$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;
|
$target = $upload_dir.'/'.$filename;
|
||||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) api_fail('Upload fehlgeschlagen', 500);
|
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)
|
bericht_als_beilage_indexieren($target, $cmd); // Beilage → nur im Reiter (KB #1641)
|
||||||
|
|
||||||
$relpath = str_replace(DOL_DATA_ROOT.'/', '', $target);
|
$relpath = str_replace(DOL_DATA_ROOT.'/', '', $target);
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,50 @@ $download = !empty($_GET['download']);
|
||||||
$filename = $download ? basename($full) : '';
|
$filename = $download ? basename($full) : '';
|
||||||
|
|
||||||
header('Content-Type: '.$mime);
|
header('Content-Type: '.$mime);
|
||||||
header('Content-Length: '.filesize($full));
|
header('Accept-Ranges: bytes');
|
||||||
header('Cache-Control: private, max-age=3600');
|
header('Cache-Control: private, max-age=3600');
|
||||||
if ($download) {
|
if ($download) {
|
||||||
header('Content-Disposition: attachment; filename="'.addslashes($filename).'"');
|
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;
|
exit;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class modBericht extends DolibarrModules
|
||||||
|
|
||||||
$this->editor_name = 'Alles Watt läuft';
|
$this->editor_name = 'Alles Watt läuft';
|
||||||
$this->editor_url = '';
|
$this->editor_url = '';
|
||||||
$this->version = '1.7.5';
|
$this->version = '1.8.0';
|
||||||
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
|
||||||
$this->picto = 'fa-file-pdf';
|
$this->picto = 'fa-file-pdf';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 '<canvas class="att-thumb-canvas" data-pdf-url="'.dol_escape_htmltag($urls['view']).'"></canvas>';
|
||||||
print '<span class="att-type-badge">PDF</span>';
|
print '<span class="att-type-badge">PDF</span>';
|
||||||
} else {
|
} 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>';
|
if ($is_used) print '<span class="att-used-badge" title="Diese Datei ist bereits im Bericht">✓</span>';
|
||||||
print '</div>';
|
print '</div>';
|
||||||
|
|
@ -964,6 +966,8 @@ function bericht_file_taken_at($fullpath, $fallback_date = null)
|
||||||
$ts = null;
|
$ts = null;
|
||||||
if (preg_match('/\.jpe?g$/i', $fullpath)) {
|
if (preg_match('/\.jpe?g$/i', $fullpath)) {
|
||||||
$ts = bericht_jpeg_taken_at($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);
|
if (!$ts) $ts = $fallback_date ? (int) $fallback_date : @filemtime($fullpath);
|
||||||
|
|
||||||
|
|
@ -1644,3 +1648,129 @@ function bericht_find_identical_file($dir, $tmpfile)
|
||||||
|
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue