From 47cf3757605487c76c039d8bc2e4157436e3ec17 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 7 Nov 2023 16:51:42 +0100 Subject: [PATCH] Create photos sidebar tab Signed-off-by: Louis Chemineau --- lib/AppInfo/Application.php | 8 + lib/Listener/CSPListener.php | 51 ++++ lib/Listener/LoadSidebarScripts.php | 57 ++++ lib/MetadataProvider/ExifMetadataProvider.php | 22 +- package-lock.json | 45 ++- package.json | 4 +- src/components/LocationMap.vue | 122 +++++++++ src/sidebar.js | 101 +++++++ src/views/PhotosTab.vue | 259 ++++++++++++++++++ 9 files changed, 660 insertions(+), 9 deletions(-) create mode 100644 lib/Listener/CSPListener.php create mode 100644 lib/Listener/LoadSidebarScripts.php create mode 100644 src/components/LocationMap.vue create mode 100644 src/sidebar.js create mode 100644 src/views/PhotosTab.vue diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5c4523b72..4cbac2f6c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -27,7 +27,10 @@ use OCA\DAV\Connector\Sabre\Principal; use OCA\DAV\Events\SabrePluginAuthInitEvent; +use OCA\Files\Event\LoadSidebar; use OCA\Photos\Listener\AlbumsManagementEventListener; +use OCA\Photos\Listener\CSPListener; +use OCA\Photos\Listener\LoadSidebarScripts; use OCA\Photos\Listener\SabrePluginAuthInitListener; use OCA\Photos\Listener\TagListener; use OCA\Photos\MetadataProvider\ExifMetadataProvider; @@ -43,6 +46,7 @@ use OCP\FilesMetadata\Event\MetadataLiveEvent; use OCP\Group\Events\GroupDeletedEvent; use OCP\Group\Events\UserRemovedEvent; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; use OCP\Share\Events\ShareDeletedEvent; use OCP\SystemTag\MapperEvent; use OCP\User\Events\UserDeletedEvent; @@ -79,6 +83,10 @@ public function register(IRegistrationContext $context): void { /** Register $principalBackend for the DAV collection */ $context->registerServiceAlias('principalBackend', Principal::class); + $context->registerEventListener(LoadSidebar::class, LoadSidebarScripts::class); + + $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class); + // Metadata $context->registerEventListener(MetadataLiveEvent::class, ExifMetadataProvider::class); $context->registerEventListener(MetadataLiveEvent::class, SizeMetadataProvider::class); diff --git a/lib/Listener/CSPListener.php b/lib/Listener/CSPListener.php new file mode 100644 index 000000000..67ee6bf2b --- /dev/null +++ b/lib/Listener/CSPListener.php @@ -0,0 +1,51 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * 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\Photos\Listener; + +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; + +/** + * @template-implements IEventListener + */ +class CSPListener implements IEventListener { + + public function __construct( + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof AddContentSecurityPolicyEvent)) { + return; + } + + $csp = new ContentSecurityPolicy(); + $csp->addAllowedImageDomain('https://*.tile.openstreetmap.org'); + $event->addPolicy($csp); + } +} diff --git a/lib/Listener/LoadSidebarScripts.php b/lib/Listener/LoadSidebarScripts.php new file mode 100644 index 000000000..be099bf87 --- /dev/null +++ b/lib/Listener/LoadSidebarScripts.php @@ -0,0 +1,57 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * 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\Photos\Listener; + +use OCA\Files\Event\LoadSidebar; +use OCA\Photos\AppInfo\Application; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IRequest; +use OCP\Util; + +/** + * @template-implements IEventListener + */ +class LoadSidebarScripts implements IEventListener { + + public function __construct( + private IRequest $request, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof LoadSidebar)) { + return; + } + + // Only load sidebar tab in the photos app. + if (!preg_match('/^photos\.page\..+/', $this->request->getParams()['_route'])) { + return; + } + + Util::addScript(Application::APP_ID, 'photos-sidebar'); + } +} diff --git a/lib/MetadataProvider/ExifMetadataProvider.php b/lib/MetadataProvider/ExifMetadataProvider.php index 0495bb36d..393c6eb03 100644 --- a/lib/MetadataProvider/ExifMetadataProvider.php +++ b/lib/MetadataProvider/ExifMetadataProvider.php @@ -80,11 +80,11 @@ public function handle(Event $event): void { } if ($rawExifData && array_key_exists('EXIF', $rawExifData)) { - $event->getMetadata()->setArray('photos-exif', $this->base64Encode($rawExifData['EXIF'])); + $event->getMetadata()->setArray('photos-exif', $this->sanitizeEntries($rawExifData['EXIF'])); } if ($rawExifData && array_key_exists('IFD0', $rawExifData)) { - $event->getMetadata()->setArray('photos-ifd0', $this->base64Encode($rawExifData['IFD0'])); + $event->getMetadata()->setArray('photos-ifd0', $this->sanitizeEntries($rawExifData['IFD0'])); } if ( @@ -142,14 +142,26 @@ private function parseGPSData(string $rawData): float { /** * Exif data can contain anything. * This method will base 64 encode any non UTF-8 string in an array. + * This will also remove control characters from UTF-8 strings. */ - private function base64Encode(array $data): array { + private function sanitizeEntries(array $data): array { + $cleanData = []; + foreach ($data as $key => $value) { if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) { - $data[$key] = 'base64:'.base64_encode($value); + $value = 'base64:'.base64_encode($value); + } elseif (is_string($value)) { + // TODO: Can be remove when the Sidebar use the @nextcloud/files to fetch and parse the DAV response. + $value = preg_replace('/[[:cntrl:]]/u', '', $value); + } + + if (preg_match('/[^a-zA-Z]/', $key) !== 0) { + $key = preg_replace('/[^a-zA-Z]/', '_', $key); } + + $cleanData[$key] = $value; } - return $data; + return $cleanData; } } diff --git a/package-lock.json b/package-lock.json index 6e29acf5a..0512b49be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "agpl", "dependencies": { "@essentials/request-timeout": "^1.3.0", - "@mdi/svg": "^7.1.96", + "@mdi/svg": "^7.3.67", "@nextcloud/auth": "^2.1.0", "@nextcloud/axios": "^2.1.0", "@nextcloud/dialogs": "^4.1.0", @@ -28,6 +28,7 @@ "camelcase": "^7.0.0", "debounce": "^1.2.1", "he": "^1.2.0", + "leaflet-defaulticon-compatibility": "^0.1.2", "path-posix": "^1.0.0", "qs": "^6.11.2", "url-parse": "^1.5.10", @@ -36,6 +37,7 @@ "vue-router": "^3.6.5", "vue-template-compiler": "^2.7.14", "vue-virtual-grid": "^2.5.0", + "vue2-leaflet": "^2.7.1", "vuex": "^3.6.2", "vuex-router-sync": "^5.0.0", "webdav": "^4.11.0" @@ -3112,8 +3114,9 @@ } }, "node_modules/@mdi/svg": { - "version": "7.1.96", - "license": "Apache-2.0" + "version": "7.3.67", + "resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-7.3.67.tgz", + "integrity": "sha512-KNr7D8jbu8DEprgRckVywVBkajsGGqocFjOzlekv35UedLjpkMDTkFO8VYnhnLySL0QaPBa568fe8BZsB0TBJQ==" }, "node_modules/@nextcloud/auth": { "version": "2.1.0", @@ -4802,6 +4805,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.13", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", + "integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==", + "peer": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "dev": true, @@ -4889,6 +4898,15 @@ "license": "MIT", "peer": true }, + "node_modules/@types/leaflet": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.7.tgz", + "integrity": "sha512-FOfKB1ALYUDnXkH7LfTFreWiZr9R7GErqGP+8lYQGWr2GFq5+jy3Ih0M7e9j41cvRN65kLALJ4dc43yZwyl/6g==", + "peer": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "3.0.11", "license": "MIT", @@ -14537,6 +14555,17 @@ "node": "> 0.8" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "peer": true + }, + "node_modules/leaflet-defaulticon-compatibility": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz", + "integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q==" + }, "node_modules/leven": { "version": "3.1.0", "dev": true, @@ -20417,6 +20446,16 @@ "vue": "^2.5.0" } }, + "node_modules/vue2-leaflet": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/vue2-leaflet/-/vue2-leaflet-2.7.1.tgz", + "integrity": "sha512-K7HOlzRhjt3Z7+IvTqEavIBRbmCwSZSCVUlz9u4Rc+3xGCLsHKz4TAL4diAmfHElCQdPPVdZdJk8wPUt2fu6WQ==", + "peerDependencies": { + "@types/leaflet": "^1.5.7", + "leaflet": "^1.3.4", + "vue": "^2.5.17" + } + }, "node_modules/vuex": { "version": "3.6.2", "license": "MIT", diff --git a/package.json b/package.json index 370b2769c..4eabf3b9c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@essentials/request-timeout": "^1.3.0", - "@mdi/svg": "^7.1.96", + "@mdi/svg": "^7.3.67", "@nextcloud/auth": "^2.1.0", "@nextcloud/axios": "^2.1.0", "@nextcloud/dialogs": "^4.1.0", @@ -57,6 +57,7 @@ "camelcase": "^7.0.0", "debounce": "^1.2.1", "he": "^1.2.0", + "leaflet-defaulticon-compatibility": "^0.1.2", "path-posix": "^1.0.0", "qs": "^6.11.2", "url-parse": "^1.5.10", @@ -65,6 +66,7 @@ "vue-router": "^3.6.5", "vue-template-compiler": "^2.7.14", "vue-virtual-grid": "^2.5.0", + "vue2-leaflet": "^2.7.1", "vuex": "^3.6.2", "vuex-router-sync": "^5.0.0", "webdav": "^4.11.0" diff --git a/src/components/LocationMap.vue b/src/components/LocationMap.vue new file mode 100644 index 000000000..b1636db5b --- /dev/null +++ b/src/components/LocationMap.vue @@ -0,0 +1,122 @@ + + + + + + + diff --git a/src/sidebar.js b/src/sidebar.js new file mode 100644 index 000000000..bdd92014c --- /dev/null +++ b/src/sidebar.js @@ -0,0 +1,101 @@ +/** + * @copyright Copyright (c) 2023 Louis Chemineau + * + * @author Louis Chemineau + * + * @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 . + * + */ +import Vue from 'vue' +// eslint-disable-next-line n/no-missing-import, import/no-unresolved +import InformationSlabSymbol from '@mdi/svg/svg/information-slab-symbol.svg?raw' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { getRequestToken } from '@nextcloud/auth' +import { generateFilePath } from '@nextcloud/router' +import { registerDavProperty } from '@nextcloud/files' + +Vue.prototype.t = t +Vue.prototype.n = n + +__webpack_nonce__ = btoa(getRequestToken() ?? '') +__webpack_public_path__ = generateFilePath('photos', '', 'js/') + +registerDavProperty('nc:metadata-photos-original_date_time', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-photos-exif', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-photos-ifd0', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-photos-gps', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:metadata-photos-place', { nc: 'http://nextcloud.org/ns' }) + +// Init Photos tab component +let PhotosTabView = null +let PhotosTabInstance = null +const photosTab = new OCA.Files.Sidebar.Tab({ + id: 'photos', + name: t('photos', 'Details'), + iconSvg: InformationSlabSymbol, + + async mount(el, fileInfo, context) { + // only load if needed + if (PhotosTabView === null) { + const { default: PhotosTab } = await import('./views/PhotosTab.vue') + PhotosTabView = PhotosTabView ?? Vue.extend(PhotosTab) + } + // destroy previous instance if available + if (PhotosTabInstance) { + PhotosTabInstance.$destroy() + } + PhotosTabInstance = new PhotosTabView({ + // Better integration with vue parent component + parent: context, + }) + // No need to await this, we will show a loading indicator instead + PhotosTabInstance.update(fileInfo) + PhotosTabInstance.$mount(el) + }, + update(fileInfo) { + PhotosTabInstance.update(fileInfo) + }, + destroy() { + PhotosTabInstance.$destroy() + PhotosTabInstance = null + }, +}) + +window.addEventListener('DOMContentLoaded', async function() { + if (OCA.Files && OCA.Files.Sidebar) { + OCA.Files.Sidebar.registerTab(photosTab) + const { default: PhotosTab } = await import(/* webpackPreload: true */ './views/PhotosTab.vue') + PhotosTabView = PhotosTabView ?? Vue.extend(PhotosTab) + } + + /** + * + * @param metadataArray + */ + function parseMetadataArray(metadataArray) { + return metadataArray?.reduce((parsedArray, metadata) => ({ ...parsedArray, [metadata.nodeName]: metadata.textContent }), {}) + } + + OC.Files.getClient().addFileInfoParser(function(response) { + return { + 'metadata-photos-original_date_time': response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-original_date_time`], + 'metadata-photos-exif': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-exif`]), + 'metadata-photos-ifd0': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-ifd0`]), + 'metadata-photos-gps': parseMetadataArray(response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-gps`]), + 'metadata-photos-place': response.propStat[0].properties[`{${OC.Files.Client.NS_NEXTCLOUD}}metadata-photos-place`], + } + }) +}) diff --git a/src/views/PhotosTab.vue b/src/views/PhotosTab.vue new file mode 100644 index 000000000..8b13098a5 --- /dev/null +++ b/src/views/PhotosTab.vue @@ -0,0 +1,259 @@ + + + + + + +