From c49a2cedb87675a7bd12e28b9c40d3fb661a71dc 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 | 9 +- package-lock.json | 45 +++- package.json | 4 +- src/components/LocationMap.vue | 121 +++++++++ src/sidebar.js | 83 ++++++ src/views/PhotosTab.vue | 250 ++++++++++++++++++ webpack.js | 3 +- 10 files changed, 625 insertions(+), 6 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 5939c4426..b85b3ea6f 100644 --- a/lib/MetadataProvider/ExifMetadataProvider.php +++ b/lib/MetadataProvider/ExifMetadataProvider.php @@ -147,11 +147,18 @@ 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 { foreach ($data as $key => $value) { - if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) { + if (!is_string($value)) { + continue; + } + + if (!mb_check_encoding($value, 'UTF-8')) { $data[$key] = 'base64:'.base64_encode($value); + } else { + $data[$key] = preg_replace('/[[:cntrl:]]/u', '', $value); } } diff --git a/package-lock.json b/package-lock.json index 59fea19a0..cc62e8eb6 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", @@ -4233,6 +4236,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, @@ -4320,6 +4329,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", @@ -13948,6 +13966,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, @@ -19674,6 +19703,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 34addfdf5..83b6aec24 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..42f153216 --- /dev/null +++ b/src/components/LocationMap.vue @@ -0,0 +1,121 @@ + + + + + + + diff --git a/src/sidebar.js b/src/sidebar.js new file mode 100644 index 000000000..0010c9ec5 --- /dev/null +++ b/src/sidebar.js @@ -0,0 +1,83 @@ +/** + * @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) + } +}) diff --git a/src/views/PhotosTab.vue b/src/views/PhotosTab.vue new file mode 100644 index 000000000..e23218aca --- /dev/null +++ b/src/views/PhotosTab.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/webpack.js b/webpack.js index 4e15e5230..88308068d 100644 --- a/webpack.js +++ b/webpack.js @@ -12,6 +12,7 @@ const { basename } = require('path') webpackConfig.entry = { main: path.join(__dirname, 'src', 'main.js'), public: path.join(__dirname, 'src', 'public.js'), + sidebar: path.join(__dirname, 'src', 'sidebar.js'), } webpackRules.RULE_JS.exclude = BabelLoaderExcludeNodeModulesExcept([ @@ -48,7 +49,7 @@ webpackConfig.plugins.push( // patch webdav/dist/request.js new webpack.NormalModuleReplacementPlugin( /request(\.js)?/, - function (resource) { + function(resource) { if (resource.context.indexOf('webdav') > -1) { console.debug('Patched request for webdav', basename(resource.contextInfo.issuer)) resource.request = path.join(__dirname, 'src/patchedRequest.js')