memories/lib/Controller/ImageController.php

493 lines
16 KiB
PHP

<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Varun Patil <radialapps@gmail.com>
* @author Varun Patil <radialapps@gmail.com>
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace OCA\Memories\Controller;
use OCA\Memories\AppInfo\Application;
use OCA\Memories\Exceptions;
use OCA\Memories\Exif;
use OCA\Memories\Service;
use OCA\Memories\Util;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Files\IRootFolder;
class ImageController extends GenericApiController
{
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* @PublicPage
*
* Get preview of image
*/
public function preview(
int $id,
int $x = 32,
int $y = 32,
bool $a = false,
string $mode = 'fill',
): Http\Response {
return Util::guardEx(function () use ($id, $x, $y, $a, $mode) {
if (-1 === $id || 0 === $x || 0 === $y) {
throw Exceptions::MissingParameter('id, x, y');
}
// Get preview for this file
$file = $this->fs->getUserFile($id);
$preview = \OC::$server->get(\OCP\IPreview::class)->getPreview($file, $x, $y, !$a, $mode);
// Get the filename. We need to move the extension from
// the preview file to the filename's end if it's not there
// Do the comparison case-insensitive
$filename = $file->getName();
if ($ext = pathinfo($preview->getName(), PATHINFO_EXTENSION)) {
if (!str_ends_with(strtolower($filename), strtolower('.'.$ext))) {
$filename .= '.'.$ext;
}
}
// Generate response with proper content-disposition
$response = new Http\DataDownloadResponse($preview->getContent(), $filename, $preview->getMimeType());
$response->cacheFor(3600 * 24, false, true);
return $response;
});
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* @PublicPage
*
* Get preview of many images
*/
public function multipreview(array $files): Http\Response
{
return Util::guardExDirect(function (Http\IOutput $out) use ($files) {
// Filter files with valid parameters
$files = array_filter($files, static function (array $file) {
return isset($file['reqid'], $file['fileid'], $file['x'], $file['y'], $file['a'])
&& (int) $file['fileid'] > 0
&& (int) $file['x'] > 0
&& (int) $file['y'] > 0;
});
// Sort files by size, ascending
usort($files, static function (array $a, array $b) {
$aArea = (int) $a['x'] * (int) $a['y'];
$bArea = (int) $b['x'] * (int) $b['y'];
return $aArea <=> $bArea;
});
/** @var \OCP\IPreview */
$previewManager = \OC::$server->get(\OCP\IPreview::class);
// For checking max previews
$previewRoot = new \OC\Preview\Storage\Root(
\OC::$server->get(IRootFolder::class),
\OC::$server->get(\OC\SystemConfig::class),
);
// stream the response
$out->setHeader('Content-Type: application/octet-stream');
foreach ($files as $bodyFile) {
$reqid = $bodyFile['reqid'];
$fileid = (int) $bodyFile['fileid'];
$x = (int) $bodyFile['x'];
$y = (int) $bodyFile['y'];
$a = '1' === $bodyFile['a'];
try {
// Make sure max preview exists
$file = $this->fs->getUserFile($fileid);
$fileId = (string) $file->getId();
$folder = $previewRoot->getFolder($fileId);
$hasMax = false;
foreach ($folder->getDirectoryListing() as $preview) {
$name = $preview->getName();
if (str_contains($name, '-max')) {
$hasMax = true;
break;
}
}
if (!$hasMax) {
continue;
}
// Add this preview to the response
$preview = $previewManager->getPreview($file, $x, $y, !$a, \OCP\IPreview::MODE_FILL);
$content = $preview->getContent();
if (empty($content)) {
continue;
}
ob_start();
// Encode parameters
$json = json_encode([
'reqid' => $reqid,
'len' => \strlen($content),
'type' => $preview->getMimeType(),
]);
// Send the length of the json as a single byte
$out->setOutput(\chr(\strlen($json)));
$out->setOutput($json);
// Send the image
$out->setOutput($content);
ob_end_flush();
} catch (\OCP\Files\NotFoundException $e) {
continue;
} catch (\Exception $e) {
continue;
}
}
});
}
/**
* @NoAdminRequired
*
* @PublicPage
*
* Get EXIF info for an image with file id
*
* @param string fileid
*/
public function info(
int $id,
bool $basic = false,
bool $current = false,
bool $tags = false,
string $clusters = '',
): Http\Response {
return Util::guardEx(function () use ($id, $basic, $current, $tags, $clusters) {
$file = $this->fs->getUserFile($id);
// Get the image info
$info = $this->tq->getInfoById($id, $basic);
// Add fileid and etag
$info['fileid'] = $file->getId();
$info['etag'] = $file->getEtag();
// Inject permissions and convert to string
$info['permissions'] = Util::permissionsToStr($file->getPermissions());
// Inject other file parameters that are cheap to get now
$info['mimetype'] = $file->getMimeType();
$info['size'] = $file->getSize();
$info['basename'] = $file->getName();
// Allow these ony for logged in users
if ($user = $this->userSession->getUser()) {
// Get the path of the file relative to current user
// "/admin/files/Photos/Camera/20230821_135017.jpg" => "/Photos/..."
$parts = explode('/', $file->getPath());
if (\count($parts) > 3 && 'files' === $parts[2] && $parts[1] === $user->getUID()) {
$info['filename'] = '/'.implode('/', \array_slice($parts, 3));
}
// Get list of tags for this file
if ($tags) {
$info['tags'] = $this->getTags($id);
}
// Get latest exif data if requested
if ($current) {
$info['current'] = Exif::getExifFromFile($file);
}
// Get clusters for this file
if ($clusters) {
$clist = [];
foreach (explode(',', $clusters) as $type) {
$backend = \OC::$server->get(\OCA\Memories\ClustersBackend\Manager::class)->get($type);
if ($backend->isEnabled()) {
$clist[$type] = $backend->getClusters($id);
}
}
$info['clusters'] = $clist;
}
}
return new JSONResponse($info, Http::STATUS_OK);
});
}
/**
* @NoAdminRequired
*
* Set the exif data for a file.
*/
public function setExif(int $id, array $raw): Http\Response
{
return Util::guardEx(function () use ($id, $raw) {
$file = $this->fs->getUserFile($id);
// Check if user has permissions
if (!$file->isUpdateable() || Util::isEncryptionEnabled()) {
throw Exceptions::ForbiddenFileUpdate($file->getName());
}
// Check if allowed to edit file
$mime = $file->getMimeType();
if (!\in_array($mime, Exif::allowedEditMimetypes(), true)) {
$name = $file->getName();
throw Exceptions::Forbidden("Cannot edit file {$name} (blacklisted type {$mime})");
}
// Set the exif data
Exif::setFileExif($file, $raw);
// If rotation changed then update the previews
if ($raw['Orientation'] ?? false) {
$this->deletePreviews($file);
}
return $this->info($id, true);
});
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*
* @PublicPage
*
* Get a full resolution decodable image for editing from a file.
* The returned image may be png / webp / jpeg / gif.
* These formats are supported by all browsers.
*/
public function decodable(string $id): Http\Response
{
return Util::guardEx(function () use ($id) {
$file = $this->fs->getUserFile((int) $id);
// Check if valid image
$mimetype = $file->getMimeType();
if (!\in_array($mimetype, Application::IMAGE_MIMES, true)) {
throw Exceptions::Forbidden('Not an image');
}
/** @var string Blob of image */
$blob = $file->getContent();
/** @var string Name of file */
$name = $file->getName();
// Convert image to JPEG if required
if (!\in_array($mimetype, ['image/png', 'image/webp', 'image/jpeg', 'image/gif'], true)) {
[$blob, $mimetype] = $this->getImageJPEG($blob, $mimetype);
$name .= '.jpg';
}
// Return the image
$response = new Http\DataDownloadResponse($blob, $name, $mimetype);
$response->cacheFor(3600 * 24, false, false);
return $response;
});
}
/**
* @NoAdminRequired
*/
public function editImage(
int $id,
string $name,
int $width,
int $height,
?float $quality,
string $extension,
array $state,
): Http\Response {
return Util::guardEx(function () use ($id, $name, $width, $height, $quality, $extension, $state) {
// Get the file
$file = $this->fs->getUserFile($id);
// Check if creating a copy
$copy = $name !== $file->getName();
// Check if user has permissions to do this
if (!$file->isUpdateable() || ($copy && !$file->getParent()->isCreatable())) {
throw Exceptions::ForbiddenFileUpdate($file->getName());
}
// Check if target copy file exists
if ($copy && $file->getParent()->nodeExists($name)) {
throw Exceptions::ForbiddenFileUpdate($name);
}
// Check if we have imagick
if (!class_exists('Imagick')) {
throw Exceptions::Forbidden('Imagick extension is not available');
}
// Read the image
$image = new \Imagick();
$image->readImageBlob($file->getContent());
// Due to a bug in filerobot, the provided width and height may be swapped
// 1. If the user does not rotate the image, we're fine
// 2. If image is rotated and user doesn't change the save resolution,
// the wxh corresponds to the original image, not the rotated one
// 3. If image is rotated and user changes the save resolution,
// the wxh corresponds to the rotated image.
$iw = $image->getImageWidth();
$ih = $image->getImageHeight();
$shouldResize = $width !== $iw || $height !== $ih;
// Apply the edits
(new Service\FileRobotMagick($image, $state))->apply();
// Resize the image
$iw = $image->getImageWidth();
$ih = $image->getImageHeight();
if ($shouldResize && $width && $height && ($iw !== $width || $ih !== $height)) {
$image->resizeImage($width, $height, \Imagick::FILTER_LANCZOS, 1, true);
}
// Set image format
$image->setImageFormat($extension);
// Set quality if specified
if (null !== $quality && $quality >= 0 && $quality <= 1) {
$image->setImageCompressionQuality((int) round(100 * $quality));
}
// Save the image
$blob = $image->getImageBlob();
// Save the file
if ($copy) {
$file = $file->getParent()->newFile($name, $blob);
} else {
$file->putContent($blob);
}
// Make sure the preview is updated
\OC::$server->get(\OCP\IPreview::class)->getPreview($file);
return $this->info($file->getId(), true);
});
}
/**
* Given a blob of image data, return a JPEG blob.
*
* @param string $blob Blob of image data in any format
* @param string $mimetype Mimetype of image data
*
* @return string[] [blob, mimetype]
*
* @psalm-return list{string, string}
*/
private function getImageJPEG($blob, $mimetype): array
{
// TODO: Use imaginary if available
// Check if Imagick is available
if (!class_exists('Imagick')) {
throw Exceptions::Forbidden('Imagick extension is not available');
}
// Read original image
try {
$image = new \Imagick();
$image->readImageBlob($blob);
} catch (\ImagickException $e) {
throw Exceptions::Forbidden('Imagick failed to read image: '.$e->getMessage());
}
// Convert to JPEG
try {
$image->autoOrient();
$image->setImageFormat('jpeg');
$image->setImageCompressionQuality(85);
$blob = $image->getImageBlob();
$mimetype = $image->getImageMimeType();
} catch (\ImagickException $e) {
throw Exceptions::Forbidden('Imagick failed to convert image: '.$e->getMessage());
} finally {
$image->clear();
}
return [$blob, $mimetype];
}
/**
* Get the tags for a file.
*
* @return string[]
*/
private function getTags(int $fileId): array
{
// Make sure tags are enabled
if (!Util::tagsIsEnabled()) {
return [];
}
// Get the tag ids for this file
$objectMapper = \OC::$server->get(\OCP\SystemTag\ISystemTagObjectMapper::class);
$tagIds = $objectMapper->getTagIdsForObjects([$fileId], 'files')[(string) $fileId];
// Get all matching tag objects
$tags = \OC::$server->get(\OCP\SystemTag\ISystemTagManager::class)->getTagsByIds($tagIds);
// Filter out the tags that are not user visible
$visible = array_filter($tags, static fn ($t) => $t->isUserVisible());
// Get the tag names
return array_map(static fn ($t) => $t->getName(), $visible);
}
/**
* Invalidate previews for a file.
*/
private function deletePreviews(\OCP\Files\File $file): void
{
try {
$previewRoot = new \OC\Preview\Storage\Root(
\OC::$server->get(IRootFolder::class),
\OC::$server->get(\OC\SystemConfig::class),
);
$fileId = (string) $file->getId();
$previewRoot->getFolder($fileId)->delete();
} catch (\Exception $e) {
return;
}
}
}