bericht/api/photo.php
Eduard Wisch c4c4aba891
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
1.8.0: Videos aus der Baustellen-App annehmen und abspielbar ausliefern [deploy]
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>
2026-10-19 23:54:19 +02:00

172 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/* GET /api/photo.php?relpath=<path>
* Liefert eine Datei aus DOL_DATA_ROOT aus, authentifiziert per JWT.
* Whitelist: nur facture/, commande/, propal/, bericht/
*
* Query:
* relpath — relativer Pfad unter DOL_DATA_ROOT
* size=thumb&w=<px> — serverseitig erzeugtes Thumbnail (GD, gecacht, mit ETag).
* Das ist der Weg für Kachel-Ansichten. w: 40–800, Default 320.
* size=small|mini — altes Verhalten: nutzt ein von Dolibarr vorgefertigtes
* thumbs/<name>_small.<ext>, falls vorhanden. Für Dateien aus
* der PWA existiert so eines nicht (kein vignette() beim Upload).
* download=1 — als Anhang ausliefern statt inline
*/
// Dieser Endpoint liefert Binärdaten aus — KEIN JSON Content-Type!
// Deshalb nicht _inc.php nutzen, sondern awlauth + Dolibarr manuell laden.
if (!defined('NOLOGIN')) define('NOLOGIN', '1');
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');
$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) die("Include of main fails");
require_once __DIR__.'/../lib/bericht.lib.php';
// Auth über das zentrale SSO-Cookie awl_sso. Bewusst awlauth_verify (OHNE CSRF),
// weil dieser reine GET-Read auch per window.location/<a href> geladen wird
// (Download-Link, Audio-Element) und dort kein X-Requested-With setzbar ist.
if (!dol_include_once('/awlauth/lib/awlauth.lib.php') || !function_exists('awlauth_verify')) {
http_response_code(500);
header('Content-Type: text/plain');
echo 'SSO-Modul (awlauth) nicht verfügbar';
exit;
}
$user = awlauth_verify();
if (!$user || !$user->hasRight('bericht', 'read')) {
http_response_code(401);
header('Content-Type: text/plain');
echo 'Nicht angemeldet';
exit;
}
$relpath = (string) ($_GET['relpath'] ?? '');
$size = (string) ($_GET['size'] ?? '');
if (empty($relpath)) {
http_response_code(400);
header('Content-Type: text/plain');
echo 'relpath fehlt';
exit;
}
// Whitelist
if (!preg_match('#^(facture|commande|propal|bericht)/#', $relpath)) {
http_response_code(403);
header('Content-Type: text/plain');
echo 'Pfad nicht erlaubt: '.$relpath;
exit;
}
$full = bericht_resolve_data_path($relpath);
if (!$full || !file_exists($full)) {
http_response_code(404);
header('Content-Type: text/plain');
echo 'Datei nicht gefunden: '.$relpath;
exit;
}
// Serverseitig erzeugtes Thumbnail (GD, gecacht) — der Weg für die Kachel-Ansicht der PWA.
//
// Die alte Variante size=small/mini konnte nur ein Thumbnail ausliefern, das Dolibarr selbst
// unter thumbs/<name>_small.<ext> abgelegt hatte. Fotos aus der PWA kommen aber über
// orders.php?action=upload_photo herein, und dort läuft kein vignette() — es gab also nie
// eines, und jede Kachel lud das komplette Handyfoto. bericht_attachment_thumb() (seit 1.5.0,
// auch vom Editor genutzt) rendert bei Bedarf und legt das Ergebnis unter bericht/thumbs/ ab.
if ($size === 'thumb') {
$w = (int) ($_GET['w'] ?? 320);
if ($w < 40) $w = 40;
if ($w > 800) $w = 800;
// ETag über mtime: ein ersetztes Original liefert einen neuen Wert, sonst antwortet
// der Server mit 304 und überträgt gar nichts.
$etag = '"'.sha1($relpath.'|'.@filemtime($full).'|'.$w).'"';
header('ETag: '.$etag);
header('Cache-Control: private, max-age=2592000');
if (trim((string) ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
exit;
}
$thumb_data = bericht_attachment_thumb($full, $w);
if ($thumb_data !== null) {
header('Content-Type: image/jpeg');
header('Content-Length: '.strlen($thumb_data));
echo $thumb_data;
exit;
}
// Kein unterstütztes Bild (PDF/Video) oder zu groß für den Speicher:
// unten regulär das Original ausliefern.
}
// Alt-Variante: von Dolibarr vorgefertigtes Thumbnail, falls vorhanden
if ($size === 'small' || $size === 'mini') {
$dir = dirname($full);
$base = pathinfo($full, PATHINFO_FILENAME);
$ext = pathinfo($full, PATHINFO_EXTENSION);
$thumb = $dir.'/thumbs/'.$base.'_'.$size.'.'.$ext;
if (file_exists($thumb)) $full = $thumb;
}
$mime = function_exists('dol_mimetype') ? dol_mimetype($full) : 'application/octet-stream';
$download = !empty($_GET['download']);
$filename = $download ? basename($full) : '';
header('Content-Type: '.$mime);
header('Accept-Ranges: bytes');
header('Cache-Control: private, max-age=3600');
if ($download) {
header('Content-Disposition: attachment; filename="'.addslashes($filename).'"');
}
// 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;