memories/lib/Exif.php

425 lines
13 KiB
PHP
Raw Permalink 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
{
2022-10-20 21:05:01 +00:00
private const EXIFTOOL_VER = '12.49';
/** 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.
*/
2022-10-19 17:10:36 +00:00
public static function getPhotosPath(IConfig &$config, string &$userId)
{
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']);
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
*
* @param string $dt
2022-10-19 17:10:36 +00:00
* @param mixed $date
*
2022-09-25 13:21:40 +00:00
* @return int unix timestamp
*/
2022-10-19 17:10:36 +00:00
public static function parseExifDate($date)
{
2022-09-25 13:21:40 +00:00
$dt = $date;
2022-10-19 17:10:36 +00:00
if (isset($dt) && \is_string($dt) && !empty($dt)) {
2022-09-25 13:21:40 +00:00
$dt = explode('-', explode('+', $dt, 2)[0], 2)[0]; // get rid of timezone if present
$dt = \DateTime::createFromFormat('Y:m:d H:i:s', $dt);
if (!$dt) {
2022-10-19 17:10:36 +00:00
throw new \Exception("Invalid date: {$date}");
2022-09-25 13:21:40 +00:00
}
if ($dt && $dt->getTimestamp() > -5364662400) { // 1800 A.D.
return $dt->getTimestamp();
}
2022-10-19 17:10:36 +00:00
throw new \Exception("Date too old: {$date}");
2022-09-25 13:21:40 +00:00
} else {
2022-10-19 17:10:36 +00:00
throw new \Exception('No date provided');
2022-09-25 13:21:40 +00:00
}
}
2022-09-27 21:05:26 +00:00
/**
* Forget the timezone for an epoch timestamp and get the same
* time epoch for UTC.
*
* @param int $epoch
*/
2022-10-19 17:10:36 +00:00
public static function forgetTimezone($epoch)
{
2022-09-27 21:05:26 +00:00
$dt = new \DateTime();
$dt->setTimestamp($epoch);
$tz = getenv('TZ'); // at least works on debian ...
if ($tz) {
$dt->setTimezone(new \DateTimeZone($tz));
}
$utc = new \DateTime($dt->format('Y-m-d H:i:s'), new \DateTimeZone('UTC'));
2022-10-19 17:10:36 +00:00
2022-09-27 21:05:26 +00:00
return $utc->getTimestamp();
}
2022-08-20 02:53:21 +00:00
/**
* Get the date taken from either the file or exif data if available.
2022-10-19 17:10:36 +00:00
*
2022-09-27 21:05:26 +00:00
* @return int unix timestamp
2022-08-20 02:53:21 +00:00
*/
2022-10-19 17:10:36 +00:00
public static function getDateTaken(File &$file, array &$exif)
{
2022-09-09 15:42:44 +00:00
$dt = $exif['DateTimeOriginal'] ?? null;
2022-08-20 02:53:21 +00:00
if (!isset($dt) || empty($dt)) {
2022-09-09 15:42:44 +00:00
$dt = $exif['CreateDate'] ?? null;
2022-08-20 02:53:21 +00:00
}
// Check if found something
2022-09-25 13:21:40 +00:00
try {
return self::parseExifDate($dt);
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 creation time
$dateTaken = $file->getCreationTime();
// Fall back to modification time
2022-10-19 17:10:36 +00:00
if (0 === $dateTaken) {
2022-08-20 02:53:21 +00:00
$dateTaken = $file->getMtime();
}
2022-10-19 17:10:36 +00:00
2022-09-27 21:05:26 +00:00
return self::forgetTimezone($dateTaken);
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-10-28 16:48:08 +00:00
if ($width <= 0 || $height <= 0 || $width > 10000 || $height > 10000) {
return [0, 0];
}
2022-10-15 19:15:07 +00:00
return [$width, $height];
}
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
*/
2022-11-10 06:19:44 +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-10 06:19:44 +00:00
$stdout = self::readOrTimeout($pipes[1], 30000);
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
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';
2022-10-20 19:31:12 +00:00
$config = \OC::$server->getConfig();
$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
*/
private static function readOrTimeout($handle, $timeout, $delimiter = null)
{
$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)
{
fwrite(self::$staticPipes[0], "{$path}\n-json\n-b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
2022-10-19 17:10:36 +00:00
fflush(self::$staticPipes[0]);
$readyToken = "\n{ready}\n";
try {
$buf = self::readOrTimeout(self::$staticPipes[1], 5000, $readyToken);
$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)
{
$pipes = [];
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', '-b', $path]), [
2022-10-19 17:10:36 +00:00
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
stream_set_blocking($pipes[1], false);
try {
$stdout = self::readOrTimeout($pipes[1], 5000);
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];
}
}