diff --git a/appinfo/routes.php b/appinfo/routes.php index 0fcc7698..eef548ec 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,30 +25,31 @@ return [ ], // Public pages - ['name' => 'page#sharedFolder', 'url' => '/s/{token}', 'verb' => 'GET'], + ['name' => 'page#sharedfolder', 'url' => '/s/{token}', 'verb' => 'GET'], - // API - ['name' => 'api#days', 'url' => '/api/days', 'verb' => 'GET'], - ['name' => 'api#dayPost', 'url' => '/api/days', 'verb' => 'POST'], - ['name' => 'api#day', 'url' => '/api/days/{id}', 'verb' => 'GET'], + // API Routes - ['name' => 'api#tags', 'url' => '/api/tags', 'verb' => 'GET'], - ['name' => 'api#tagPreviews', 'url' => '/api/tag-previews', 'verb' => 'GET'], + ['name' => 'days#days', 'url' => '/api/days', 'verb' => 'GET'], + ['name' => 'days#dayPost', 'url' => '/api/days', 'verb' => 'POST'], + ['name' => 'days#day', 'url' => '/api/days/{id}', 'verb' => 'GET'], - ['name' => 'api#albums', 'url' => '/api/albums', 'verb' => 'GET'], + ['name' => 'tags#tags', 'url' => '/api/tags', 'verb' => 'GET'], + ['name' => 'tags#previews', 'url' => '/api/tag-previews', 'verb' => 'GET'], - ['name' => 'api#faces', 'url' => '/api/faces', 'verb' => 'GET'], - ['name' => 'api#facePreview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'], + ['name' => 'albums#albums', 'url' => '/api/albums', 'verb' => 'GET'], - ['name' => 'api#imageInfo', 'url' => '/api/info/{id}', 'verb' => 'GET'], - ['name' => 'api#imageEdit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'], + ['name' => 'faces#faces', 'url' => '/api/faces', 'verb' => 'GET'], + ['name' => 'faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'], - ['name' => 'api#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], + ['name' => 'image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'], + ['name' => 'image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'], + + ['name' => 'archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], // Config API - ['name' => 'api#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], + ['name' => 'other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'], // Service worker - ['name' => 'api#serviceWorker', 'url' => '/service-worker.js', 'verb' => 'GET'], + ['name' => 'other#serviceWorker', 'url' => '/service-worker.js', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/AlbumsController.php b/lib/Controller/AlbumsController.php new file mode 100644 index 00000000..2172c8ca --- /dev/null +++ b/lib/Controller/AlbumsController.php @@ -0,0 +1,60 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; + +class AlbumsController extends ApiBase +{ + /** + * @NoAdminRequired + * + * Get list of albums with counts of images + */ + public function albums(): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check tags enabled for this user + if (!$this->albumsIsEnabled()) { + return new JSONResponse(['message' => 'Albums not enabled for user'], Http::STATUS_PRECONDITION_FAILED); + } + + // Run actual query + $list = []; + $t = (int) $this->request->getParam('t'); + if ($t & 1) { // personal + $list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID())); + } + if ($t & 2) { // shared + $list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID(), true)); + } + + return new JSONResponse($list, Http::STATUS_OK); + } +} diff --git a/lib/Controller/ApiBase.php b/lib/Controller/ApiBase.php new file mode 100644 index 00000000..043fb398 --- /dev/null +++ b/lib/Controller/ApiBase.php @@ -0,0 +1,164 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCA\Memories\AppInfo\Application; +use OCA\Memories\Db\TimelineQuery; +use OCA\Memories\Db\TimelineWrite; +use OCA\Memories\Exif; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IPreview; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Share\IManager as IShareManager; + +class ApiBase extends Controller +{ + protected IConfig $config; + protected IUserSession $userSession; + protected IRootFolder $rootFolder; + protected IAppManager $appManager; + protected TimelineQuery $timelineQuery; + protected TimelineWrite $timelineWrite; + protected IShareManager $shareManager; + protected IPreview $previewManager; + + public function __construct( + IRequest $request, + IConfig $config, + IUserSession $userSession, + IDBConnection $connection, + IRootFolder $rootFolder, + IAppManager $appManager, + IShareManager $shareManager, + IPreview $preview + ) { + parent::__construct(Application::APPNAME, $request); + + $this->config = $config; + $this->userSession = $userSession; + $this->connection = $connection; + $this->rootFolder = $rootFolder; + $this->appManager = $appManager; + $this->shareManager = $shareManager; + $this->previewManager = $preview; + $this->timelineQuery = new TimelineQuery($connection); + $this->timelineWrite = new TimelineWrite($connection, $preview); + } + + /** Get logged in user's UID or throw HTTP error */ + protected function getUid(): string + { + $user = $this->userSession->getUser(); + if ($this->getShareToken()) { + $user = null; + } elseif (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + return $user ? $user->getUID() : ''; + } + + /** Get the Folder object relevant to the request */ + protected function getRequestFolder() + { + // Albums have no folder + if ($this->request->getParam('album')) { + return null; + } + + // Public shared folder + if ($token = $this->getShareToken()) { + $share = $this->shareManager->getShareByToken($token)->getNode(); // throws exception if not found + if (!$share instanceof Folder) { + throw new \Exception('Share not found or invalid'); + } + + return $share; + } + + // Anything else needs a user + $user = $this->userSession->getUser(); + if (null === $user) { + return null; + } + $uid = $user->getUID(); + + $folder = null; + $folderPath = $this->request->getParam('folder'); + $forcedTimelinePath = $this->request->getParam('timelinePath'); + $userFolder = $this->rootFolder->getUserFolder($uid); + + if (null !== $folderPath) { + $folder = $userFolder->get($folderPath); + } elseif (null !== $forcedTimelinePath) { + $folder = $userFolder->get($forcedTimelinePath); + } else { + $configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); + $folder = $userFolder->get($configPath); + } + + if (!$folder instanceof Folder) { + throw new \Exception('Folder not found'); + } + + return $folder; + } + + protected function getShareToken() + { + return $this->request->getParam('folder_share'); + } + + /** + * Check if albums are enabled for this user. + */ + protected function albumsIsEnabled(): bool + { + return \OCA\Memories\Util::albumsIsEnabled($this->appManager); + } + + /** + * Check if tags is enabled for this user. + */ + protected function tagsIsEnabled(): bool + { + return \OCA\Memories\Util::tagsIsEnabled($this->appManager); + } + + /** + * Check if recognize is enabled for this user. + */ + protected function recognizeIsEnabled(): bool + { + return \OCA\Memories\Util::recognizeIsEnabled($this->appManager); + } +} diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php deleted file mode 100644 index 1fdaba34..00000000 --- a/lib/Controller/ApiController.php +++ /dev/null @@ -1,901 +0,0 @@ - - * @author John Molakvoæ - * @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 . - */ - -namespace OCA\Memories\Controller; - -use OCA\Memories\AppInfo\Application; -use OCA\Memories\Db\TimelineQuery; -use OCA\Memories\Db\TimelineWrite; -use OCA\Memories\Exif; -use OCP\App\IAppManager; -use OCP\AppFramework\Controller; -use OCP\AppFramework\Http; -use OCP\AppFramework\Http\ContentSecurityPolicy; -use OCP\AppFramework\Http\DataDisplayResponse; -use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\JSONResponse; -use OCP\AppFramework\Http\StreamResponse; -use OCP\Files\FileInfo; -use OCP\Files\Folder; -use OCP\Files\IRootFolder; -use OCP\IConfig; -use OCP\IDBConnection; -use OCP\IPreview; -use OCP\IRequest; -use OCP\IUserSession; -use OCP\Share\IManager as IShareManager; - -class ApiController extends Controller -{ - private IConfig $config; - private IUserSession $userSession; - private IDBConnection $connection; - private IRootFolder $rootFolder; - private IAppManager $appManager; - private TimelineQuery $timelineQuery; - private TimelineWrite $timelineWrite; - private IShareManager $shareManager; - private IPreview $preview; - - public function __construct( - IRequest $request, - IConfig $config, - IUserSession $userSession, - IDBConnection $connection, - IRootFolder $rootFolder, - IAppManager $appManager, - IShareManager $shareManager, - IPreview $preview - ) { - parent::__construct(Application::APPNAME, $request); - - $this->config = $config; - $this->userSession = $userSession; - $this->connection = $connection; - $this->rootFolder = $rootFolder; - $this->appManager = $appManager; - $this->shareManager = $shareManager; - $this->previewManager = $preview; - $this->timelineQuery = new TimelineQuery($this->connection); - $this->timelineWrite = new TimelineWrite($connection, $preview); - } - - /** - * @NoAdminRequired - * - * @PublicPage - */ - public function days(): JSONResponse - { - // Get the folder to show - $uid = $this->getUid(); - - // Get the folder to show - $folder = null; - - try { - $folder = $this->getRequestFolder(); - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); - } - - // Params - $recursive = null === $this->request->getParam('folder'); - $archive = null !== $this->request->getParam('archive'); - - // Run actual query - try { - $list = $this->timelineQuery->getDays( - $folder, - $uid, - $recursive, - $archive, - $this->getTransformations(true), - ); - - // Preload some day responses - $this->preloadDays($list, $uid, $folder, $recursive, $archive); - - // Add subfolder info if querying non-recursively - if (!$recursive) { - array_unshift($list, $this->getSubfoldersEntry($folder)); - } - - return new JSONResponse($list, Http::STATUS_OK); - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - /** - * @NoAdminRequired - * - * @PublicPage - */ - public function dayPost(): JSONResponse - { - $id = $this->request->getParam('body_ids'); - if (null === $id) { - return new JSONResponse([], Http::STATUS_BAD_REQUEST); - } - - return $this->day($id); - } - - /** - * @NoAdminRequired - * - * @PublicPage - */ - public function day(string $id): JSONResponse - { - // Get user - $uid = $this->getUid(); - - // Check for wildcard - $day_ids = []; - if ('*' === $id) { - $day_ids = null; - } else { - // Split at commas and convert all parts to int - $day_ids = array_map(function ($part) { - return (int) $part; - }, explode(',', $id)); - } - - // Check if $day_ids is empty - if (null !== $day_ids && 0 === \count($day_ids)) { - return new JSONResponse([], Http::STATUS_OK); - } - - // Get the folder to show - $folder = null; - - try { - $folder = $this->getRequestFolder(); - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); - } - - // Params - $recursive = null === $this->request->getParam('folder'); - $archive = null !== $this->request->getParam('archive'); - - // Run actual query - try { - $list = $this->timelineQuery->getDay( - $folder, - $uid, - $day_ids, - $recursive, - $archive, - $this->getTransformations(false), - ); - - return new JSONResponse($list, Http::STATUS_OK); - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - /** - * Get subfolders entry for days response. - */ - public function getSubfoldersEntry(Folder &$folder) - { - // Ugly: get the view of the folder with reflection - // This is unfortunately the only way to get the contents of a folder - // matching a MIME type without using SEARCH, which is deep - $rp = new \ReflectionProperty('\OC\Files\Node\Node', 'view'); - $rp->setAccessible(true); - $view = $rp->getValue($folder); - - // Get the subfolders - $folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder); - - // Sort by name - usort($folders, function ($a, $b) { - return strnatcmp($a->getName(), $b->getName()); - }); - - // Process to response type - return [ - 'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS, - 'count' => \count($folders), - 'detail' => array_map(function ($node) { - return [ - 'fileid' => $node->getId(), - 'name' => $node->getName(), - 'isfolder' => 1, - 'path' => $node->getPath(), - ]; - }, $folders, []), - ]; - } - - /** - * @NoAdminRequired - * - * Get list of tags with counts of images - */ - public function tags(): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Check tags enabled for this user - if (!$this->tagsIsEnabled()) { - return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED); - } - - // If this isn't the timeline folder then things aren't going to work - $folder = $this->getRequestFolder(); - if (null === $folder) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - - // Run actual query - $list = $this->timelineQuery->getTags( - $folder, - ); - - return new JSONResponse($list, Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * Get previews for a tag - */ - public function tagPreviews(): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Check tags enabled for this user - if (!$this->tagsIsEnabled()) { - return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED); - } - - // If this isn't the timeline folder then things aren't going to work - $folder = $this->getRequestFolder(); - if (null === $folder) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - - // Get the tag - $tagName = $this->request->getParam('tag'); - - // Run actual query - $list = $this->timelineQuery->getTagPreviews( - $tagName, - $folder, - ); - - return new JSONResponse($list, Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * Get list of albums with counts of images - */ - public function albums(): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Check tags enabled for this user - if (!$this->albumsIsEnabled()) { - return new JSONResponse(['message' => 'Albums not enabled for user'], Http::STATUS_PRECONDITION_FAILED); - } - - // Run actual query - $list = []; - $t = (int) $this->request->getParam('t'); - if ($t & 1) { // personal - $list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID())); - } - if ($t & 2) { // shared - $list = array_merge($list, $this->timelineQuery->getAlbums($user->getUID(), true)); - } - - return new JSONResponse($list, Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * Get list of faces with counts of images - */ - public function faces(): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Check faces enabled for this user - if (!$this->recognizeIsEnabled()) { - return new JSONResponse(['message' => 'Recognize app not enabled or not v3+'], Http::STATUS_PRECONDITION_FAILED); - } - - // If this isn't the timeline folder then things aren't going to work - $folder = $this->getRequestFolder(); - if (null === $folder) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - - // Run actual query - $list = $this->timelineQuery->getFaces( - $folder, - ); - - return new JSONResponse($list, Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * @NoCSRFRequired - * - * Get face preview image cropped with imagick - * - * @return DataResponse - */ - public function facePreview(string $id): Http\Response - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Check faces enabled for this user - if (!$this->recognizeIsEnabled()) { - return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Get folder to search for - $folder = $this->getRequestFolder(); - if (null === $folder) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - - // Run actual query - $detections = $this->timelineQuery->getFacePreviewDetection($folder, (int) $id); - if (null === $detections || 0 === \count($detections)) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } - - // Find the first detection that has a preview - $preview = null; - foreach ($detections as &$detection) { - // Get the file (also checks permissions) - $files = $folder->getById($detection['file_id']); - if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) { - continue; - } - - // Get (hopefully cached) preview image - try { - $preview = $this->previewManager->getPreview($files[0], 2048, 2048, false); - } catch (\Exception $e) { - continue; - } - - // Got the preview - break; - } - - // Make sure the preview is valid - if (null === $preview) { - return new DataResponse([], Http::STATUS_NOT_FOUND); - } - - // Crop image - $image = new \Imagick(); - $image->readImageBlob($preview->getContent()); - $iw = $image->getImageWidth(); - $ih = $image->getImageHeight(); - $dw = (float) $detection['width']; - $dh = (float) $detection['height']; - $dcx = (float) $detection['x'] + (float) $detection['width'] / 2; - $dcy = (float) $detection['y'] + (float) $detection['height'] / 2; - $faceDim = max($dw * $iw, $dh * $ih) * 1.5; - $image->cropImage( - (int) $faceDim, - (int) $faceDim, - (int) ($dcx * $iw - $faceDim / 2), - (int) ($dcy * $ih - $faceDim / 2), - ); - $image->scaleImage(256, 256, true); - $blob = $image->getImageBlob(); - - // Create and send response - $response = new DataDisplayResponse($blob, Http::STATUS_OK, [ - 'Content-Type' => $image->getImageMimeType(), - ]); - $response->cacheFor(3600 * 24, false, false); - - return $response; - } - - /** - * @NoAdminRequired - * - * Get image info for one file - * - * @param string fileid - */ - public function imageInfo(string $id): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - - // Check for permissions and get numeric Id - $file = $userFolder->getById((int) $id); - if (0 === \count($file)) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - $file = $file[0]; - - // Get the image info - $info = $this->timelineQuery->getInfoById($file->getId()); - - return new JSONResponse($info, Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * Change exif data for one file - * - * @param string fileid - */ - public function imageEdit(string $id): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - - // Check for permissions and get numeric Id - $file = $userFolder->getById((int) $id); - if (0 === \count($file)) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); - } - $file = $file[0]; - - // Check if user has permissions - if (!$file->isUpdateable()) { - return new JSONResponse([], Http::STATUS_FORBIDDEN); - } - - // Get new date from body - $body = $this->request->getParams(); - if (!isset($body['date'])) { - return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST); - } - - // Make sure the date is valid - try { - Exif::parseExifDate($body['date']); - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); - } - - // Update date - try { - $res = Exif::updateExifDate($file, $body['date']); - if (false === $res) { - return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } catch (\Exception $e) { - return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - // Reprocess the file - $this->timelineWrite->processFile($file, true); - - return $this->imageInfo($id); - } - - /** - * @NoAdminRequired - * - * Move one file to the archive folder - * - * @param string fileid - */ - public function archive(string $id): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED); - } - $uid = $user->getUID(); - $userFolder = $this->rootFolder->getUserFolder($uid); - - // Check for permissions and get numeric Id - $file = $userFolder->getById((int) $id); - if (0 === \count($file)) { - return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND); - } - $file = $file[0]; - - // Check if user has permissions - if (!$file->isUpdateable()) { - return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN); - } - - // Create archive folder in the root of the user's configured timeline - $timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); - $timelineFolder = $userFolder->get($timelinePath); - if (null === $timelineFolder || !$timelineFolder instanceof Folder) { - return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR); - } - if (!$timelineFolder->isCreatable()) { - return new JSONResponse(['message' => 'Cannot create archive folder'], Http::STATUS_FORBIDDEN); - } - - // Get path of current file relative to the timeline folder - // remove timelineFolder path from start of file path - $timelinePath = $timelineFolder->getPath(); // no trailing slash - if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) { - return new JSONResponse(['message' => 'Files outside timeline cannot be archived'], Http::STATUS_INTERNAL_SERVER_ERROR); - } - $relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash - - // Final path of the file including the file name - $destinationPath = ''; - - // Check if we want to archive or unarchive - $body = $this->request->getParams(); - $unarchive = isset($body['archive']) && false === $body['archive']; - - // Get if the file is already in the archive (relativePath starts with archive) - $archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER; - if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) { - // file already in archive, remove it instead - $destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash)); - if (!$unarchive) { - return new JSONResponse(['message' => 'File already archived'], Http::STATUS_BAD_REQUEST); - } - } else { - // file not in archive, put it in there - $destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER.$relativePath); - if ($unarchive) { - return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST); - } - } - - // Remove the filename - $destinationFolders = explode('/', $destinationPath); - array_pop($destinationFolders); - - // Create folder tree - $folder = $timelineFolder; - foreach ($destinationFolders as $folderName) { - if ('' === $folderName) { - continue; - } - - try { - $existingFolder = $folder->get($folderName.'/'); - if (!$existingFolder instanceof Folder) { - throw new \OCP\Files\NotFoundException('Not a folder'); - } - $folder = $existingFolder; - } catch (\OCP\Files\NotFoundException $e) { - try { - $folder = $folder->newFolder($folderName); - } catch (\OCP\Files\NotPermittedException $e) { - return new JSONResponse(['message' => 'Failed to create folder'], Http::STATUS_FORBIDDEN); - } - } - } - - // Move file to archive folder - try { - $file->move($folder->getPath().'/'.$file->getName()); - } catch (\OCP\Files\NotPermittedException $e) { - return new JSONResponse(['message' => 'Failed to move file'], Http::STATUS_FORBIDDEN); - } catch (\OCP\Files\NotFoundException $e) { - return new JSONResponse(['message' => 'File not found'], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (\OCP\Files\InvalidPathException $e) { - return new JSONResponse(['message' => 'Invalid path'], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (\OCP\Lock\LockedException $e) { - return new JSONResponse(['message' => 'File is locked'], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - return new JSONResponse([], Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * update preferences (user setting) - * - * @param string key the identifier to change - * @param string value the value to set - * - * @return JSONResponse an empty JSONResponse with respective http status code - */ - public function setUserConfig(string $key, string $value): JSONResponse - { - $user = $this->userSession->getUser(); - if (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - // Make sure not running in read-only mode - if ($this->config->getSystemValue('memories.readonly', false)) { - return new JSONResponse(['message' => 'Cannot change settings in readonly mode'], Http::STATUS_FORBIDDEN); - } - - $userId = $user->getUid(); - $this->config->setUserValue($userId, Application::APPNAME, $key, $value); - - return new JSONResponse([], Http::STATUS_OK); - } - - /** - * @NoAdminRequired - * - * @PublicPage - * - * @NoCSRFRequired - */ - public function serviceWorker(): StreamResponse - { - $response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js'); - $response->setHeaders([ - 'Content-Type' => 'application/javascript', - 'Service-Worker-Allowed' => '/', - ]); - $policy = new ContentSecurityPolicy(); - $policy->addAllowedWorkerSrcDomain("'self'"); - $policy->addAllowedScriptDomain("'self'"); - $policy->addAllowedConnectDomain("'self'"); - $response->setContentSecurityPolicy($policy); - - return $response; - } - - /** Get logged in user's UID or throw HTTP error */ - private function getUid(): string - { - $user = $this->userSession->getUser(); - if ($this->getShareToken()) { - $user = null; - } elseif (null === $user) { - return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); - } - - return $user ? $user->getUID() : ''; - } - - /** - * Get transformations depending on the request. - * - * @param bool $aggregateOnly Only apply transformations for aggregation (days call) - */ - private function getTransformations(bool $aggregateOnly) - { - $transforms = []; - - // Add extra information, basename and mimetype - if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) { - $fields = explode(',', $fields); - $transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields]; - } - - // Other transforms not allowed for public shares - if (null === $this->userSession->getUser()) { - return $transforms; - } - - // Filter only favorites - if ($this->request->getParam('fav')) { - $transforms[] = [$this->timelineQuery, 'transformFavoriteFilter']; - } - - // Filter only videos - if ($this->request->getParam('vid')) { - $transforms[] = [$this->timelineQuery, 'transformVideoFilter']; - } - - // Filter only for one face - if ($this->recognizeIsEnabled()) { - $face = $this->request->getParam('face'); - if ($face) { - $transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face]; - } - - $faceRect = $this->request->getParam('facerect'); - if ($faceRect && !$aggregateOnly) { - $transforms[] = [$this->timelineQuery, 'transformFaceRect', $face]; - } - } - - // Filter only for one tag - if ($this->tagsIsEnabled()) { - if ($tagName = $this->request->getParam('tag')) { - $transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName]; - } - } - - // Filter for one album - if ($this->albumsIsEnabled()) { - if ($albumId = $this->request->getParam('album')) { - $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; - } - } - - // Limit number of responses for day query - $limit = $this->request->getParam('limit'); - if ($limit) { - $transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit]; - } - - return $transforms; - } - - /** - * Preload a few "day" at the start of "days" response. - * - * @param array $days the days array - * @param string $uid User ID or blank for public shares - * @param null|Folder $folder the folder to search in - * @param bool $recursive search in subfolders - * @param bool $archive search in archive folder only - */ - private function preloadDays(array &$days, string $uid, &$folder, bool $recursive, bool $archive) - { - $transforms = $this->getTransformations(false); - $preloaded = 0; - $preloadDayIds = []; - $preloadDays = []; - foreach ($days as &$day) { - if ($day['count'] <= 0) { - continue; - } - - $preloaded += $day['count']; - $preloadDayIds[] = $day['dayid']; - $preloadDays[] = &$day; - - if ($preloaded >= 50 || \count($preloadDayIds) > 5) { // should be enough - break; - } - } - - if (\count($preloadDayIds) > 0) { - $allDetails = $this->timelineQuery->getDay( - $folder, - $uid, - $preloadDayIds, - $recursive, - $archive, - $transforms, - ); - - // Group into dayid - $detailMap = []; - foreach ($allDetails as &$detail) { - $detailMap[$detail['dayid']][] = &$detail; - } - foreach ($preloadDays as &$day) { - $m = $detailMap[$day['dayid']]; - if (isset($m) && null !== $m && \count($m) > 0) { - $day['detail'] = $m; - } - } - } - } - - /** Get the Folder object relevant to the request */ - private function getRequestFolder() - { - // Albums have no folder - if ($this->request->getParam('album')) { - return null; - } - - // Public shared folder - if ($token = $this->getShareToken()) { - $share = $this->shareManager->getShareByToken($token)->getNode(); // throws exception if not found - if (!$share instanceof Folder) { - throw new \Exception('Share not found or invalid'); - } - - return $share; - } - - // Anything else needs a user - $user = $this->userSession->getUser(); - if (null === $user) { - return null; - } - $uid = $user->getUID(); - - $folder = null; - $folderPath = $this->request->getParam('folder'); - $forcedTimelinePath = $this->request->getParam('timelinePath'); - $userFolder = $this->rootFolder->getUserFolder($uid); - - if (null !== $folderPath) { - $folder = $userFolder->get($folderPath); - } elseif (null !== $forcedTimelinePath) { - $folder = $userFolder->get($forcedTimelinePath); - } else { - $configPath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); - $folder = $userFolder->get($configPath); - } - - if (!$folder instanceof Folder) { - throw new \Exception('Folder not found'); - } - - return $folder; - } - - private function getShareToken() - { - return $this->request->getParam('folder_share'); - } - - /** - * Check if albums are enabled for this user. - */ - private function albumsIsEnabled(): bool - { - return \OCA\Memories\Util::albumsIsEnabled($this->appManager); - } - - /** - * Check if tags is enabled for this user. - */ - private function tagsIsEnabled(): bool - { - return \OCA\Memories\Util::tagsIsEnabled($this->appManager); - } - - /** - * Check if recognize is enabled for this user. - */ - private function recognizeIsEnabled(): bool - { - return \OCA\Memories\Util::recognizeIsEnabled($this->appManager); - } -} diff --git a/lib/Controller/ArchiveController.php b/lib/Controller/ArchiveController.php new file mode 100644 index 00000000..4ca93d0c --- /dev/null +++ b/lib/Controller/ArchiveController.php @@ -0,0 +1,143 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCA\Memories\Exif; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\Folder; + +class ArchiveController extends ApiBase +{ + /** + * @NoAdminRequired + * + * Move one file to the archive folder + * + * @param string fileid + */ + public function archive(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse(['message' => 'Not logged in'], Http::STATUS_PRECONDITION_FAILED); + } + $uid = $user->getUID(); + $userFolder = $this->rootFolder->getUserFolder($uid); + + // Check for permissions and get numeric Id + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { + return new JSONResponse(['message' => 'No such file'], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // Check if user has permissions + if (!$file->isUpdateable()) { + return new JSONResponse(['message' => 'Cannot update this file'], Http::STATUS_FORBIDDEN); + } + + // Create archive folder in the root of the user's configured timeline + $timelinePath = Exif::removeExtraSlash(Exif::getPhotosPath($this->config, $uid)); + $timelineFolder = $userFolder->get($timelinePath); + if (null === $timelineFolder || !$timelineFolder instanceof Folder) { + return new JSONResponse(['message' => 'Cannot get timeline'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + if (!$timelineFolder->isCreatable()) { + return new JSONResponse(['message' => 'Cannot create archive folder'], Http::STATUS_FORBIDDEN); + } + + // Get path of current file relative to the timeline folder + // remove timelineFolder path from start of file path + $timelinePath = $timelineFolder->getPath(); // no trailing slash + if (substr($file->getPath(), 0, \strlen($timelinePath)) !== $timelinePath) { + return new JSONResponse(['message' => 'Files outside timeline cannot be archived'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + $relativePath = substr($file->getPath(), \strlen($timelinePath)); // has a leading slash + + // Final path of the file including the file name + $destinationPath = ''; + + // Check if we want to archive or unarchive + $body = $this->request->getParams(); + $unarchive = isset($body['archive']) && false === $body['archive']; + + // Get if the file is already in the archive (relativePath starts with archive) + $archiveFolderWithLeadingSlash = '/'.\OCA\Memories\Util::$ARCHIVE_FOLDER; + if (substr($relativePath, 0, \strlen($archiveFolderWithLeadingSlash)) === $archiveFolderWithLeadingSlash) { + // file already in archive, remove it instead + $destinationPath = substr($relativePath, \strlen($archiveFolderWithLeadingSlash)); + if (!$unarchive) { + return new JSONResponse(['message' => 'File already archived'], Http::STATUS_BAD_REQUEST); + } + } else { + // file not in archive, put it in there + $destinationPath = Exif::removeExtraSlash(\OCA\Memories\Util::$ARCHIVE_FOLDER.$relativePath); + if ($unarchive) { + return new JSONResponse(['message' => 'File not archived'], Http::STATUS_BAD_REQUEST); + } + } + + // Remove the filename + $destinationFolders = explode('/', $destinationPath); + array_pop($destinationFolders); + + // Create folder tree + $folder = $timelineFolder; + foreach ($destinationFolders as $folderName) { + if ('' === $folderName) { + continue; + } + + try { + $existingFolder = $folder->get($folderName.'/'); + if (!$existingFolder instanceof Folder) { + throw new \OCP\Files\NotFoundException('Not a folder'); + } + $folder = $existingFolder; + } catch (\OCP\Files\NotFoundException $e) { + try { + $folder = $folder->newFolder($folderName); + } catch (\OCP\Files\NotPermittedException $e) { + return new JSONResponse(['message' => 'Failed to create folder'], Http::STATUS_FORBIDDEN); + } + } + } + + // Move file to archive folder + try { + $file->move($folder->getPath().'/'.$file->getName()); + } catch (\OCP\Files\NotPermittedException $e) { + return new JSONResponse(['message' => 'Failed to move file'], Http::STATUS_FORBIDDEN); + } catch (\OCP\Files\NotFoundException $e) { + return new JSONResponse(['message' => 'File not found'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCP\Files\InvalidPathException $e) { + return new JSONResponse(['message' => 'Invalid path'], Http::STATUS_INTERNAL_SERVER_ERROR); + } catch (\OCP\Lock\LockedException $e) { + return new JSONResponse(['message' => 'File is locked'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse([], Http::STATUS_OK); + } +} diff --git a/lib/Controller/DaysController.php b/lib/Controller/DaysController.php new file mode 100644 index 00000000..d0b3a67b --- /dev/null +++ b/lib/Controller/DaysController.php @@ -0,0 +1,304 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\FileInfo; +use OCP\Files\Folder; + +class DaysController extends ApiBase +{ + /** + * @NoAdminRequired + * + * @PublicPage + */ + public function days(): JSONResponse + { + // Get the folder to show + $uid = $this->getUid(); + + // Get the folder to show + $folder = null; + + try { + $folder = $this->getRequestFolder(); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } + + // Params + $recursive = null === $this->request->getParam('folder'); + $archive = null !== $this->request->getParam('archive'); + + // Run actual query + try { + $list = $this->timelineQuery->getDays( + $folder, + $uid, + $recursive, + $archive, + $this->getTransformations(true), + ); + + // Preload some day responses + $this->preloadDays($list, $uid, $folder, $recursive, $archive); + + // Add subfolder info if querying non-recursively + if (!$recursive) { + array_unshift($list, $this->getSubfoldersEntry($folder)); + } + + return new JSONResponse($list, Http::STATUS_OK); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * @NoAdminRequired + * + * @PublicPage + */ + public function dayPost(): JSONResponse + { + $id = $this->request->getParam('body_ids'); + if (null === $id) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + return $this->day($id); + } + + /** + * @NoAdminRequired + * + * @PublicPage + */ + public function day(string $id): JSONResponse + { + // Get user + $uid = $this->getUid(); + + // Check for wildcard + $day_ids = []; + if ('*' === $id) { + $day_ids = null; + } else { + // Split at commas and convert all parts to int + $day_ids = array_map(function ($part) { + return (int) $part; + }, explode(',', $id)); + } + + // Check if $day_ids is empty + if (null !== $day_ids && 0 === \count($day_ids)) { + return new JSONResponse([], Http::STATUS_OK); + } + + // Get the folder to show + $folder = null; + + try { + $folder = $this->getRequestFolder(); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } + + // Params + $recursive = null === $this->request->getParam('folder'); + $archive = null !== $this->request->getParam('archive'); + + // Run actual query + try { + $list = $this->timelineQuery->getDay( + $folder, + $uid, + $day_ids, + $recursive, + $archive, + $this->getTransformations(false), + ); + + return new JSONResponse($list, Http::STATUS_OK); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get subfolders entry for days response. + */ + public function getSubfoldersEntry(Folder &$folder) + { + // Ugly: get the view of the folder with reflection + // This is unfortunately the only way to get the contents of a folder + // matching a MIME type without using SEARCH, which is deep + $rp = new \ReflectionProperty('\OC\Files\Node\Node', 'view'); + $rp->setAccessible(true); + $view = $rp->getValue($folder); + + // Get the subfolders + $folders = $view->getDirectoryContent($folder->getPath(), FileInfo::MIMETYPE_FOLDER, $folder); + + // Sort by name + usort($folders, function ($a, $b) { + return strnatcmp($a->getName(), $b->getName()); + }); + + // Process to response type + return [ + 'dayid' => \OCA\Memories\Util::$TAG_DAYID_FOLDERS, + 'count' => \count($folders), + 'detail' => array_map(function ($node) { + return [ + 'fileid' => $node->getId(), + 'name' => $node->getName(), + 'isfolder' => 1, + 'path' => $node->getPath(), + ]; + }, $folders, []), + ]; + } + + /** + * Get transformations depending on the request. + * + * @param bool $aggregateOnly Only apply transformations for aggregation (days call) + */ + private function getTransformations(bool $aggregateOnly) + { + $transforms = []; + + // Add extra information, basename and mimetype + if (!$aggregateOnly && ($fields = $this->request->getParam('fields'))) { + $fields = explode(',', $fields); + $transforms[] = [$this->timelineQuery, 'transformExtraFields', $fields]; + } + + // Other transforms not allowed for public shares + if (null === $this->userSession->getUser()) { + return $transforms; + } + + // Filter only favorites + if ($this->request->getParam('fav')) { + $transforms[] = [$this->timelineQuery, 'transformFavoriteFilter']; + } + + // Filter only videos + if ($this->request->getParam('vid')) { + $transforms[] = [$this->timelineQuery, 'transformVideoFilter']; + } + + // Filter only for one face + if ($this->recognizeIsEnabled()) { + $face = $this->request->getParam('face'); + if ($face) { + $transforms[] = [$this->timelineQuery, 'transformFaceFilter', $face]; + } + + $faceRect = $this->request->getParam('facerect'); + if ($faceRect && !$aggregateOnly) { + $transforms[] = [$this->timelineQuery, 'transformFaceRect', $face]; + } + } + + // Filter only for one tag + if ($this->tagsIsEnabled()) { + if ($tagName = $this->request->getParam('tag')) { + $transforms[] = [$this->timelineQuery, 'transformTagFilter', $tagName]; + } + } + + // Filter for one album + if ($this->albumsIsEnabled()) { + if ($albumId = $this->request->getParam('album')) { + $transforms[] = [$this->timelineQuery, 'transformAlbumFilter', $albumId]; + } + } + + // Limit number of responses for day query + $limit = $this->request->getParam('limit'); + if ($limit) { + $transforms[] = [$this->timelineQuery, 'transformLimitDay', (int) $limit]; + } + + return $transforms; + } + + /** + * Preload a few "day" at the start of "days" response. + * + * @param array $days the days array + * @param string $uid User ID or blank for public shares + * @param null|Folder $folder the folder to search in + * @param bool $recursive search in subfolders + * @param bool $archive search in archive folder only + */ + private function preloadDays(array &$days, string $uid, &$folder, bool $recursive, bool $archive) + { + $transforms = $this->getTransformations(false); + $preloaded = 0; + $preloadDayIds = []; + $preloadDays = []; + foreach ($days as &$day) { + if ($day['count'] <= 0) { + continue; + } + + $preloaded += $day['count']; + $preloadDayIds[] = $day['dayid']; + $preloadDays[] = &$day; + + if ($preloaded >= 50 || \count($preloadDayIds) > 5) { // should be enough + break; + } + } + + if (\count($preloadDayIds) > 0) { + $allDetails = $this->timelineQuery->getDay( + $folder, + $uid, + $preloadDayIds, + $recursive, + $archive, + $transforms, + ); + + // Group into dayid + $detailMap = []; + foreach ($allDetails as &$detail) { + $detailMap[$detail['dayid']][] = &$detail; + } + foreach ($preloadDays as &$day) { + $m = $detailMap[$day['dayid']]; + if (isset($m) && null !== $m && \count($m) > 0) { + $day['detail'] = $m; + } + } + } + } +} diff --git a/lib/Controller/FacesController.php b/lib/Controller/FacesController.php new file mode 100644 index 00000000..a6634ed7 --- /dev/null +++ b/lib/Controller/FacesController.php @@ -0,0 +1,150 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataDisplayResponse; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\FileInfo; + +class FacesController extends ApiBase +{ + /** + * @NoAdminRequired + * + * Get list of faces with counts of images + */ + public function faces(): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check faces enabled for this user + if (!$this->recognizeIsEnabled()) { + return new JSONResponse(['message' => 'Recognize app not enabled or not v3+'], Http::STATUS_PRECONDITION_FAILED); + } + + // If this isn't the timeline folder then things aren't going to work + $folder = $this->getRequestFolder(); + if (null === $folder) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Run actual query + $list = $this->timelineQuery->getFaces( + $folder, + ); + + return new JSONResponse($list, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * Get face preview image cropped with imagick + * + * @return DataResponse + */ + public function preview(string $id): Http\Response + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check faces enabled for this user + if (!$this->recognizeIsEnabled()) { + return new DataResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Get folder to search for + $folder = $this->getRequestFolder(); + if (null === $folder) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Run actual query + $detections = $this->timelineQuery->getFacePreviewDetection($folder, (int) $id); + if (null === $detections || 0 === \count($detections)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + // Find the first detection that has a preview + $preview = null; + foreach ($detections as &$detection) { + // Get the file (also checks permissions) + $files = $folder->getById($detection['file_id']); + if (0 === \count($files) || FileInfo::TYPE_FILE !== $files[0]->getType()) { + continue; + } + + // Get (hopefully cached) preview image + try { + $preview = $this->previewManager->getPreview($files[0], 2048, 2048, false); + } catch (\Exception $e) { + continue; + } + + // Got the preview + break; + } + + // Make sure the preview is valid + if (null === $preview) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + // Crop image + $image = new \Imagick(); + $image->readImageBlob($preview->getContent()); + $iw = $image->getImageWidth(); + $ih = $image->getImageHeight(); + $dw = (float) $detection['width']; + $dh = (float) $detection['height']; + $dcx = (float) $detection['x'] + (float) $detection['width'] / 2; + $dcy = (float) $detection['y'] + (float) $detection['height'] / 2; + $faceDim = max($dw * $iw, $dh * $ih) * 1.5; + $image->cropImage( + (int) $faceDim, + (int) $faceDim, + (int) ($dcx * $iw - $faceDim / 2), + (int) ($dcy * $ih - $faceDim / 2), + ); + $image->scaleImage(256, 256, true); + $blob = $image->getImageBlob(); + + // Create and send response + $response = new DataDisplayResponse($blob, Http::STATUS_OK, [ + 'Content-Type' => $image->getImageMimeType(), + ]); + $response->cacheFor(3600 * 24, false, false); + + return $response; + } +} diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php new file mode 100644 index 00000000..ad9b7023 --- /dev/null +++ b/lib/Controller/ImageController.php @@ -0,0 +1,115 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCA\Memories\Exif; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; + +class ImageController extends ApiBase +{ + /** + * @NoAdminRequired + * + * Get image info for one file + * + * @param string fileid + */ + public function info(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + // Check for permissions and get numeric Id + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // Get the image info + $info = $this->timelineQuery->getInfoById($file->getId()); + + return new JSONResponse($info, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * Change exif data for one file + * + * @param string fileid + */ + public function edit(string $id): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + // Check for permissions and get numeric Id + $file = $userFolder->getById((int) $id); + if (0 === \count($file)) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + $file = $file[0]; + + // Check if user has permissions + if (!$file->isUpdateable()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + // Get new date from body + $body = $this->request->getParams(); + if (!isset($body['date'])) { + return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST); + } + + // Make sure the date is valid + try { + Exif::parseExifDate($body['date']); + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + // Update date + try { + $res = Exif::updateExifDate($file, $body['date']); + if (false === $res) { + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } catch (\Exception $e) { + return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Reprocess the file + $this->timelineWrite->processFile($file, true); + + return $this->info($id); + } +} diff --git a/lib/Controller/OtherController.php b/lib/Controller/OtherController.php new file mode 100644 index 00000000..5d95830f --- /dev/null +++ b/lib/Controller/OtherController.php @@ -0,0 +1,84 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCA\Memories\AppInfo\Application; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\StreamResponse; + +class OtherController extends ApiBase +{ + /** + * @NoAdminRequired + * + * update preferences (user setting) + * + * @param string key the identifier to change + * @param string value the value to set + * + * @return JSONResponse an empty JSONResponse with respective http status code + */ + public function setUserConfig(string $key, string $value): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Make sure not running in read-only mode + if ($this->config->getSystemValue('memories.readonly', false)) { + return new JSONResponse(['message' => 'Cannot change settings in readonly mode'], Http::STATUS_FORBIDDEN); + } + + $userId = $user->getUid(); + $this->config->setUserValue($userId, Application::APPNAME, $key, $value); + + return new JSONResponse([], Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * @PublicPage + * + * @NoCSRFRequired + */ + public function serviceWorker(): StreamResponse + { + $response = new StreamResponse(__DIR__.'/../../js/memories-service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/', + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + + return $response; + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index b89234bb..7390710d 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -109,7 +109,7 @@ class PageController extends Controller * * @NoCSRFRequired */ - public function sharedFolder(string $token) + public function sharedfolder(string $token) { // Scripts Util::addScript($this->appName, 'memories-main'); diff --git a/lib/Controller/TagsController.php b/lib/Controller/TagsController.php new file mode 100644 index 00000000..d4c3c667 --- /dev/null +++ b/lib/Controller/TagsController.php @@ -0,0 +1,96 @@ + + * @author Varun Patil + * @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 . + */ + +namespace OCA\Memories\Controller; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; + +class TagsController extends ApiBase +{ + /** + * @NoAdminRequired + * + * Get list of tags with counts of images + */ + public function tags(): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check tags enabled for this user + if (!$this->tagsIsEnabled()) { + return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED); + } + + // If this isn't the timeline folder then things aren't going to work + $folder = $this->getRequestFolder(); + if (null === $folder) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Run actual query + $list = $this->timelineQuery->getTags( + $folder, + ); + + return new JSONResponse($list, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * Get previews for a tag + */ + public function previews(): JSONResponse + { + $user = $this->userSession->getUser(); + if (null === $user) { + return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED); + } + + // Check tags enabled for this user + if (!$this->tagsIsEnabled()) { + return new JSONResponse(['message' => 'Tags not enabled for user'], Http::STATUS_PRECONDITION_FAILED); + } + + // If this isn't the timeline folder then things aren't going to work + $folder = $this->getRequestFolder(); + if (null === $folder) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + // Get the tag + $tagName = $this->request->getParam('tag'); + + // Run actual query + $list = $this->timelineQuery->getTagPreviews( + $tagName, + $folder, + ); + + return new JSONResponse($list, Http::STATUS_OK); + } +}