memories/lib/Exif.php

521 lines
16 KiB
PHP
Raw Normal View History

2022-08-20 02:53:21 +00:00
<?php
2022-10-19 17:10:36 +00:00
2022-08-20 02:53:21 +00:00
declare(strict_types=1);
namespace OCA\Memories;
use OCA\Memories\AppInfo\Application;
use OCP\Files\File;
use OCP\IConfig;
2022-10-19 17:10:36 +00:00
class Exif
{
private const FORBIDDEN_EDIT_MIMES = ['image/bmp', 'image/x-dcraw', 'video/MP2T'];
private const EXIFTOOL_VER = '12.58';
2022-11-24 11:18:35 +00:00
private const EXIFTOOL_TIMEOUT = 30000;
2023-02-24 17:28:57 +00:00
private const EXIFTOOL_ARGS = ['-api', 'QuickTimeUTC=1', '-n', '-U', '-json', '--b'];
2022-10-20 21:05:01 +00:00
/** Opened instance of exiftool when running in command mode */
2022-10-19 17:10:36 +00:00
private static $staticProc;
private static $staticPipes;
private static $noStaticProc = false;
2022-10-19 17:10:36 +00:00
public static function closeStaticExiftoolProc()
{
try {
if (self::$staticProc) {
fclose(self::$staticPipes[0]);
fclose(self::$staticPipes[1]);
fclose(self::$staticPipes[2]);
proc_terminate(self::$staticProc);
2022-09-15 01:14:06 +00:00
self::$staticProc = null;
self::$staticPipes = null;
}
2022-10-19 17:10:36 +00:00
} catch (\Exception $ex) {
}
}
2022-10-19 17:10:36 +00:00
public static function restartStaticExiftoolProc()
{
2022-09-15 01:14:06 +00:00
self::closeStaticExiftoolProc();
self::ensureStaticExiftoolProc();
}
2022-10-19 17:10:36 +00:00
public static function ensureStaticExiftoolProc()
{
if (self::$noStaticProc) {
return;
}
if (!self::$staticProc) {
self::initializeStaticExiftoolProc();
2022-09-09 15:18:55 +00:00
usleep(500000); // wait if error
2022-10-19 17:10:36 +00:00
if (!proc_get_status(self::$staticProc)['running']) {
error_log('WARN: Failed to create stay_open exiftool process');
self::$noStaticProc = true;
self::$staticProc = null;
}
2022-10-19 17:10:36 +00:00
return;
}
2022-10-19 17:10:36 +00:00
if (!proc_get_status(self::$staticProc)['running']) {
self::$staticProc = null;
self::ensureStaticExiftoolProc();
}
}
2022-08-20 02:53:21 +00:00
/**
* Get the path to the user's configured photos directory.
*/
public static function getPhotosPath(IConfig $config, string &$userId)
2022-10-19 17:10:36 +00:00
{
2022-08-20 02:53:21 +00:00
$p = $config->getUserValue($userId, Application::APPNAME, 'timelinePath', '');
if (empty($p)) {
2022-09-13 17:39:38 +00:00
return 'Photos/';
2022-08-20 02:53:21 +00:00
}
2022-10-19 17:10:36 +00:00
2022-09-13 17:39:38 +00:00
return self::sanitizePath($p);
}
/**
* Sanitize a path to keep only ASCII characters and special characters.
*/
2022-10-19 17:10:36 +00:00
public static function sanitizePath(string $path)
{
return mb_ereg_replace('([^\\w\\s\\d\\-_~,;:!@#$&*{}\[\]\'\\[\\]\\(\\).\\\/])', '', $path);
2022-09-13 17:39:38 +00:00
}
/**
2022-10-19 17:10:36 +00:00
* Keep only one slash if multiple repeating.
2022-09-13 17:39:38 +00:00
*/
2022-10-19 17:10:36 +00:00
public static function removeExtraSlash(string $path)
{
return mb_ereg_replace('\/\/+', '/', $path);
2022-09-13 17:39:38 +00:00
}
/**
2022-10-19 17:10:36 +00:00
* Remove any leading slash present on the path.
2022-09-13 17:39:38 +00:00
*/
2022-10-19 17:10:36 +00:00
public static function removeLeadingSlash(string $path)
{
2022-09-13 23:30:01 +00:00
return mb_ereg_replace('~^/+~', '', $path);
2022-08-20 02:53:21 +00:00
}
/**
* Get exif data as a JSON object from a Nextcloud file.
*/
2022-10-19 17:10:36 +00:00
public static function getExifFromFile(File &$file)
{
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
if (!\is_string($path)) {
throw new \Exception('Failed to get local file path');
2022-08-20 02:53:21 +00:00
}
$exif = self::getExifFromLocalPath($path);
// We need to remove blacklisted fields to prevent leaking info
unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions'], $exif['ThumbnailImage']);
// Ignore zero dates
$dateFields = [
'DateTimeOriginal',
'SubSecDateTimeOriginal',
'CreateDate',
'ModifyDate',
'TrackCreateDate',
'TrackModifyDate',
'MediaCreateDate',
'MediaModifyDate',
];
foreach ($dateFields as $field) {
if (\array_key_exists($field, $exif) && \is_string($exif[$field]) && str_starts_with($exif[$field], '0000:00:00')) {
unset($exif[$field]);
}
2023-02-24 05:19:09 +00:00
}
// Ignore zero lat lng
if (\array_key_exists('GPSLatitude', $exif) && abs((float) $exif['GPSLatitude']) < 0.0001
&& \array_key_exists('GPSLongitude', $exif) && abs((float) $exif['GPSLongitude']) < 0.0001) {
unset($exif['GPSLatitude'], $exif['GPSLongitude']);
}
return $exif;
2022-08-20 02:53:21 +00:00
}
/** Get exif data as a JSON object from a local file path */
2022-10-19 17:10:36 +00:00
public static function getExifFromLocalPath(string &$path)
{
if (null !== self::$staticProc) {
2022-08-26 00:37:40 +00:00
self::ensureStaticExiftoolProc();
2022-09-15 01:14:06 +00:00
2022-10-19 17:10:36 +00:00
return self::getExifFromLocalPathWithStaticProc($path);
2022-09-15 01:14:06 +00:00
}
2022-10-19 17:10:36 +00:00
return self::getExifFromLocalPathWithSeparateProc($path);
}
2022-09-25 13:21:40 +00:00
/**
2022-10-19 17:10:36 +00:00
* Parse date from exif format and throw error if invalid.
2022-09-25 13:21:40 +00:00
*/
public static function parseExifDate(array $exif): \DateTime
2022-10-19 17:10:36 +00:00
{
// Get date from exif
$exifDate = $exif['SubSecDateTimeOriginal'] ?? $exif['DateTimeOriginal'] ?? $exif['CreateDate'] ?? null;
if (null === $exifDate || empty($exifDate) || !\is_string($exifDate)) {
throw new \Exception('No date found in exif');
}
// Get timezone from exif
try {
$exifTz = $exif['OffsetTimeOriginal'] ?? $exif['OffsetTime'] ?? $exif['LocationTZID'] ?? null;
$exifTz = new \DateTimeZone($exifTz);
} catch (\Error $e) {
$exifTz = null;
}
// Force UTC if no timezone found
$parseTz = $exifTz ?? new \DateTimeZone('UTC');
// https://github.com/pulsejet/memories/pull/397
// https://github.com/pulsejet/memories/issues/485
$formats = [
'Y:m:d H:i', // 2023:03:05 18:58
'Y:m:d H:iO', // 2023:03:05 18:58+05:00
'Y:m:d H:i:s', // 2023:03:05 18:58:17
'Y:m:d H:i:sO', // 2023:03:05 10:58:17+05:00
'Y:m:d H:i:s.u', // 2023:03:05 10:58:17.000
'Y:m:d H:i:s.uO', // 2023:03:05 10:58:17.000Z
];
/** @var \DateTime $dt */
$parsedDate = null;
foreach ($formats as $format) {
if ($parsedDate = \DateTime::createFromFormat($format, $exifDate, $parseTz)) {
break;
2022-09-25 13:21:40 +00:00
}
}
2022-10-19 17:10:36 +00:00
// If we couldn't parse the date, throw an error
if (!$parsedDate) {
throw new \Exception("Invalid date: {$exifDate}");
2022-09-25 13:21:40 +00:00
}
// Filter out dates before 1800 A.D.
if ($parsedDate->getTimestamp() < -5364662400) { // 1800 A.D.
throw new \Exception("Date too old: {$exifDate}");
2022-09-27 21:05:26 +00:00
}
2022-10-19 17:10:36 +00:00
// Force the timezone to be the same as parseTz
if ($exifTz) {
$parsedDate->setTimezone($exifTz);
}
return $parsedDate;
2022-09-27 21:05:26 +00:00
}
2022-08-20 02:53:21 +00:00
/**
* Get the date taken from either the file or exif data if available.
*/
public static function getDateTaken(File $file, array $exif): \DateTime
2022-10-19 17:10:36 +00:00
{
2022-09-25 13:21:40 +00:00
try {
return self::parseExifDate($exif);
2022-10-11 19:57:55 +00:00
} catch (\Exception $ex) {
} catch (\ValueError $ex) {
}
2022-08-20 02:53:21 +00:00
// Fall back to modification time
try {
$parseTz = new \DateTimeZone(getenv('TZ')); // debian
} catch (\Error $e) {
$parseTz = new \DateTimeZone('UTC');
}
$dt = new \DateTime('@'.$file->getMtime(), $parseTz);
$dt->setTimezone($parseTz);
return self::forgetTimezone($dt);
}
/**
* Convert time to local date in UTC.
*/
public static function forgetTimezone(\DateTime $date): \DateTime
{
return new \DateTime($date->format('Y-m-d H:i:s'), new \DateTimeZone('UTC'));
2022-08-20 02:53:21 +00:00
}
2022-09-25 13:21:40 +00:00
2022-10-15 19:15:07 +00:00
/**
2022-10-19 17:10:36 +00:00
* Get image dimensions from Exif data.
*
2022-10-15 19:15:07 +00:00
* @return array [width, height]
*/
2022-10-19 17:10:36 +00:00
public static function getDimensions(array &$exif)
{
2022-10-15 19:15:07 +00:00
$width = $exif['ImageWidth'] ?? 0;
$height = $exif['ImageHeight'] ?? 0;
2022-10-16 05:23:07 +00:00
// Check if image is rotated and we need to swap width and height
$rotation = $exif['Rotation'] ?? 0;
$orientation = $exif['Orientation'] ?? 0;
2022-10-19 17:10:36 +00:00
if (\in_array($orientation, [5, 6, 7, 8], true) || \in_array($rotation, [90, 270], true)) {
2022-10-16 05:23:07 +00:00
return [$height, $width];
}
2022-12-03 07:50:33 +00:00
if ($width <= 0 || $height <= 0 || $width > 100000 || $height > 100000) {
2022-10-28 16:48:08 +00:00
return [0, 0];
}
2022-10-15 19:15:07 +00:00
return [$width, $height];
}
/**
* Get the list of MIME Types that are allowed to be edited.
*/
public static function allowedEditMimetypes(): array
{
return array_diff(array_merge(Application::IMAGE_MIMES, Application::VIDEO_MIMES), self::FORBIDDEN_EDIT_MIMES);
}
2022-09-25 13:21:40 +00:00
/**
2022-11-10 06:19:44 +00:00
* Set exif data using raw json.
*
* @param string $path to local file
* @param array $data exif data
2022-09-25 13:21:40 +00:00
*
2022-11-10 06:19:44 +00:00
* @throws \Exception on failure
2022-09-25 13:21:40 +00:00
*/
public static function setExif(string $path, array $data)
2022-10-19 17:10:36 +00:00
{
2022-11-10 06:19:44 +00:00
$data['SourceFile'] = $path;
$raw = json_encode([$data]);
$cmd = array_merge(self::getExiftool(), ['-json=-', $path]);
$proc = proc_open($cmd, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
2022-09-25 13:21:40 +00:00
2022-11-10 06:19:44 +00:00
fwrite($pipes[0], $raw);
fclose($pipes[0]);
2022-10-19 17:10:36 +00:00
2022-11-24 11:18:35 +00:00
$stdout = self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
2022-11-10 06:19:44 +00:00
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
if (false !== strpos($stdout, 'error')) {
error_log("Exiftool error: {$stdout}");
2022-10-19 17:10:36 +00:00
2022-11-10 06:19:44 +00:00
throw new \Exception('Could not set exif data: '.$stdout);
2022-09-25 13:21:40 +00:00
}
}
2022-10-19 17:10:36 +00:00
public static function setFileExif(File $file, array $data)
{
// Get path to local file so we can skip reading
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
// Set exif data
self::setExif($path, $data);
// Update remote file if not local
if (!$file->getStorage()->isLocal()) {
$file->putContent(fopen($path, 'r')); // closes the handler
}
// Touch the file, triggering a reprocess through the hook
$file->touch();
}
public static function getBinaryExifProp(string $path, string $prop)
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), [$prop, '-n', '-b', $path]), [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
2022-11-24 11:18:35 +00:00
return self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
throw new \Exception('Could not read from Exiftool');
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
}
public static function getExifWithDuplicates(string $path)
{
return self::getExifFromLocalPathWithSeparateProc($path, ['-G4']);
}
2022-10-20 19:31:12 +00:00
/** Get path to exiftool binary */
private static function getExiftool()
{
2022-10-22 17:45:20 +00:00
$configKey = 'memories.exiftool';
$config = \OC::$server->get(IConfig::class);
2022-10-20 19:31:12 +00:00
$configPath = $config->getSystemValue($configKey);
$noLocal = $config->getSystemValue($configKey.'_no_local', false);
// We know already where it is
2022-10-20 21:05:01 +00:00
if (!empty($configPath) && file_exists($configPath)) {
2022-10-30 04:23:20 +00:00
if (!is_executable($configPath)) {
chmod($configPath, 0755);
}
2022-10-22 22:07:08 +00:00
2022-10-30 04:23:20 +00:00
return explode(' ', $configPath);
2022-10-20 20:41:34 +00:00
}
2022-10-20 19:31:12 +00:00
// Detect architecture
2022-11-09 09:23:12 +00:00
$arch = $noLocal ? null : \OCA\Memories\Util::getArch();
$libc = $noLocal ? null : \OCA\Memories\Util::getLibc();
2022-10-20 19:31:12 +00:00
// Get static binary if available
if ($arch && $libc && !$noLocal) {
// get target file path
2022-11-09 09:23:12 +00:00
$path = realpath(__DIR__."/../exiftool-bin/exiftool-{$arch}-{$libc}");
2022-10-20 19:31:12 +00:00
// check if file exists
if (file_exists($path)) {
2022-10-22 21:47:49 +00:00
// make executable before version check
2022-10-30 04:23:20 +00:00
if (!is_executable($path)) {
chmod($path, 0755);
}
2022-10-22 21:47:49 +00:00
2022-10-20 19:31:12 +00:00
// check if the version prints correctly
$ver = self::EXIFTOOL_VER;
2022-10-20 21:05:01 +00:00
$vero = shell_exec("{$path} -ver");
2022-10-20 19:31:12 +00:00
if ($vero && false !== stripos(trim($vero), $ver)) {
$out = trim($vero);
2022-10-20 21:05:01 +00:00
echo "Exiftool binary version check passed {$out} <==> {$ver}\n";
2022-10-20 19:31:12 +00:00
$config->setSystemValue($configKey, $path);
2022-10-20 21:05:01 +00:00
2022-10-30 04:23:20 +00:00
return [$path];
2022-10-20 19:31:12 +00:00
}
2022-10-20 21:05:01 +00:00
error_log("Exiftool version check failed {$vero} <==> {$ver}");
$config->setSystemValue($configKey.'_no_local', true);
2022-10-20 19:31:12 +00:00
} else {
2022-10-20 21:05:01 +00:00
error_log("Exiftool not found: {$path}");
2022-10-20 19:31:12 +00:00
}
}
2022-10-20 20:41:34 +00:00
// Fallback to perl script
2022-10-20 21:05:01 +00:00
$path = __DIR__.'/../exiftool-bin/exiftool/exiftool';
2022-10-20 20:41:34 +00:00
if (file_exists($path)) {
2022-10-30 04:23:20 +00:00
return ['perl', $path];
2022-10-20 20:41:34 +00:00
}
2022-10-20 21:05:01 +00:00
error_log("Exiftool not found: {$path}");
2022-10-20 20:41:34 +00:00
2022-10-20 19:31:12 +00:00
// Fallback to system binary
2022-10-30 04:23:20 +00:00
return ['exiftool'];
2022-10-20 19:31:12 +00:00
}
2022-10-19 17:10:36 +00:00
/** Initialize static exiftool process for local reads */
private static function initializeStaticExiftoolProc()
{
self::closeStaticExiftoolProc();
2022-10-30 04:23:20 +00:00
self::$staticProc = proc_open(array_merge(self::getExiftool(), ['-stay_open', 'true', '-@', '-']), [
2022-10-19 17:10:36 +00:00
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], self::$staticPipes);
stream_set_blocking(self::$staticPipes[1], false);
}
/**
* Read from non blocking handle or throw timeout.
*
* @param resource $handle
* @param int $timeout milliseconds
* @param string $delimiter null for eof
*/
2022-11-24 02:28:34 +00:00
private static function readOrTimeout($handle, int $timeout, ?string $delimiter = null)
2022-10-19 17:10:36 +00:00
{
$buf = '';
$waitedMs = 0;
while ($waitedMs < $timeout && ($delimiter ? !str_ends_with($buf, $delimiter) : !feof($handle))) {
$r = stream_get_contents($handle);
if (empty($r)) {
++$waitedMs;
usleep(1000);
continue;
}
$buf .= $r;
}
if ($waitedMs >= $timeout) {
throw new \Exception('Timeout');
}
return $buf;
}
private static function getExifFromLocalPathWithStaticProc(string &$path)
{
2023-02-24 17:28:57 +00:00
$args = implode("\n", self::EXIFTOOL_ARGS);
fwrite(self::$staticPipes[0], "{$path}\n{$args}\n-execute\n");
2022-10-19 17:10:36 +00:00
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
try {
2022-11-24 11:18:35 +00:00
$buf = self::readOrTimeout(self::$staticPipes[1], self::EXIFTOOL_TIMEOUT, $readyToken);
2022-10-19 17:10:36 +00:00
$tokPos = strrpos($buf, $readyToken);
$buf = substr($buf, 0, $tokPos);
return self::processStdout($buf);
} catch (\Exception $ex) {
error_log("ERROR: Exiftool may have crashed, restarting process [{$path}]");
self::restartStaticExiftoolProc();
throw new \Exception('Nothing to read from Exiftool');
}
}
private static function getExifFromLocalPathWithSeparateProc(string &$path, array $extraArgs = [])
2022-10-19 17:10:36 +00:00
{
$pipes = [];
2023-02-24 17:28:57 +00:00
$proc = proc_open(array_merge(self::getExiftool(), self::EXIFTOOL_ARGS, $extraArgs, [$path]), [
2022-10-19 17:10:36 +00:00
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
2022-11-24 11:18:35 +00:00
$stdout = self::readOrTimeout($pipes[1], self::EXIFTOOL_TIMEOUT);
2022-10-19 17:10:36 +00:00
return self::processStdout($stdout);
} catch (\Exception $ex) {
error_log("Exiftool timeout: [{$path}]");
throw new \Exception('Could not read from Exiftool');
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
proc_terminate($proc);
}
}
/** Get json array from stdout of exiftool */
private static function processStdout(string &$stdout)
{
$json = json_decode($stdout, true);
if (!$json) {
throw new \Exception('Could not read exif data');
}
return $json[0];
}
}