diff --git a/REUSE.toml b/REUSE.toml index 213fa90c825f4..cca3f3047dc8b 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -226,7 +226,7 @@ SPDX-FileCopyrightText = "2021 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] -path = ["apps/encryption/img/app.svg", "apps/federation/img/app.svg", "apps/files/img/add-color.png", "apps/files/img/add-color.svg", "apps/files/img/app-dark.svg", "apps/files/img/app.svg", "apps/files/img/change.png", "apps/files/img/change.svg", "apps/files/img/delete-color.png", "apps/files/img/delete-color.svg", "apps/files/img/folder.svg", "apps/files/img/folder-move.svg", "apps/files_external/img/app-dark.svg", "apps/files_external/img/app.svg", "apps/user_ldap/img/app-dark.svg", "apps/user_ldap/img/app.svg", "apps/comments/img/comments-dark.svg", "apps/comments/img/comments.svg", "apps/files_reminders/img/app-dark.svg", "apps/files_reminders/img/app.svg", "apps/files_versions/img/app-dark.svg", "apps/files_versions/img/app.svg", "apps/files_trashbin/img/app-dark.svg", "apps/files_trashbin/img/app.svg", "apps/theming/img/accessibility-dark.svg", "apps/testing/img/app.svg", "apps/sharebymail/img/app.svg", "apps/systemtags/img/app.svg", "apps/provisioning_api/img/app.svg", "apps/settings/img/change.svg", "apps/settings/img/help.svg", "apps/settings/img/password.svg", "apps/settings/img/personal.png", "apps/settings/img/personal.svg", "apps/settings/img/share.svg", "apps/settings/img/users-white.svg", "apps/settings/img/users.svg", "apps/files_sharing/img/app.svg", "apps/settings/img/admin.svg", "apps/settings/img/library_add_check.svg", "apps/settings/img/settings.svg", "apps/settings/img/settings_apps.svg", "apps/settings/img/toggle-filelist.svg", "apps/webhook_listeners/img/app-dark.svg", "apps/webhook_listeners/img/app.svg", "core/img/filetypes/application-pdf.svg", "core/img/breadcrumb.svg", "core/img/filetypes/application.svg", "core/img/filetypes/audio.svg", "core/img/filetypes/file.svg", "core/img/filetypes/folder-drag-accept.svg", "core/img/filetypes/folder-encrypted.svg", "core/img/filetypes/folder-external.svg", "core/img/filetypes/folder-public.svg", "core/img/filetypes/folder-shared.svg", "core/img/filetypes/folder-starred.svg", "core/img/filetypes/folder.svg", "core/img/filetypes/image.svg", "core/img/filetypes/link.svg", "core/img/filetypes/location.svg", "core/img/filetypes/mindmap.svg", "core/img/filetypes/text-calendar.svg", "core/img/filetypes/text-code.svg", "core/img/filetypes/text-vcard.svg", "core/img/filetypes/text.svg", "core/img/filetypes/video.svg", "core/img/filetypes/x-office-document.svg", "core/img/filetypes/x-office-drawing.svg", "core/img/filetypes/x-office-form-template.svg", "core/img/filetypes/x-office-form.svg", "core/img/filetypes/x-office-presentation.svg", "core/img/filetypes/x-office-spreadsheet.svg", "core/img/places/calendar-dark.png", "core/img/places/calendar.png", "core/img/places/calendar.svg", "core/img/places/contacts-dark.png", "core/img/places/contacts.svg", "core/img/places/default-app-icon.svg", "core/img/places/files.svg", "core/img/places/home.svg", "core/img/places/link.svg", "core/img/places/music.svg", "core/img/places/picture.svg", "core/img/rating/s0.svg", "core/img/rating/s1.svg", "core/img/rating/s10.svg", "core/img/rating/s2.svg", "core/img/rating/s3.svg", "core/img/rating/s4.svg", "core/img/rating/s5.svg", "core/img/rating/s6.svg", "core/img/rating/s7.svg", "core/img/rating/s8.svg", "core/img/rating/s9.svg", "core/img/mail.svg", "core/img/rss.svg", "core/img/clients/desktop.svg", "core/img/clients/phone.svg", "core/img/clients/tablet.svg", "core/img/categories/auth.svg", "core/img/categories/bundles.svg", "core/img/categories/customization.svg", "core/img/categories/files.svg", "core/img/categories/games.svg", "core/img/categories/integration.svg", "core/img/categories/monitoring.svg", "core/img/categories/multimedia.svg", "core/img/categories/office.svg", "core/img/categories/organization.svg", "core/img/categories/social.svg", "core/img/categories/workflow.svg", "core/img/apps/circles.svg", "core/img/apps/notes.svg", "core/img/apps/richdocuments.svg", "core/img/caldav/attendees.png", "core/img/caldav/attendees.svg", "core/img/caldav/description.png", "core/img/caldav/description.svg", "core/img/caldav/link.png", "core/img/caldav/link.svg", "core/img/caldav/location.png", "core/img/caldav/location.svg", "core/img/caldav/organizer.png", "core/img/caldav/organizer.svg", "core/img/caldav/time.png", "core/img/caldav/time.svg", "core/img/caldav/title.png", "core/img/caldav/title.svg", "core/img/actions/add-folder-description.svg", "core/img/actions/add.svg", "core/img/actions/address.png", "core/img/actions/address.svg", "core/img/actions/alert-outline.svg", "core/img/actions/arrow-left.svg", "core/img/actions/arrow-right.svg", "core/img/actions/audio-off.svg", "core/img/actions/audio.svg", "core/img/actions/caret-white.svg", "core/img/actions/caret.svg", "core/img/actions/change.svg", "core/img/actions/clippy.svg", "core/img/actions/close.svg", "core/img/actions/comment.png", "core/img/actions/comment.svg", "core/img/actions/confirm-fade.svg", "core/img/actions/confirm-white.svg", "core/img/actions/confirm.svg", "core/img/actions/delete.png", "core/img/actions/delete.svg", "core/img/actions/details.svg", "core/img/actions/disabled-user.svg", "core/img/actions/disabled-users.svg", "core/img/actions/download.png", "core/img/actions/download.svg", "core/img/actions/edit.svg", "core/img/actions/error-white.svg", "core/img/actions/error.svg", "core/img/actions/external.svg", "core/img/actions/filter.svg", "core/img/actions/fullscreen.svg", "core/img/actions/group.svg", "core/img/actions/history.png", "core/img/actions/history.svg", "core/img/actions/info-white.svg", "core/img/actions/info.png", "core/img/actions/info.svg", "core/img/actions/logout.svg", "core/img/actions/mail-outline.svg", "core/img/actions/mail.svg", "core/img/actions/menu-sidebar.svg", "core/img/actions/menu.svg", "core/img/actions/more-white.svg", "core/img/actions/more.png", "core/img/actions/more.svg", "core/img/actions/password-white.svg", "core/img/actions/password.png", "core/img/actions/password.svg", "core/img/actions/pause.svg", "core/img/actions/phone-outline.svg", "core/img/actions/phone.svg", "core/img/actions/play-add.svg", "core/img/actions/play-next.svg", "core/img/actions/play-previous.svg", "core/img/actions/play.svg", "core/img/actions/profile.svg", "core/img/actions/projects.svg", "core/img/actions/public-white.svg", "core/img/actions/public.svg", "core/img/actions/quota.svg", "core/img/actions/recent.svg", "core/img/actions/rename.svg", "core/img/actions/screen-off.svg", "core/img/actions/screen.svg", "core/img/actions/search.svg", "core/img/actions/settings-dark.svg", "core/img/actions/settings.svg", "core/img/actions/share.png", "core/img/actions/share.svg", "core/img/actions/shared.svg", "core/img/actions/sound-off.svg", "core/img/actions/sound.svg", "core/img/actions/star-dark.svg", "core/img/actions/star.png", "core/img/actions/star.svg", "core/img/actions/starred.png", "core/img/actions/starred.svg", "core/img/actions/tag.png", "core/img/actions/tag.svg", "core/img/actions/template-add.svg", "core/img/actions/timezone.svg", "core/img/actions/toggle-background.svg", "core/img/actions/toggle-filelist.svg", "core/img/actions/toggle-pictures.svg", "core/img/actions/toggle.svg", "core/img/actions/triangle-e.svg", "core/img/actions/triangle-n.svg", "core/img/actions/triangle-s.svg", "core/img/actions/unshare.svg", "core/img/actions/upload.svg", "core/img/actions/user-admin.svg", "core/img/actions/user.svg", "core/img/actions/verified.svg", "core/img/actions/verify.svg", "core/img/actions/verifying.svg", "core/img/actions/video-off.svg", "core/img/actions/video-switch.svg", "core/img/actions/video.svg", "core/img/actions/view-close.svg", "core/img/actions/view-download.svg", "core/img/actions/view-next.svg", "core/img/actions/view-pause.svg", "core/img/actions/view-play.svg", "core/img/actions/view-previous.svg", "core/img/places/contacts-dark.png", "core/img/places/contacts.svg", "apps/user_status/img/app-dark.svg", "apps/user_status/img/app.svg", "apps/theming/img/app-dark.svg", "apps/theming/img/app.svg", "apps/updatenotification/img/app.svg", "apps/weather_status/img/app-dark.svg", "apps/weather_status/img/app.svg", "apps/updatenotification/img/notification.svg"] +path = ["apps/encryption/img/app.svg", "apps/federation/img/app.svg", "apps/files/img/add-color.png", "apps/files/img/add-color.svg", "apps/files/img/app-dark.svg", "apps/files/img/app.svg", "apps/files/img/change.png", "apps/files/img/change.svg", "apps/files/img/delete-color.png", "apps/files/img/delete-color.svg", "apps/files/img/folder.svg", "apps/files/img/folder-move.svg", "apps/files_external/img/app-dark.svg", "apps/files_external/img/app.svg", "apps/user_ldap/img/app-dark.svg", "apps/user_ldap/img/app.svg", "apps/comments/img/comments-dark.svg", "apps/comments/img/comments.svg", "apps/files_reminders/img/app-dark.svg", "apps/files_reminders/img/app.svg", "apps/files_versions/img/app-dark.svg", "apps/files_versions/img/app.svg", "apps/files_trashbin/img/app-dark.svg", "apps/files_trashbin/img/app.svg", "apps/theming/img/accessibility-dark.svg", "apps/testing/img/app.svg", "apps/sharebymail/img/app.svg", "apps/systemtags/img/app.svg", "apps/provisioning_api/img/app.svg", "apps/settings/img/change.svg", "apps/settings/img/help.svg", "apps/settings/img/password.svg", "apps/settings/img/personal.png", "apps/settings/img/personal.svg", "apps/settings/img/share.svg", "apps/settings/img/users-white.svg", "apps/settings/img/users.svg", "apps/files_sharing/img/app.svg", "apps/settings/img/admin.svg", "apps/settings/img/library_add_check.svg", "apps/settings/img/previews.svg", "apps/settings/img/settings.svg", "apps/settings/img/settings_apps.svg", "apps/settings/img/toggle-filelist.svg", "apps/webhook_listeners/img/app-dark.svg", "apps/webhook_listeners/img/app.svg", "core/img/filetypes/application-pdf.svg", "core/img/breadcrumb.svg", "core/img/filetypes/application.svg", "core/img/filetypes/audio.svg", "core/img/filetypes/file.svg", "core/img/filetypes/folder-drag-accept.svg", "core/img/filetypes/folder-encrypted.svg", "core/img/filetypes/folder-external.svg", "core/img/filetypes/folder-public.svg", "core/img/filetypes/folder-shared.svg", "core/img/filetypes/folder-starred.svg", "core/img/filetypes/folder.svg", "core/img/filetypes/image.svg", "core/img/filetypes/link.svg", "core/img/filetypes/location.svg", "core/img/filetypes/mindmap.svg", "core/img/filetypes/text-calendar.svg", "core/img/filetypes/text-code.svg", "core/img/filetypes/text-vcard.svg", "core/img/filetypes/text.svg", "core/img/filetypes/video.svg", "core/img/filetypes/x-office-document.svg", "core/img/filetypes/x-office-drawing.svg", "core/img/filetypes/x-office-form-template.svg", "core/img/filetypes/x-office-form.svg", "core/img/filetypes/x-office-presentation.svg", "core/img/filetypes/x-office-spreadsheet.svg", "core/img/places/calendar-dark.png", "core/img/places/calendar.png", "core/img/places/calendar.svg", "core/img/places/contacts-dark.png", "core/img/places/contacts.svg", "core/img/places/default-app-icon.svg", "core/img/places/files.svg", "core/img/places/home.svg", "core/img/places/link.svg", "core/img/places/music.svg", "core/img/places/picture.svg", "core/img/rating/s0.svg", "core/img/rating/s1.svg", "core/img/rating/s10.svg", "core/img/rating/s2.svg", "core/img/rating/s3.svg", "core/img/rating/s4.svg", "core/img/rating/s5.svg", "core/img/rating/s6.svg", "core/img/rating/s7.svg", "core/img/rating/s8.svg", "core/img/rating/s9.svg", "core/img/mail.svg", "core/img/rss.svg", "core/img/clients/desktop.svg", "core/img/clients/phone.svg", "core/img/clients/tablet.svg", "core/img/categories/auth.svg", "core/img/categories/bundles.svg", "core/img/categories/customization.svg", "core/img/categories/files.svg", "core/img/categories/games.svg", "core/img/categories/integration.svg", "core/img/categories/monitoring.svg", "core/img/categories/multimedia.svg", "core/img/categories/office.svg", "core/img/categories/organization.svg", "core/img/categories/social.svg", "core/img/categories/workflow.svg", "core/img/apps/circles.svg", "core/img/apps/notes.svg", "core/img/apps/richdocuments.svg", "core/img/caldav/attendees.png", "core/img/caldav/attendees.svg", "core/img/caldav/description.png", "core/img/caldav/description.svg", "core/img/caldav/link.png", "core/img/caldav/link.svg", "core/img/caldav/location.png", "core/img/caldav/location.svg", "core/img/caldav/organizer.png", "core/img/caldav/organizer.svg", "core/img/caldav/time.png", "core/img/caldav/time.svg", "core/img/caldav/title.png", "core/img/caldav/title.svg", "core/img/actions/add-folder-description.svg", "core/img/actions/add.svg", "core/img/actions/address.png", "core/img/actions/address.svg", "core/img/actions/alert-outline.svg", "core/img/actions/arrow-left.svg", "core/img/actions/arrow-right.svg", "core/img/actions/audio-off.svg", "core/img/actions/audio.svg", "core/img/actions/caret-white.svg", "core/img/actions/caret.svg", "core/img/actions/change.svg", "core/img/actions/clippy.svg", "core/img/actions/close.svg", "core/img/actions/comment.png", "core/img/actions/comment.svg", "core/img/actions/confirm-fade.svg", "core/img/actions/confirm-white.svg", "core/img/actions/confirm.svg", "core/img/actions/delete.png", "core/img/actions/delete.svg", "core/img/actions/details.svg", "core/img/actions/disabled-user.svg", "core/img/actions/disabled-users.svg", "core/img/actions/download.png", "core/img/actions/download.svg", "core/img/actions/edit.svg", "core/img/actions/error-white.svg", "core/img/actions/error.svg", "core/img/actions/external.svg", "core/img/actions/filter.svg", "core/img/actions/fullscreen.svg", "core/img/actions/group.svg", "core/img/actions/history.png", "core/img/actions/history.svg", "core/img/actions/info-white.svg", "core/img/actions/info.png", "core/img/actions/info.svg", "core/img/actions/logout.svg", "core/img/actions/mail-outline.svg", "core/img/actions/mail.svg", "core/img/actions/menu-sidebar.svg", "core/img/actions/menu.svg", "core/img/actions/more-white.svg", "core/img/actions/more.png", "core/img/actions/more.svg", "core/img/actions/password-white.svg", "core/img/actions/password.png", "core/img/actions/password.svg", "core/img/actions/pause.svg", "core/img/actions/phone-outline.svg", "core/img/actions/phone.svg", "core/img/actions/play-add.svg", "core/img/actions/play-next.svg", "core/img/actions/play-previous.svg", "core/img/actions/play.svg", "core/img/actions/profile.svg", "core/img/actions/projects.svg", "core/img/actions/public-white.svg", "core/img/actions/public.svg", "core/img/actions/quota.svg", "core/img/actions/recent.svg", "core/img/actions/rename.svg", "core/img/actions/screen-off.svg", "core/img/actions/screen.svg", "core/img/actions/search.svg", "core/img/actions/settings-dark.svg", "core/img/actions/settings.svg", "core/img/actions/share.png", "core/img/actions/share.svg", "core/img/actions/shared.svg", "core/img/actions/sound-off.svg", "core/img/actions/sound.svg", "core/img/actions/star-dark.svg", "core/img/actions/star.png", "core/img/actions/star.svg", "core/img/actions/starred.png", "core/img/actions/starred.svg", "core/img/actions/tag.png", "core/img/actions/tag.svg", "core/img/actions/template-add.svg", "core/img/actions/timezone.svg", "core/img/actions/toggle-background.svg", "core/img/actions/toggle-filelist.svg", "core/img/actions/toggle-pictures.svg", "core/img/actions/toggle.svg", "core/img/actions/triangle-e.svg", "core/img/actions/triangle-n.svg", "core/img/actions/triangle-s.svg", "core/img/actions/unshare.svg", "core/img/actions/upload.svg", "core/img/actions/user-admin.svg", "core/img/actions/user.svg", "core/img/actions/verified.svg", "core/img/actions/verify.svg", "core/img/actions/verifying.svg", "core/img/actions/video-off.svg", "core/img/actions/video-switch.svg", "core/img/actions/video.svg", "core/img/actions/view-close.svg", "core/img/actions/view-download.svg", "core/img/actions/view-next.svg", "core/img/actions/view-pause.svg", "core/img/actions/view-play.svg", "core/img/actions/view-previous.svg", "core/img/places/contacts-dark.png", "core/img/places/contacts.svg", "apps/user_status/img/app-dark.svg", "apps/user_status/img/app.svg", "apps/theming/img/app-dark.svg", "apps/theming/img/app.svg", "apps/updatenotification/img/app.svg", "apps/weather_status/img/app-dark.svg", "apps/weather_status/img/app.svg", "apps/updatenotification/img/notification.svg"] precedence = "aggregate" SPDX-FileCopyrightText = "2018-2024 Google LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/apps/settings/appinfo/info.xml b/apps/settings/appinfo/info.xml index 3c3eeceff87dd..908d6644e9119 100644 --- a/apps/settings/appinfo/info.xml +++ b/apps/settings/appinfo/info.xml @@ -33,6 +33,7 @@ OCA\Settings\Settings\Admin\ArtificialIntelligence OCA\Settings\Settings\Admin\Server OCA\Settings\Settings\Admin\Sharing + OCA\Settings\Settings\Admin\Previews OCA\Settings\Settings\Admin\Security OCA\Settings\Settings\Admin\Delegation OCA\Settings\Sections\Admin\Additional @@ -45,6 +46,7 @@ OCA\Settings\Sections\Admin\Security OCA\Settings\Sections\Admin\Server OCA\Settings\Sections\Admin\Sharing + OCA\Settings\Sections\Admin\Previews OCA\Settings\Settings\Admin\Users OCA\Settings\Sections\Admin\Users OCA\Settings\Settings\Personal\Additional diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index aa288301381b7..1cb16422178c3 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -33,6 +33,7 @@ 'OCA\\Settings\\Controller\\MailSettingsController' => $baseDir . '/../lib/Controller/MailSettingsController.php', 'OCA\\Settings\\Controller\\PersonalSettingsController' => $baseDir . '/../lib/Controller/PersonalSettingsController.php', 'OCA\\Settings\\Controller\\PresetController' => $baseDir . '/../lib/Controller/PresetController.php', + 'OCA\\Settings\\Controller\\PreviewAdminController' => $baseDir . '/../lib/Controller/PreviewAdminController.php', 'OCA\\Settings\\Controller\\ReasonsController' => $baseDir . '/../lib/Controller/ReasonsController.php', 'OCA\\Settings\\Controller\\TwoFactorSettingsController' => $baseDir . '/../lib/Controller/TwoFactorSettingsController.php', 'OCA\\Settings\\Controller\\UsersController' => $baseDir . '/../lib/Controller/UsersController.php', @@ -57,6 +58,7 @@ 'OCA\\Settings\\Sections\\Admin\\Office' => $baseDir . '/../lib/Sections/Admin/Office.php', 'OCA\\Settings\\Sections\\Admin\\Overview' => $baseDir . '/../lib/Sections/Admin/Overview.php', 'OCA\\Settings\\Sections\\Admin\\Presets' => $baseDir . '/../lib/Sections/Admin/Presets.php', + 'OCA\\Settings\\Sections\\Admin\\Previews' => $baseDir . '/../lib/Sections/Admin/Previews.php', 'OCA\\Settings\\Sections\\Admin\\Security' => $baseDir . '/../lib/Sections/Admin/Security.php', 'OCA\\Settings\\Sections\\Admin\\Server' => $baseDir . '/../lib/Sections/Admin/Server.php', 'OCA\\Settings\\Sections\\Admin\\Sharing' => $baseDir . '/../lib/Sections/Admin/Sharing.php', @@ -77,6 +79,7 @@ 'OCA\\Settings\\Settings\\Admin\\MailProvider' => $baseDir . '/../lib/Settings/Admin/MailProvider.php', 'OCA\\Settings\\Settings\\Admin\\Overview' => $baseDir . '/../lib/Settings/Admin/Overview.php', 'OCA\\Settings\\Settings\\Admin\\Presets' => $baseDir . '/../lib/Settings/Admin/Presets.php', + 'OCA\\Settings\\Settings\\Admin\\Previews' => $baseDir . '/../lib/Settings/Admin/Previews.php', 'OCA\\Settings\\Settings\\Admin\\Security' => $baseDir . '/../lib/Settings/Admin/Security.php', 'OCA\\Settings\\Settings\\Admin\\Server' => $baseDir . '/../lib/Settings/Admin/Server.php', 'OCA\\Settings\\Settings\\Admin\\Sharing' => $baseDir . '/../lib/Settings/Admin/Sharing.php', diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index ec3304c1e7530..a5df505377ed5 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.php @@ -48,6 +48,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\Controller\\MailSettingsController' => __DIR__ . '/..' . '/../lib/Controller/MailSettingsController.php', 'OCA\\Settings\\Controller\\PersonalSettingsController' => __DIR__ . '/..' . '/../lib/Controller/PersonalSettingsController.php', 'OCA\\Settings\\Controller\\PresetController' => __DIR__ . '/..' . '/../lib/Controller/PresetController.php', + 'OCA\\Settings\\Controller\\PreviewAdminController' => __DIR__ . '/..' . '/../lib/Controller/PreviewAdminController.php', 'OCA\\Settings\\Controller\\ReasonsController' => __DIR__ . '/..' . '/../lib/Controller/ReasonsController.php', 'OCA\\Settings\\Controller\\TwoFactorSettingsController' => __DIR__ . '/..' . '/../lib/Controller/TwoFactorSettingsController.php', 'OCA\\Settings\\Controller\\UsersController' => __DIR__ . '/..' . '/../lib/Controller/UsersController.php', @@ -72,6 +73,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\Sections\\Admin\\Office' => __DIR__ . '/..' . '/../lib/Sections/Admin/Office.php', 'OCA\\Settings\\Sections\\Admin\\Overview' => __DIR__ . '/..' . '/../lib/Sections/Admin/Overview.php', 'OCA\\Settings\\Sections\\Admin\\Presets' => __DIR__ . '/..' . '/../lib/Sections/Admin/Presets.php', + 'OCA\\Settings\\Sections\\Admin\\Previews' => __DIR__ . '/..' . '/../lib/Sections/Admin/Previews.php', 'OCA\\Settings\\Sections\\Admin\\Security' => __DIR__ . '/..' . '/../lib/Sections/Admin/Security.php', 'OCA\\Settings\\Sections\\Admin\\Server' => __DIR__ . '/..' . '/../lib/Sections/Admin/Server.php', 'OCA\\Settings\\Sections\\Admin\\Sharing' => __DIR__ . '/..' . '/../lib/Sections/Admin/Sharing.php', @@ -92,6 +94,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\Settings\\Admin\\MailProvider' => __DIR__ . '/..' . '/../lib/Settings/Admin/MailProvider.php', 'OCA\\Settings\\Settings\\Admin\\Overview' => __DIR__ . '/..' . '/../lib/Settings/Admin/Overview.php', 'OCA\\Settings\\Settings\\Admin\\Presets' => __DIR__ . '/..' . '/../lib/Settings/Admin/Presets.php', + 'OCA\\Settings\\Settings\\Admin\\Previews' => __DIR__ . '/..' . '/../lib/Settings/Admin/Previews.php', 'OCA\\Settings\\Settings\\Admin\\Security' => __DIR__ . '/..' . '/../lib/Settings/Admin/Security.php', 'OCA\\Settings\\Settings\\Admin\\Server' => __DIR__ . '/..' . '/../lib/Settings/Admin/Server.php', 'OCA\\Settings\\Settings\\Admin\\Sharing' => __DIR__ . '/..' . '/../lib/Settings/Admin/Sharing.php', diff --git a/apps/settings/img/previews.svg b/apps/settings/img/previews.svg new file mode 100644 index 0000000000000..f6025c789e525 --- /dev/null +++ b/apps/settings/img/previews.svg @@ -0,0 +1 @@ + diff --git a/apps/settings/lib/Controller/PreviewAdminController.php b/apps/settings/lib/Controller/PreviewAdminController.php new file mode 100644 index 0000000000000..b470c12a348d6 --- /dev/null +++ b/apps/settings/lib/Controller/PreviewAdminController.php @@ -0,0 +1,112 @@ + $settings Preview settings to persist + * @return DataResponse, array{}> + * @throws OCSBadRequestException Invalid settings payload + * + * 200: Settings saved + */ + #[AuthorizedAdminSetting(settings: Previews::class)] + #[PasswordConfirmationRequired] + #[ApiRoute(verb: 'PUT', url: '/api/admin/previews')] + public function update(array $settings): DataResponse { + try { + $this->previewAdminConfig->setSettings($settings); + } catch (\InvalidArgumentException $e) { + throw new OCSBadRequestException($e->getMessage()); + } + + return new DataResponse($this->previewAdminConfig->getSettings()); + } + + /** + * Test connectivity to an Imaginary preview service + * + * @param string|null $url Imaginary base URL + * @param string|null $key Optional Imaginary API key + * @return DataResponse + * @throws OCSBadRequestException Invalid URL + * + * 200: Connection test completed + */ + #[AuthorizedAdminSetting(settings: Previews::class)] + #[ApiRoute(verb: 'POST', url: '/api/admin/previews/imaginary/test')] + public function testImaginary(?string $url = null, ?string $key = null): DataResponse { + try { + $target = $this->previewAdminConfig->validateImaginaryUrl($url ?? ''); + } catch (\InvalidArgumentException $e) { + throw new OCSBadRequestException($e->getMessage()); + } + + if ($target === '') { + return new DataResponse([ + 'status' => 'unconfigured', + ]); + } + + try { + $client = $this->clientService->newClient(); + $options = [ + 'timeout' => 3, + 'connect_timeout' => 3, + 'nextcloud' => ['allow_local_address' => true], + ]; + if (is_string($key) && $key !== '') { + $options['query'] = ['key' => $key]; + } + $response = $client->get($target, $options); + $statusCode = $response->getStatusCode(); + $reachable = $statusCode >= 200 && $statusCode < 500; + return new DataResponse([ + 'status' => $reachable ? 'reachable' : 'unreachable', + 'httpCode' => $statusCode, + ]); + } catch (\Throwable $e) { + $this->logger->info('Imaginary connection test failed', [ + 'exception' => $e, + ]); + return new DataResponse([ + 'status' => 'unreachable', + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/apps/settings/lib/Sections/Admin/Previews.php b/apps/settings/lib/Sections/Admin/Previews.php new file mode 100644 index 0000000000000..cf2351d2f2742 --- /dev/null +++ b/apps/settings/lib/Sections/Admin/Previews.php @@ -0,0 +1,42 @@ +urlGenerator->imagePath('settings', 'previews.svg'); + } + + #[\Override] + public function getID(): string { + return 'previews'; + } + + #[\Override] + public function getName(): string { + return $this->l->t('Previews'); + } + + #[\Override] + public function getPriority(): int { + return 6; + } +} diff --git a/apps/settings/lib/Settings/Admin/Previews.php b/apps/settings/lib/Settings/Admin/Previews.php new file mode 100644 index 0000000000000..171bc179222fd --- /dev/null +++ b/apps/settings/lib/Settings/Admin/Previews.php @@ -0,0 +1,56 @@ +initialState->provideInitialState('previewsSettings', $this->previewAdminConfig->getSettings()); + $this->initialState->provideInitialState('previewsDocumentation', 'https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/previews_configuration.html'); + + Util::addScript($this->appName, 'vue-settings-admin-previews'); + return new TemplateResponse($this->appName, 'settings/admin/previews', [], ''); + } + + #[\Override] + public function getSection(): string { + return 'previews'; + } + + #[\Override] + public function getPriority(): int { + return 0; + } + + #[\Override] + public function getAuthorizedAppConfig(): array { + return [ + 'preview' => ['/jpeg_quality/', '/webp_quality/'], + ]; + } + + #[\Override] + public function getName(): ?string { + return null; + } +} diff --git a/apps/settings/src/admin-settings-previews.ts b/apps/settings/src/admin-settings-previews.ts new file mode 100644 index 0000000000000..ec8b32393b9a8 --- /dev/null +++ b/apps/settings/src/admin-settings-previews.ts @@ -0,0 +1,13 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import AdminSettingsPreviews from './views/AdminSettingsPreviews.vue' + +export default new Vue({ + name: 'AdminSettingsPreviewsSection', + el: '#vue-admin-settings-previews', + render: (h) => h(AdminSettingsPreviews), +}) diff --git a/apps/settings/src/views/AdminSettingsPreviews.vue b/apps/settings/src/views/AdminSettingsPreviews.vue new file mode 100644 index 0000000000000..c8f8d8d2b5d1e --- /dev/null +++ b/apps/settings/src/views/AdminSettingsPreviews.vue @@ -0,0 +1,1214 @@ + + + + + + + diff --git a/apps/settings/templates/settings/admin/previews.php b/apps/settings/templates/settings/admin/previews.php new file mode 100644 index 0000000000000..44071317e73bf --- /dev/null +++ b/apps/settings/templates/settings/admin/previews.php @@ -0,0 +1,8 @@ + + +
diff --git a/apps/settings/tests/Controller/PreviewAdminControllerTest.php b/apps/settings/tests/Controller/PreviewAdminControllerTest.php new file mode 100644 index 0000000000000..13468568f69e4 --- /dev/null +++ b/apps/settings/tests/Controller/PreviewAdminControllerTest.php @@ -0,0 +1,61 @@ +adminConfig = $this->createMock(PreviewAdminConfig::class); + $this->controller = new PreviewAdminController( + 'settings', + $this->createMock(IRequest::class), + $this->adminConfig, + $this->createMock(IClientService::class), + $this->createMock(LoggerInterface::class), + ); + } + + public function testUpdatePersistsValidPayload(): void { + $payload = ['enablePreviews' => true, 'previewMaxX' => 2048]; + $this->adminConfig->expects($this->once())->method('setSettings')->with($payload); + $this->adminConfig->method('getSettings')->willReturn($payload + ['enablePreviews' => true]); + + $response = $this->controller->update($payload); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertTrue($response->getData()['enablePreviews']); + } + + public function testUpdateRejectsInvalidPayload(): void { + $this->adminConfig->method('setSettings')->willThrowException(new \InvalidArgumentException('Refusing to write an empty preview provider list')); + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('Refusing to write an empty preview provider list'); + $this->controller->update(['enabledPreviewProviders' => []]); + } + + public function testTestImaginaryRejectsGarbageUrl(): void { + $this->adminConfig->method('validateImaginaryUrl')->willThrowException(new \InvalidArgumentException('Imaginary URL must use http or https')); + $this->expectException(OCSBadRequestException::class); + $this->expectExceptionMessage('Imaginary URL must use http or https'); + $this->controller->testImaginary('not-a-url'); + } +} diff --git a/apps/settings/tests/Sections/Admin/PreviewsTest.php b/apps/settings/tests/Sections/Admin/PreviewsTest.php new file mode 100644 index 0000000000000..0776180b7450c --- /dev/null +++ b/apps/settings/tests/Sections/Admin/PreviewsTest.php @@ -0,0 +1,43 @@ +urlGenerator = $this->createMock(IURLGenerator::class); + $this->l10n = $this->createMock(IL10N::class); + $this->section = new Previews($this->l10n, $this->urlGenerator); + } + + public function testGetIconUsesSettingsPreviewSvg(): void { + $this->urlGenerator->expects($this->once()) + ->method('imagePath') + ->with('settings', 'previews.svg') + ->willReturn('/apps/settings/img/previews.svg'); + + $this->assertSame('/apps/settings/img/previews.svg', $this->section->getIcon()); + } + + public function testGetId(): void { + $this->assertSame('previews', $this->section->getID()); + } +} diff --git a/build/frontend-legacy/webpack.modules.cjs b/build/frontend-legacy/webpack.modules.cjs index 0dae644ae7e66..5a1cf64c9a9f2 100644 --- a/build/frontend-legacy/webpack.modules.cjs +++ b/build/frontend-legacy/webpack.modules.cjs @@ -53,6 +53,7 @@ module.exports = { 'vue-settings-admin-security': path.join(__dirname, 'apps/settings/src', 'main-admin-security.js'), 'vue-settings-admin-settings-presets': path.join(__dirname, 'apps/settings/src', 'main-admin-settings-presets.js'), 'vue-settings-admin-sharing': path.join(__dirname, 'apps/settings/src', 'admin-settings-sharing.ts'), + 'vue-settings-admin-previews': path.join(__dirname, 'apps/settings/src', 'admin-settings-previews.ts'), 'vue-settings-nextcloud-pdf': path.join(__dirname, 'apps/settings/src', 'main-nextcloud-pdf.js'), 'vue-settings-profile-contact': path.join(__dirname, 'apps/settings/src', 'main-profile-contact.js'), 'vue-settings-language-locale': path.join(__dirname, 'apps/settings/src', 'main-language-locale.js'), diff --git a/config/config.sample.php b/config/config.sample.php index 6a2ab485c2269..a84d33d5d3ecd 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -1640,6 +1640,12 @@ * - ``OC\Preview\Imaginary`` * - ``OC\Preview\ImaginaryPDF`` * + * When ``preview_imaginary_url`` is set and this key is still unset, Nextcloud + * tries ``OC\Preview\Imaginary`` first (as in AIO and the server-tuning + * guide), then the built-in image providers. ``OC\Preview\HEIC`` is also + * enabled as a fallback when ImageMagick can decode HEIC, because Imaginary + * does not handle every HEIC/HEIF file. + * * Defaults to the following providers: * * - ``OC\Preview\PNG`` @@ -1667,6 +1673,20 @@ 'OC\Preview\OpenDocument', ], + /** + * Output format used only by the Imaginary preview providers + * (``OC\Preview\Imaginary`` and ``OC\Preview\ImaginaryPDF``). + * Other providers ignore this key and keep the source image type. + * + * Valid values: ``jpeg``, ``webp`` + * + * ``webp`` forces WebP for every Imaginary source. ``jpeg`` writes JPEG + * for most images and PNG for GIF, PDF, PNG, SVG, and Illustrator files. + * + * Defaults to ``jpeg`` + */ + 'preview_format' => 'jpeg', + /** * Maximum file size for file metadata generation. * diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php index 94fea7314b565..375ddbe39301d 100644 --- a/lib/private/Preview/Generator.php +++ b/lib/private/Preview/Generator.php @@ -349,60 +349,74 @@ private function getMaxPreview(array $previews, File $file, string $mimeType, ?s * @throws NotFoundException */ private function generateProviderPreview(File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, ?string $version): Preview { - $previewProviders = $this->previewManager->getProviders(); - foreach ($previewProviders as $supportedMimeType => $providers) { - // Filter out providers that does not support this mime + $entries = []; + foreach ($this->previewManager->getProviders() as $supportedMimeType => $providers) { if (!preg_match($supportedMimeType, $mimeType)) { continue; } foreach ($providers as $providerClosure) { - $provider = $this->helper->getProvider($providerClosure); if (!$provider) { continue; } - if (!$provider->isAvailable($file)) { continue; } + $entries[] = [ + 'class' => ltrim($provider::class, '\\'), + 'provider' => $provider, + ]; + } + } - $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new'); - $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency); - try { - $this->logger->debug('Calling preview provider for {mimeType} with width={width}, height={height}', [ - 'mimeType' => $mimeType, - 'width' => $width, - 'height' => $height, - ]); - $preview = $this->helper->getThumbnail($provider, $file, $width, $height); - } finally { - self::unguardWithSemaphore($sem); - } + foreach ($entries as $entry) { + $provider = $entry['provider']; + $class = $entry['class']; - if (!($preview instanceof IImage)) { - continue; - } + $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new'); + $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency); + try { + $this->logger->debug('Calling preview provider {provider} for {mimeType} with width={width}, height={height}', [ + 'provider' => $class, + 'mimeType' => $mimeType, + 'width' => $width, + 'height' => $height, + ]); + $preview = $this->helper->getThumbnail($provider, $file, $width, $height); + } catch (\Throwable $e) { + $this->logger->warning('Preview provider {provider} failed for {mimeType}', [ + 'provider' => $class, + 'mimeType' => $mimeType, + 'exception' => $e, + ]); + continue; + } finally { + self::unguardWithSemaphore($sem); + } - try { - $previewEntry = new Preview(); - $previewEntry->generateId(); - $previewEntry->setFileId($file->getId()); - $previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId()); - $previewEntry->setSourceMimeType($file->getMimeType()); - $previewEntry->setWidth($preview->width()); - $previewEntry->setHeight($preview->height()); - $previewEntry->setVersion($version); - $previewEntry->setMax($max); - $previewEntry->setCropped($crop); - $previewEntry->setEncrypted(false); - $previewEntry->setMimetype($preview->dataMimeType()); - $previewEntry->setEtag($file->getEtag()); - $previewEntry->setMtime((new \DateTime())->getTimestamp()); - return $this->savePreview($previewEntry, $preview); - } catch (NotPermittedException) { - throw new NotFoundException(); - } + if (!($preview instanceof IImage)) { + continue; + } + + try { + $previewEntry = new Preview(); + $previewEntry->generateId(); + $previewEntry->setFileId($file->getId()); + $previewEntry->setStorageId($file->getMountPoint()->getNumericStorageId()); + $previewEntry->setSourceMimeType($file->getMimeType()); + $previewEntry->setWidth($preview->width()); + $previewEntry->setHeight($preview->height()); + $previewEntry->setVersion($version); + $previewEntry->setMax($max); + $previewEntry->setCropped($crop); + $previewEntry->setEncrypted(false); + $previewEntry->setMimetype($preview->dataMimeType()); + $previewEntry->setEtag($file->getEtag()); + $previewEntry->setMtime((new \DateTime())->getTimestamp()); + return $this->savePreview($previewEntry, $preview); + } catch (NotPermittedException) { + throw new NotFoundException(); } } diff --git a/lib/private/Preview/PreviewAdminConfig.php b/lib/private/Preview/PreviewAdminConfig.php new file mode 100644 index 0000000000000..8acf49ca693e9 --- /dev/null +++ b/lib/private/Preview/PreviewAdminConfig.php @@ -0,0 +1,635 @@ + */ + private const IMAGICK_FORMATS = ['SVG', 'TIFF', 'PDF', 'AI', 'PSD', 'EPS', 'TTF', 'HEIC', 'TGA', 'SGI']; + + /** + * Full admin-table order from the last providers save in this request. + * + * @var list|null + */ + private ?array $providerTableOrder = null; + + public function __construct( + private readonly IConfig $config, + private readonly IAppConfig $appConfig, + private readonly ?IMagickSupport $imagickSupport = null, + private readonly ?IBinaryFinder $binaryFinder = null, + ) { + } + + /** + * Built-in defaults when ``enabledPreviewProviders`` is unset. + * + * @return list + */ + public static function getBuiltinDefaultProviders(): array { + return [ + MarkDown::class, + TXT::class, + OpenDocument::class, + PNG::class, + JPEG::class, + GIF::class, + BMP::class, + XBitmap::class, + Krita::class, + WebP::class, + ]; + } + + /** + * Default enabledPreviewProviders list used when the key is unset. + * + * @return list + */ + public static function getDefaultEnabledProviders(): array { + return self::getBuiltinDefaultProviders(); + } + + /** + * Nextcloud-encouraged provider list. + * + * Matches core defaults, plus Imaginary first when it is configured (server + * tuning / AIO). Native HEIC is appended as a fallback because Imaginary + * does not handle every HEIC/HEIF file. + * + * @return list + */ + public static function getRecommendedEnabledProviders(bool $imaginaryConfigured, bool $heicFallback = false): array { + $providers = self::getBuiltinDefaultProviders(); + if (!$imaginaryConfigured) { + return $providers; + } + $providers = array_merge([Imaginary::class], $providers); + if ($heicFallback) { + $providers[] = HEIC::class; + } + return array_values(array_unique($providers)); + } + + /** + * Providers disabled by default due to security, performance, or privacy + * concerns. They remain selectable, but Nextcloud discourages enabling + * them and considers them unsupported. + * + * Imaginary and the legacy Image helper are opt-in alternatives, not + * members of this list. + * + * @see https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/previews_configuration.html + */ + public static function isUnsupportedProvider(string $class): bool { + $class = self::normalizeClassName($class); + if (in_array($class, self::getBuiltinDefaultProviders(), true)) { + return false; + } + if (in_array($class, [Imaginary::class, ImaginaryPDF::class, Image::class], true)) { + return false; + } + foreach (self::getProviderCatalog() as $entry) { + if ($entry['class'] === $class) { + return true; + } + } + return false; + } + + /** + * Known core providers shown in the admin UI (enabled and disabled). + * + * @return list}> + */ + public static function getProviderCatalog(): array { + return [ + self::catalogEntry(PNG::class, 'PNG', 'image/png', 'none', null, ['image/png']), + self::catalogEntry(JPEG::class, 'JPEG', 'image/jpeg', 'none', null, ['image/jpeg']), + self::catalogEntry(GIF::class, 'GIF', 'image/gif', 'none', null, ['image/gif']), + self::catalogEntry(BMP::class, 'BMP', 'image/bmp', 'none', null, ['image/bmp']), + self::catalogEntry(XBitmap::class, 'XBitmap', 'image/x-xbitmap', 'none', null, ['image/x-xbitmap']), + self::catalogEntry(WebP::class, 'WebP', 'image/webp', 'none', null, ['image/webp']), + self::catalogEntry(Krita::class, 'Krita', 'application/x-krita', 'none', null, ['application/x-krita']), + self::catalogEntry(HEIC::class, 'HEIC', 'image/heic, image/heif', 'imagick', 'HEIC', ['image/heic', 'image/heif']), + self::catalogEntry(TIFF::class, 'TIFF', 'image/tiff', 'imagick', 'TIFF', ['image/tiff']), + self::catalogEntry(SVG::class, 'SVG', 'image/svg+xml', 'imagick', 'SVG', ['image/svg+xml']), + self::catalogEntry(TGA::class, 'TGA', 'image/tga', 'imagick', 'TGA', ['image/tga']), + self::catalogEntry(SGI::class, 'SGI', 'image/sgi', 'imagick', 'SGI', ['image/sgi']), + self::catalogEntry(Imaginary::class, 'Imaginary', 'images (bmp, png, jpeg, gif, heic, heif, svg, tiff, webp), illustrator', 'imaginary', null, ['image/bmp', 'image/x-bitmap', 'image/png', 'image/jpeg', 'image/gif', 'image/heic', 'image/heif', 'image/svg+xml', 'image/tiff', 'image/webp', 'application/illustrator']), + self::catalogEntry(ImaginaryPDF::class, 'Imaginary PDF', 'application/pdf', 'imaginary', null, ['application/pdf']), + self::catalogEntry(PDF::class, 'PDF', 'application/pdf', 'imagick', 'PDF', ['application/pdf']), + self::catalogEntry(Postscript::class, 'Postscript', 'application/postscript', 'imagick', 'EPS', ['application/postscript']), + self::catalogEntry(Illustrator::class, 'Illustrator', 'application/illustrator', 'imagick', 'AI', ['application/illustrator']), + self::catalogEntry(Photoshop::class, 'Photoshop', 'application/x-photoshop', 'imagick', 'PSD', ['application/x-photoshop']), + self::catalogEntry(Font::class, 'Font', 'application/font-sfnt', 'imagick', 'TTF', ['application/font-sfnt']), + self::catalogEntry(MarkDown::class, 'Markdown', 'text/markdown', 'none', null, ['text/markdown']), + self::catalogEntry(TXT::class, 'Plain text', 'text/plain', 'none', null, ['text/plain']), + self::catalogEntry(OpenDocument::class, 'OpenDocument', 'application/vnd.oasis.opendocument.*', 'none', null, ['application/vnd.oasis.opendocument.*']), + self::catalogEntry(MSOfficeDoc::class, 'MS Office Doc', 'application/msword', 'office', null, ['application/msword']), + self::catalogEntry(MSOffice2003::class, 'MS Office 2003', 'application/vnd.ms-*', 'office', null, ['application/vnd.ms-*']), + self::catalogEntry(MSOffice2007::class, 'MS Office 2007', 'application/vnd.openxmlformats-officedocument.*', 'office', null, ['application/vnd.openxmlformats-officedocument.*']), + self::catalogEntry(StarOffice::class, 'StarOffice', 'application/vnd.sun.xml.*', 'office', null, ['application/vnd.sun.xml.*']), + self::catalogEntry(EMF::class, 'EMF', 'image/emf', 'office', null, ['image/emf']), + self::catalogEntry(MP3::class, 'MP3', 'audio/mpeg', 'none', null, ['audio/mpeg']), + self::catalogEntry(Movie::class, 'Movie', 'video/*', 'ffmpeg', null, ['video/*']), + self::catalogEntry(Image::class, 'Image (legacy)', 'enables PNG, JPEG, GIF, BMP, XBitmap, Krita, WebP', 'none', null, ['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/x-xbitmap', 'application/x-krita', 'image/webp']), + ]; + } + + /** + * @param class-string $class + * @param list $sourceMimes + * @return array{class: class-string, name: string, mime: string, requirement: string, imagickFormat: ?string, sourceMimes: list} + */ + private static function catalogEntry( + string $class, + string $name, + string $mime, + string $requirement, + ?string $imagickFormat, + array $sourceMimes, + ): array { + return [ + 'class' => $class, + 'name' => $name, + 'mime' => $mime, + 'requirement' => $requirement, + 'imagickFormat' => $imagickFormat, + 'sourceMimes' => $sourceMimes, + ]; + } + + /** + * @return list + */ + public function getEnabledPreviewProviders(): array { + $value = $this->config->getSystemValue('enabledPreviewProviders', null); + if (!is_array($value)) { + return $this->getRecommendedEnabledProvidersForInstance(); + } + $providers = []; + foreach ($value as $class) { + if (!is_string($class) || $class === '') { + continue; + } + $providers[] = self::normalizeClassName($class); + } + return array_values(array_unique($providers)); + } + + /** + * Snapshot of all preview settings for the admin UI. + * + * @return array + */ + public function getSettings(): array { + $maxX = $this->config->getSystemValue('preview_max_x', 4096); + $maxY = $this->config->getSystemValue('preview_max_y', 4096); + $enabled = $this->getEnabledPreviewProviders(); + $enabledSet = array_fill_keys($enabled, true); + $detection = $this->getDetection(); + + $order = []; + $seen = []; + foreach ($this->providerTableOrder ?? $this->getDefaultProviderOrder() as $class) { + $class = self::normalizeClassName((string)$class); + if ($class === '' || isset($seen[$class])) { + continue; + } + $order[] = $class; + $seen[$class] = true; + } + foreach ($enabled as $class) { + if (isset($seen[$class])) { + continue; + } + $order[] = $class; + $seen[$class] = true; + } + foreach (self::getProviderCatalog() as $entry) { + if (isset($seen[$entry['class']])) { + continue; + } + $order[] = $entry['class']; + $seen[$entry['class']] = true; + } + + $providers = []; + foreach ($order as $class) { + $meta = $this->findCatalogEntry($class) ?? [ + 'class' => $class, + 'name' => $this->classBasename($class), + 'mime' => '', + 'requirement' => 'none', + 'imagickFormat' => null, + 'sourceMimes' => [], + ]; + $providers[] = $this->providerRow($meta, isset($enabledSet[$class]), $detection); + } + + $ffmpegConfigured = $this->configuredBinary('preview_ffmpeg_path'); + $ffprobeConfigured = $this->configuredBinary('preview_ffprobe_path'); + $officeConfigured = $this->configuredBinary('preview_libreoffice_path'); + + return [ + 'enablePreviews' => $this->config->getSystemValueBool('enable_previews', true), + 'previewMaxX' => is_numeric($maxX) ? (int)$maxX : null, + 'previewMaxY' => is_numeric($maxY) ? (int)$maxY : null, + 'previewMaxMemory' => $this->config->getSystemValueInt('preview_max_memory', 256), + 'previewMaxFilesizeImage' => $this->config->getSystemValueInt('preview_max_filesize_image', 50), + 'jpegQuality' => $this->appConfig->getValueInt('preview', 'jpeg_quality', 80), + 'webpQuality' => $this->appConfig->getValueInt('preview', 'webp_quality', 80), + 'previewFormat' => $this->config->getSystemValueString('preview_format', 'jpeg'), + 'previewConcurrencyNew' => $this->getOptionalInt('preview_concurrency_new'), + 'previewConcurrencyAll' => $this->getOptionalInt('preview_concurrency_all'), + 'previewExpirationDays' => $this->config->getSystemValueInt('preview_expiration_days', 0), + 'imaginaryUrl' => $this->config->getSystemValueString('preview_imaginary_url', ''), + 'imaginaryKey' => $this->config->getSystemValueString('preview_imaginary_key', ''), + 'ffmpegPath' => $ffmpegConfigured, + 'ffprobePath' => $ffprobeConfigured, + 'libreofficePath' => $officeConfigured, + 'providers' => $providers, + 'defaultEnabledProviders' => $this->getRecommendedEnabledProvidersForInstance(), + 'defaultProviderOrder' => $this->getDefaultProviderOrder(), + 'detection' => $detection, + 'configIsReadonly' => $this->config->getSystemValueBool('config_is_read_only', false), + ]; + } + + /** + * Persist admin UI payload. + * + * @param array $settings + * @throws \InvalidArgumentException + */ + public function setSettings(array $settings): void { + if (array_key_exists('enablePreviews', $settings)) { + $this->config->setSystemValue('enable_previews', (bool)$settings['enablePreviews']); + } + if (array_key_exists('previewMaxX', $settings)) { + $this->setNullableInt('preview_max_x', $settings['previewMaxX'], 1); + } + if (array_key_exists('previewMaxY', $settings)) { + $this->setNullableInt('preview_max_y', $settings['previewMaxY'], 1); + } + if (array_key_exists('previewMaxMemory', $settings)) { + $this->setIntKey('preview_max_memory', $settings['previewMaxMemory'], -1); + } + if (array_key_exists('previewMaxFilesizeImage', $settings)) { + $this->setIntKey('preview_max_filesize_image', $settings['previewMaxFilesizeImage'], -1); + } + if (array_key_exists('jpegQuality', $settings)) { + $this->setQuality('jpeg_quality', $settings['jpegQuality'], 'JPEG'); + } + if (array_key_exists('webpQuality', $settings)) { + $this->setQuality('webp_quality', $settings['webpQuality'], 'WebP'); + } + if (array_key_exists('previewConcurrencyNew', $settings)) { + $this->setOptionalPositiveInt('preview_concurrency_new', $settings['previewConcurrencyNew']); + } + if (array_key_exists('previewConcurrencyAll', $settings)) { + $this->setOptionalPositiveInt('preview_concurrency_all', $settings['previewConcurrencyAll']); + } + if (array_key_exists('previewExpirationDays', $settings)) { + $this->setIntKey('preview_expiration_days', $settings['previewExpirationDays'], 0); + } + if (array_key_exists('ffmpegPath', $settings)) { + $this->setBinaryPath('preview_ffmpeg_path', $settings['ffmpegPath']); + } + if (array_key_exists('ffprobePath', $settings)) { + $this->setBinaryPath('preview_ffprobe_path', $settings['ffprobePath']); + } + if (array_key_exists('libreofficePath', $settings)) { + $this->setBinaryPath('preview_libreoffice_path', $settings['libreofficePath']); + } + if (array_key_exists('previewFormat', $settings)) { + $format = is_string($settings['previewFormat']) ? strtolower($settings['previewFormat']) : ''; + if (!in_array($format, ['jpeg', 'webp'], true)) { + throw new \InvalidArgumentException('Preview format must be jpeg or webp'); + } + $this->config->setSystemValue('preview_format', $format); + } + if (array_key_exists('imaginaryUrl', $settings)) { + $this->config->setSystemValue('preview_imaginary_url', $this->validateImaginaryUrl((string)$settings['imaginaryUrl'])); + } + if (array_key_exists('imaginaryKey', $settings)) { + $key = is_string($settings['imaginaryKey']) ? $settings['imaginaryKey'] : ''; + $this->config->setSystemValue('preview_imaginary_key', $key); + } + if (array_key_exists('providers', $settings)) { + $this->setEnabledProvidersFromRows($settings['providers'], (bool)($settings['confirmEmptyProviders'] ?? false)); + } elseif (array_key_exists('enabledPreviewProviders', $settings)) { + $this->setEnabledProviders($settings['enabledPreviewProviders'], (bool)($settings['confirmEmptyProviders'] ?? false)); + } + } + + public function validateImaginaryUrl(string $url): string { + $url = trim($url); + if ($url === '') { + return ''; + } + $parts = parse_url($url); + if ($parts === false || !isset($parts['scheme'], $parts['host'])) { + throw new \InvalidArgumentException('Imaginary URL must be a valid http(s) URL'); + } + if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { + throw new \InvalidArgumentException('Imaginary URL must use http or https'); + } + return $url; + } + + public static function normalizeClassName(string $class): string { + return ltrim($class, '\\'); + } + + /** + * @param list $providers + * @return list + */ + public function setEnabledProviders(array $providers, bool $confirmEmpty = false): array { + $normalized = []; + foreach ($providers as $class) { + if (!is_string($class) || $class === '') { + continue; + } + $class = self::normalizeClassName($class); + if (!preg_match('/^[A-Za-z0-9_\\\\]+$/', $class)) { + throw new \InvalidArgumentException('Invalid preview provider class'); + } + $normalized[] = $class; + } + $normalized = array_values(array_unique($normalized)); + if ($normalized === [] && !$confirmEmpty) { + throw new \InvalidArgumentException('Refusing to write an empty preview provider list'); + } + $this->config->setSystemValue('enabledPreviewProviders', $normalized); + return $normalized; + } + + /** + * @param list $rows + */ + private function setEnabledProvidersFromRows(array $rows, bool $confirmEmpty): void { + $detection = $this->getDetection(); + $enabled = []; + $tableOrder = []; + foreach ($rows as $row) { + if (!is_array($row) || !isset($row['class']) || !is_string($row['class'])) { + continue; + } + $class = self::normalizeClassName($row['class']); + if ($class !== '') { + $tableOrder[] = $class; + } + if (!($row['enabled'] ?? false)) { + continue; + } + $entry = $this->findCatalogEntry($class); + if ($entry !== null) { + $requirement = $entry['requirement'] ?? 'none'; + $format = $entry['imagickFormat'] ?? null; + if (!$this->isProviderAvailable($requirement, is_string($format) ? $format : null, $detection)) { + continue; + } + } + $enabled[] = $class; + } + $this->providerTableOrder = array_values(array_unique($tableOrder)); + $this->setEnabledProviders($enabled, $confirmEmpty); + } + + private function setNullableInt(string $key, mixed $value, int $min): void { + if ($value === null || $value === '') { + $this->config->deleteSystemValue($key); + return; + } + $this->setIntKey($key, $value, $min); + } + + private function setIntKey(string $key, mixed $value, int $min): void { + $int = $this->toInt($value); + if ($int === null || $int < $min) { + throw new \InvalidArgumentException('Invalid integer for ' . $key); + } + $this->config->setSystemValue($key, $int); + } + + private function toInt(mixed $value): ?int { + if (is_int($value)) { + return $value; + } + if (is_string($value) && is_numeric($value)) { + return (int)$value; + } + if (is_float($value)) { + return (int)$value; + } + return null; + } + + /** + * @return array{class: class-string, name: string, mime: string, requirement: string, imagickFormat: ?string, sourceMimes: list}|null + */ + private function findCatalogEntry(string $class): ?array { + foreach (self::getProviderCatalog() as $entry) { + if ($entry['class'] === $class) { + return $entry; + } + } + return null; + } + + private function classBasename(string $class): string { + $parts = explode('\\', $class); + return (string)end($parts); + } + + /** + * @param array{class: string, name: string, mime: string, requirement?: string, imagickFormat?: ?string, sourceMimes?: list} $entry + * @param array $detection + * @return array + */ + private function providerRow(array $entry, bool $enabled, array $detection): array { + $requirement = $entry['requirement'] ?? 'none'; + $format = $entry['imagickFormat'] ?? null; + return [ + 'class' => $entry['class'], + 'name' => $entry['name'], + 'mime' => $entry['mime'], + 'sourceMimes' => $entry['sourceMimes'] ?? [], + 'enabled' => $enabled, + 'requirement' => $requirement, + 'imagickFormat' => $format, + 'available' => $this->isProviderAvailable($requirement, is_string($format) ? $format : null, $detection), + 'unsupported' => self::isUnsupportedProvider($entry['class']), + ]; + } + + /** + * @param array $detection + */ + private function isProviderAvailable(string $requirement, ?string $imagickFormat, array $detection): bool { + return match ($requirement) { + 'imagick' => (bool)$detection['imagick'] + && ($imagickFormat === null || (bool)($detection['imagickFormats'][$imagickFormat] ?? false)), + 'ffmpeg' => (bool)$detection['ffmpegFound'], + 'office' => (bool)$detection['officeFound'], + 'imaginary' => (bool)$detection['imaginaryConfigured'], + default => true, + }; + } + + /** + * @return array + */ + public function getDetection(): array { + $imagickSupport = $this->imagickSupport; + $imagick = $imagickSupport !== null && $imagickSupport->hasExtension(); + $formats = []; + foreach (self::IMAGICK_FORMATS as $format) { + $formats[$format] = $imagick && $imagickSupport->supportsFormat($format); + } + + $ffmpegDetected = $this->resolveBinary('preview_ffmpeg_path', ['ffmpeg']); + $ffprobeDetected = $this->resolveBinary('preview_ffprobe_path', ['ffprobe']) ?? $ffmpegDetected; + $officeDetected = $this->resolveBinary('preview_libreoffice_path', ['libreoffice', 'openoffice']); + + return [ + 'imagick' => $imagick, + 'imagickFormats' => $formats, + 'ffmpegFound' => $ffmpegDetected !== null, + 'ffmpegDetectedPath' => $ffmpegDetected, + 'ffprobeFound' => $ffprobeDetected !== null, + 'ffprobeDetectedPath' => $ffprobeDetected, + 'officeFound' => $officeDetected !== null, + 'officeDetectedPath' => $officeDetected, + 'imaginaryConfigured' => $this->config->getSystemValueString('preview_imaginary_url', '') !== '', + 'cpuCount' => $this->detectCpuCount(), + ]; + } + + /** + * @return list + */ + private function getRecommendedEnabledProvidersForInstance(): array { + $detection = $this->getDetection(); + return self::getRecommendedEnabledProviders( + (bool)$detection['imaginaryConfigured'], + (bool)($detection['imagickFormats']['HEIC'] ?? false), + ); + } + + /** + * Canonical provider list order for “Reset to defaults”: recommended + * enabled providers first, then the rest of the catalog. + * + * @return list + */ + private function getDefaultProviderOrder(): array { + /** @var list $order */ + $order = $this->getRecommendedEnabledProvidersForInstance(); + foreach (self::getProviderCatalog() as $entry) { + if (!in_array($entry['class'], $order, true)) { + $order[] = $entry['class']; + } + } + return $order; + } + + /** + * @param list $searchNames + */ + private function resolveBinary(string $configKey, array $searchNames): ?string { + $configured = $this->configuredBinary($configKey); + if ($configured !== '') { + return $configured; + } + $finder = $this->getBinaryFinder(); + if ($finder === null) { + return null; + } + foreach ($searchNames as $name) { + $found = $finder->findBinaryPath($name); + if (is_string($found) && $found !== '') { + return $found; + } + } + return null; + } + + private function getBinaryFinder(): ?IBinaryFinder { + if ($this->binaryFinder instanceof IBinaryFinder) { + return $this->binaryFinder; + } + try { + return Server::get(IBinaryFinder::class); + } catch (\Throwable) { + return null; + } + } + + private function configuredBinary(string $key): string { + $value = $this->config->getSystemValue($key, ''); + return is_string($value) ? $value : ''; + } + + private function detectCpuCount(): int { + if (is_readable('/proc/cpuinfo')) { + return substr_count((string)file_get_contents('/proc/cpuinfo'), 'processor'); + } + return 0; + } + + private function getOptionalInt(string $key): ?int { + $unset = new \stdClass(); + $value = $this->config->getSystemValue($key, $unset); + if ($value === $unset || $value === null || $value === '') { + return null; + } + return is_numeric($value) ? (int)$value : null; + } + + private function setQuality(string $key, mixed $value, string $label): void { + $quality = $this->toInt($value); + if ($quality === null || $quality < 1 || $quality > 100) { + throw new \InvalidArgumentException($label . ' quality must be between 1 and 100'); + } + $this->appConfig->setValueInt('preview', $key, $quality); + } + + private function setOptionalPositiveInt(string $key, mixed $value): void { + if ($value === null || $value === '') { + $this->config->deleteSystemValue($key); + return; + } + $this->setIntKey($key, $value, 1); + } + + private function setBinaryPath(string $key, mixed $value): void { + $path = is_string($value) ? trim($value) : ''; + if ($path === '') { + $this->config->deleteSystemValue($key); + return; + } + if (str_contains($path, "\0") || strlen($path) > 4096) { + throw new \InvalidArgumentException('Invalid binary path for ' . $key); + } + $this->config->setSystemValue($key, $path); + } +} diff --git a/lib/private/PreviewManager.php b/lib/private/PreviewManager.php index f1621d7973887..1bd5fb13acdcf 100644 --- a/lib/private/PreviewManager.php +++ b/lib/private/PreviewManager.php @@ -36,6 +36,7 @@ use OC\Preview\Photoshop; use OC\Preview\PNG; use OC\Preview\Postscript; +use OC\Preview\PreviewAdminConfig; use OC\Preview\PreviewMigrationService; use OC\Preview\SGI; use OC\Preview\StarOffice; @@ -73,6 +74,14 @@ class PreviewManager implements IPreview { /** @var array> $providers */ protected array $providers = []; + /** + * Registrations in insertion order, rebuilt into {@see $providers} using + * ``enabledPreviewProviders`` as key order (PHP maps keep insertion order). + * + * @var list + */ + private array $providerRegistrations = []; + /** @var array mime type => support status */ protected array $mimeTypeSupportMap = []; @@ -107,15 +116,16 @@ public function __construct( * @param string $mimeTypeRegex Regex with the mime types that are supported by this provider * @param ProviderClosure $callable */ - private function registerProviderClosure(string $mimeTypeRegex, Closure $callable): void { + private function registerProviderClosure(string $mimeTypeRegex, Closure $callable, ?string $class = null): void { if (!$this->enablePreviews) { return; } - if (!isset($this->providers[$mimeTypeRegex])) { - $this->providers[$mimeTypeRegex] = []; - } - $this->providers[$mimeTypeRegex][] = $callable; + $this->providerRegistrations[] = [ + 'regex' => $mimeTypeRegex, + 'class' => $class !== null && $class !== '' ? ltrim($class, '\\') : null, + 'callable' => $callable, + ]; $this->providerListDirty = true; } @@ -128,21 +138,57 @@ public function getProviders(): array { $this->registerCoreProviders(); $this->registerBootstrapProviders(); if ($this->providerListDirty) { - $keys = array_map('strlen', array_keys($this->providers)); - array_multisort($keys, SORT_DESC, $this->providers); + $this->rebuildProvidersMap(); $this->providerListDirty = false; } return $this->providers; } + /** + * Rebuild {@see $providers} so foreach order follows ``enabledPreviewProviders``. + * + * PHP arrays preserve insertion order. Each provider has its own MIME regex + * in core, so visiting map keys in whitelist order is try-order for Generator. + */ + private function rebuildProvidersMap(): void { + $priority = []; + foreach ($this->getEnabledDefaultProvider() as $index => $class) { + if (!is_string($class) || $class === '') { + continue; + } + $priority[ltrim($class, '\\')] = $index; + } + + $registrations = []; + foreach ($this->providerRegistrations as $index => $registration) { + $registration['index'] = $index; + $registrations[] = $registration; + } + usort($registrations, function (array $left, array $right) use ($priority): int { + $leftClass = $left['class']; + $rightClass = $right['class']; + $leftPriority = is_string($leftClass) && isset($priority[$leftClass]) ? $priority[$leftClass] : PHP_INT_MAX; + $rightPriority = is_string($rightClass) && isset($priority[$rightClass]) ? $priority[$rightClass] : PHP_INT_MAX; + if ($leftPriority !== $rightPriority) { + return $leftPriority <=> $rightPriority; + } + return $left['index'] <=> $right['index']; + }); + + $this->providers = []; + foreach ($registrations as $registration) { + $this->providers[$registration['regex']][] = $registration['callable']; + } + } + /** * Does the manager have any providers */ #[\Override] public function hasProviders(): bool { $this->registerCoreProviders(); - return !empty($this->providers); + return $this->providerRegistrations !== []; } private function getGenerator(): Generator { @@ -273,11 +319,15 @@ protected function getEnabledDefaultProvider(): array { WebP::class, ]; - $this->defaultProviders = $this->config->getSystemValue('enabledPreviewProviders', array_merge([ - MarkDown::class, - TXT::class, - OpenDocument::class, - ], $imageProviders)); + $configured = $this->config->getSystemValue('enabledPreviewProviders', null); + if (!is_array($configured)) { + $this->defaultProviders = PreviewAdminConfig::getRecommendedEnabledProviders( + $this->config->getSystemValueString('preview_imaginary_url', '') !== '', + $this->imagickSupport->hasExtension() && $this->imagickSupport->supportsFormat('HEIC'), + ); + } else { + $this->defaultProviders = $configured; + } if (in_array(Image::class, $this->defaultProviders)) { $this->defaultProviders = array_merge($this->defaultProviders, $imageProviders); @@ -296,7 +346,7 @@ protected function registerCoreProvider(string $class, string $mimeType, array $ $this->registerProviderClosure($mimeType, function () use ($class, $options): IProviderV2 { /** @var IProviderV2 $class */ return new $class($options); - }); + }, $class); } } @@ -419,13 +469,17 @@ private function registerBootstrapProviders(): void { } $this->loadedBootstrapProviders[$key] = null; - $this->registerProviderClosure($provider->getMimeTypeRegex(), function () use ($provider): IProviderV2|false { - try { - return $this->container->get($provider->getService()); - } catch (NotFoundExceptionInterface) { - return false; - } - }); + $this->registerProviderClosure( + $provider->getMimeTypeRegex(), + function () use ($provider): IProviderV2|false { + try { + return $this->container->get($provider->getService()); + } catch (NotFoundExceptionInterface) { + return false; + } + }, + $provider->getService(), + ); } } diff --git a/tests/lib/Preview/GeneratorTest.php b/tests/lib/Preview/GeneratorTest.php index 9024d2d0b6887..336fb9870fdd8 100644 --- a/tests/lib/Preview/GeneratorTest.php +++ b/tests/lib/Preview/GeneratorTest.php @@ -399,6 +399,117 @@ public function testNoProvider(): void { $this->generator->getPreview($file, 100, 100); } + public function testTriesNextProviderWhenEarlierFails(): void { + $file = $this->getFile(42, 'myMimeType'); + + $this->previewMapper->method('getAvailablePreviews') + ->with($this->equalTo([42])) + ->willReturn([42 => []]); + $this->config->method('getSystemValueString')->willReturnCallback(fn ($key, $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn ($key, $default) => $default); + + $failing = $this->createMock(IProviderV2::class); + $failing->method('isAvailable')->willReturn(true); + $ok = $this->createMock(IProviderV2::class); + $ok->method('isAvailable')->willReturn(true); + $this->previewManager->method('isMimeSupported')->willReturn(true); + $this->previewManager->method('getProviders')->willReturn([ + '/myMimeType/' => ['failing', 'ok'], + ]); + $this->helper->method('getProvider')->willReturnOnConsecutiveCalls($failing, $ok); + + $image = $this->createMock(IImage::class); + $image->method('width')->willReturn(2048); + $image->method('height')->willReturn(2048); + $image->method('valid')->willReturn(true); + $image->method('dataMimeType')->willReturn('image/png'); + $image->method('data')->willReturn('my data'); + $this->helper->method('getThumbnail')->willReturnCallback(function ($provider) use ($failing, $image) { + if ($provider === $failing) { + throw new \RuntimeException('imaginary down'); + } + return $image; + }); + $this->helper->method('getImage')->willReturn($this->getMockImage(2048, 2048, 'my resized data')); + $this->previewMapper->method('insert')->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->previewMapper->method('update')->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->storageFactory->method('writePreview')->willReturn(1000); + + $result = $this->generator->getPreview($file, 100, 100); + $this->assertSame('256-256.png', $result->getName()); + } + + public function testTriesProvidersInEnabledPreviewProvidersOrder(): void { + $file = $this->getFile(42, 'image/heic'); + + $this->previewMapper->method('getAvailablePreviews') + ->with($this->equalTo([42])) + ->willReturn([42 => []]); + $this->config->method('getSystemValueString')->willReturnCallback(fn ($key, $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn ($key, $default) => $default); + + $imaginary = new class implements IProviderV2 { + public function getMimeType(): string { + return '/image\/heic/'; + } + public function isAvailable(\OCP\Files\FileInfo $file): bool { + return true; + } + public function getThumbnail(\OCP\Files\File $file, int $maxX, int $maxY): ?IImage { + return null; + } + }; + $heic = new class implements IProviderV2 { + public function getMimeType(): string { + return '/image\/heic/'; + } + public function isAvailable(\OCP\Files\FileInfo $file): bool { + return true; + } + public function getThumbnail(\OCP\Files\File $file, int $maxX, int $maxY): ?IImage { + return null; + } + }; + + $this->previewManager->method('isMimeSupported')->willReturn(true); + // PreviewManager emits this map in enabledPreviewProviders key order + // (PHP insertion order), not regex-length order. + $this->previewManager->method('getProviders')->willReturn([ + '/image\/heic/' => ['heic'], + '/(image\/(bmp|png|jpeg|heic|heif))/' => ['imaginary'], + ]); + $this->helper->method('getProvider')->willReturnCallback(function ($id) use ($imaginary, $heic) { + return match ($id) { + 'imaginary' => $imaginary, + 'heic' => $heic, + default => false, + }; + }); + + $image = $this->createMock(IImage::class); + $image->method('width')->willReturn(2048); + $image->method('height')->willReturn(2048); + $image->method('valid')->willReturn(true); + $image->method('dataMimeType')->willReturn('image/jpeg'); + $image->method('data')->willReturn('heic data'); + $tried = []; + $this->helper->method('getThumbnail')->willReturnCallback(function ($provider) use ($heic, $imaginary, $image, &$tried) { + $tried[] = $provider; + if ($provider === $heic) { + return $image; + } + $this->fail('Imaginary must not run first when HEIC is earlier in enabledPreviewProviders'); + }); + $this->helper->method('getImage')->willReturn($this->getMockImage(2048, 2048, 'resized')); + $this->previewMapper->method('insert')->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->previewMapper->method('update')->willReturnCallback(fn (Preview $preview): Preview => $preview); + $this->storageFactory->method('writePreview')->willReturn(1000); + + $result = $this->generator->getPreview($file, 100, 100); + $this->assertSame([$heic], $tried); + $this->assertSame('256-256.png', $result->getName()); + } + private function getMockImage(int $width, int $height, string $data = '') { $image = $this->createMock(IImage::class); $image->method('height')->willReturn($width); diff --git a/tests/lib/Preview/PreviewAdminConfigTest.php b/tests/lib/Preview/PreviewAdminConfigTest.php new file mode 100644 index 0000000000000..d60e6a3796125 --- /dev/null +++ b/tests/lib/Preview/PreviewAdminConfigTest.php @@ -0,0 +1,349 @@ +config = $this->createMock(IConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->adminConfig = new PreviewAdminConfig($this->config, $this->appConfig); + } + + public function testGetSettingsUsesDefaultsWhenKeysMissing(): void { + $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default) => $default); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $settings = $this->adminConfig->getSettings(); + $this->assertTrue($settings['enablePreviews']); + $this->assertSame(4096, $settings['previewMaxX']); + $this->assertSame(PreviewAdminConfig::getDefaultEnabledProviders(), $settings['defaultEnabledProviders']); + $this->assertSame( + $settings['defaultEnabledProviders'], + array_slice($settings['defaultProviderOrder'], 0, count($settings['defaultEnabledProviders'])), + ); + $this->assertContains(Movie::class, $settings['defaultProviderOrder']); + $this->assertContains(PDF::class, $settings['defaultProviderOrder']); + $this->assertNotEmpty($settings['providers']); + } + + public function testSetEnabledProvidersPreservesOrder(): void { + $this->config->expects($this->once()) + ->method('setSystemValue') + ->with('enabledPreviewProviders', [JPEG::class, PNG::class]); + + $result = $this->adminConfig->setEnabledProviders([JPEG::class, PNG::class, JPEG::class]); + $this->assertSame([JPEG::class, PNG::class], $result); + } + + public function testSetEnabledProvidersRejectsEmptyList(): void { + $this->expectException(\InvalidArgumentException::class); + $this->adminConfig->setEnabledProviders([]); + } + + public function testSetEnabledProvidersAllowsEmptyWithConfirmation(): void { + $this->config->expects($this->once()) + ->method('setSystemValue') + ->with('enabledPreviewProviders', []); + $this->adminConfig->setEnabledProviders([], true); + } + + public function testRejectsInvalidImaginaryUrl(): void { + $this->expectException(\InvalidArgumentException::class); + $this->adminConfig->validateImaginaryUrl('javascript:alert(1)'); + } + + public function testAcceptsEmptyImaginaryUrl(): void { + $this->assertSame('', $this->adminConfig->validateImaginaryUrl(' ')); + } + + public function testSetRetentionAndConcurrency(): void { + $calls = []; + $this->config->method('setSystemValue')->willReturnCallback(function (string $key, mixed $value) use (&$calls): void { + $calls[$key] = $value; + }); + $this->adminConfig->setSettings([ + 'previewExpirationDays' => 90, + 'previewConcurrencyNew' => 2, + 'previewConcurrencyAll' => 6, + ]); + $this->assertSame(90, $calls['preview_expiration_days']); + $this->assertSame(2, $calls['preview_concurrency_new']); + $this->assertSame(6, $calls['preview_concurrency_all']); + } + + public function testEmptyConcurrencyDeletesKey(): void { + $this->config->expects($this->once())->method('deleteSystemValue')->with('preview_concurrency_new'); + $this->adminConfig->setSettings(['previewConcurrencyNew' => '']); + } + + public function testWebpQualityIsWritten(): void { + $this->appConfig->expects($this->once())->method('setValueInt')->with('preview', 'webp_quality', 75); + $this->adminConfig->setSettings(['webpQuality' => 75]); + } + + public function testProviderAvailabilityUsesDetectors(): void { + $imagick = $this->createMock(IMagickSupport::class); + $imagick->method('hasExtension')->willReturn(true); + $imagick->method('supportsFormat')->willReturnCallback(fn (string $format) => $format === 'HEIC'); + $finder = $this->createMock(IBinaryFinder::class); + $finder->method('findBinaryPath')->willReturnCallback(fn (string $name) => $name === 'ffmpeg' ? '/usr/bin/ffmpeg' : false); + + $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default) => $default); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $config = new PreviewAdminConfig($this->config, $this->appConfig, $imagick, $finder); + $settings = $config->getSettings(); + $byClass = []; + foreach ($settings['providers'] as $row) { + $byClass[$row['class']] = $row; + } + + $this->assertTrue($settings['detection']['ffmpegFound']); + $this->assertSame('/usr/bin/ffmpeg', $settings['detection']['ffmpegDetectedPath']); + $this->assertFalse($settings['detection']['officeFound']); + $this->assertTrue($byClass[HEIC::class]['available']); + $this->assertTrue($byClass[HEIC::class]['unsupported']); + $this->assertFalse($byClass[PDF::class]['available']); + $this->assertTrue($byClass[PDF::class]['unsupported']); + $this->assertTrue($byClass[Movie::class]['available']); + $this->assertTrue($byClass[Movie::class]['unsupported']); + $this->assertFalse($byClass[Movie::class]['enabled']); + $this->assertFalse($byClass[MSOfficeDoc::class]['available']); + $this->assertTrue($byClass[MSOfficeDoc::class]['unsupported']); + $this->assertFalse($byClass[Imaginary::class]['available']); + $this->assertFalse($byClass[Imaginary::class]['unsupported']); + $this->assertTrue($byClass[JPEG::class]['available']); + $this->assertFalse($byClass[JPEG::class]['unsupported']); + $this->assertContains('image/heic', $byClass[HEIC::class]['sourceMimes']); + $this->assertContains('image/heic', $byClass[Imaginary::class]['sourceMimes']); + } + + public function testMovieStaysDisabledWhenProviderListIsExplicit(): void { + $finder = $this->createMock(IBinaryFinder::class); + $finder->method('findBinaryPath')->willReturnCallback(fn (string $name) => $name === 'ffmpeg' ? '/usr/bin/ffmpeg' : false); + + $this->config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) { + if ($key === 'enabledPreviewProviders') { + return [JPEG::class, PNG::class]; + } + return $default; + }); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default) => $default); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $config = new PreviewAdminConfig($this->config, $this->appConfig, null, $finder); + $settings = $config->getSettings(); + $byClass = []; + foreach ($settings['providers'] as $row) { + $byClass[$row['class']] = $row; + } + + $this->assertTrue($byClass[Movie::class]['available']); + $this->assertFalse($byClass[Movie::class]['enabled']); + } + + public function testProviderTableOrderDoesNotChangeWhenDisabled(): void { + $enabled = [JPEG::class, PNG::class, MarkDown::class]; + $this->config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) use (&$enabled) { + if ($key === 'enabledPreviewProviders') { + return $enabled; + } + return $default; + }); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default) => $default); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $withMarkdown = $this->adminConfig->getSettings(); + $enabledIndex = array_search(MarkDown::class, array_column($withMarkdown['providers'], 'class'), true); + + $enabled = [JPEG::class, PNG::class]; + $withoutMarkdown = $this->adminConfig->getSettings(); + $disabledIndex = array_search(MarkDown::class, array_column($withoutMarkdown['providers'], 'class'), true); + + $this->assertNotFalse($enabledIndex); + $this->assertSame($enabledIndex, $disabledIndex); + $this->assertTrue($withMarkdown['providers'][$enabledIndex]['enabled']); + $this->assertFalse($withoutMarkdown['providers'][$disabledIndex]['enabled']); + } + + public function testSavedProviderTableOrderKeepsDisabledRowsInPlace(): void { + $stored = []; + $this->config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) use (&$stored) { + return $stored[$key] ?? $default; + }); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(function (string $key, string $default) use (&$stored) { + $value = $stored[$key] ?? $default; + return is_string($value) ? $value : $default; + }); + $this->config->method('setSystemValue')->willReturnCallback(function (string $key, mixed $value) use (&$stored): void { + $stored[$key] = $value; + }); + + $this->adminConfig->setSettings([ + 'providers' => [ + ['class' => JPEG::class, 'enabled' => true], + ['class' => MarkDown::class, 'enabled' => false], + ['class' => PNG::class, 'enabled' => true], + ], + ]); + + $classes = array_column($this->adminConfig->getSettings()['providers'], 'class'); + $this->assertSame([JPEG::class, MarkDown::class, PNG::class], array_slice($classes, 0, 3)); + $this->assertSame([JPEG::class, PNG::class], $stored['enabledPreviewProviders']); + } + + public function testRecommendedDefaultsPutImaginaryFirstWhenUrlIsSet(): void { + $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(function (string $key, string $default) { + return $key === 'preview_imaginary_url' ? 'http://imaginary:9000' : $default; + }); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $config = new PreviewAdminConfig($this->config, $this->appConfig); + $settings = $config->getSettings(); + $this->assertSame(Imaginary::class, $settings['providers'][0]['class']); + $this->assertTrue($settings['providers'][0]['enabled']); + $this->assertSame(Imaginary::class, $settings['defaultEnabledProviders'][0]); + $this->assertNotContains(HEIC::class, $settings['defaultEnabledProviders']); + } + + public function testRecommendedDefaultsAddHeicFallbackWhenImagickSupportsIt(): void { + $imagick = $this->createMock(IMagickSupport::class); + $imagick->method('hasExtension')->willReturn(true); + $imagick->method('supportsFormat')->willReturnCallback(fn (string $format) => $format === 'HEIC'); + + $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default); + $this->config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => $default); + $this->config->method('getSystemValueInt')->willReturnCallback(fn (string $key, int $default) => $default); + $this->config->method('getSystemValueString')->willReturnCallback(function (string $key, string $default) { + return $key === 'preview_imaginary_url' ? 'http://imaginary:9000' : $default; + }); + $this->appConfig->method('getValueInt')->willReturnCallback(fn (string $app, string $key, int $default) => $default); + + $config = new PreviewAdminConfig($this->config, $this->appConfig, $imagick); + $settings = $config->getSettings(); + $this->assertSame(Imaginary::class, $settings['defaultEnabledProviders'][0]); + $this->assertContains(HEIC::class, $settings['defaultEnabledProviders']); + $byClass = []; + foreach ($settings['providers'] as $row) { + $byClass[$row['class']] = $row; + } + $this->assertTrue($byClass[HEIC::class]['enabled']); + $this->assertTrue($byClass[HEIC::class]['available']); + $this->assertTrue($byClass[HEIC::class]['unsupported']); + } + + public function testSetSettingsDropsProvidersThatDoNotMeetRequirements(): void { + $stored = []; + $this->config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) use (&$stored) { + return $stored[$key] ?? $default; + }); + $this->config->method('getSystemValueString')->willReturnCallback(function (string $key, string $default) use (&$stored) { + $value = $stored[$key] ?? $default; + return is_string($value) ? $value : $default; + }); + $this->config->method('setSystemValue')->willReturnCallback(function (string $key, mixed $value) use (&$stored): void { + $stored[$key] = $value; + }); + $this->config->method('deleteSystemValue')->willReturnCallback(function (string $key) use (&$stored): void { + unset($stored[$key]); + }); + + $this->adminConfig->setSettings([ + 'ffmpegPath' => '', + 'imaginaryUrl' => '', + 'providers' => [ + ['class' => JPEG::class, 'enabled' => true], + ['class' => Movie::class, 'enabled' => true], + ['class' => Imaginary::class, 'enabled' => true], + ['class' => MSOfficeDoc::class, 'enabled' => true], + ], + ]); + + $this->assertSame([JPEG::class], $stored['enabledPreviewProviders']); + } + + public function testSetSettingsKeepsMovieWhenFfmpegPathIsSet(): void { + $stored = []; + $this->config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) use (&$stored) { + return $stored[$key] ?? $default; + }); + $this->config->method('getSystemValueString')->willReturnCallback(function (string $key, string $default) use (&$stored) { + $value = $stored[$key] ?? $default; + return is_string($value) ? $value : $default; + }); + $this->config->method('setSystemValue')->willReturnCallback(function (string $key, mixed $value) use (&$stored): void { + $stored[$key] = $value; + }); + + $this->adminConfig->setSettings([ + 'ffmpegPath' => '/usr/bin/ffmpeg', + 'providers' => [ + ['class' => JPEG::class, 'enabled' => true], + ['class' => Movie::class, 'enabled' => true], + ], + ]); + + $this->assertSame([JPEG::class, Movie::class], $stored['enabledPreviewProviders']); + $this->assertSame('/usr/bin/ffmpeg', $stored['preview_ffmpeg_path']); + } + + public function testUnsupportedProviderListMatchesDisabledByDefaultCatalog(): void { + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(JPEG::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(PNG::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(OpenDocument::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(Imaginary::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(ImaginaryPDF::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider(Image::class)); + $this->assertTrue(PreviewAdminConfig::isUnsupportedProvider(HEIC::class)); + $this->assertTrue(PreviewAdminConfig::isUnsupportedProvider(Movie::class)); + $this->assertTrue(PreviewAdminConfig::isUnsupportedProvider(PDF::class)); + $this->assertTrue(PreviewAdminConfig::isUnsupportedProvider(MSOfficeDoc::class)); + $this->assertFalse(PreviewAdminConfig::isUnsupportedProvider('OCA\\SomeApp\\Preview\\Custom')); + } +} diff --git a/tests/lib/Preview/PreviewManagerProviderOrderTest.php b/tests/lib/Preview/PreviewManagerProviderOrderTest.php new file mode 100644 index 0000000000000..2ba3df1001f96 --- /dev/null +++ b/tests/lib/Preview/PreviewManagerProviderOrderTest.php @@ -0,0 +1,74 @@ +createMock(IConfig::class); + $config->method('getSystemValueBool')->willReturnCallback(fn (string $key, bool $default) => match ($key) { + 'enable_previews' => true, + default => $default, + }); + $config->method('getSystemValue')->willReturnCallback(function (string $key, mixed $default) { + if ($key === 'enabledPreviewProviders') { + return [HEIC::class, Imaginary::class, JPEG::class]; + } + return $default; + }); + $config->method('getSystemValueString')->willReturn(''); + + $imagick = $this->createMock(IMagickSupport::class); + $imagick->method('hasExtension')->willReturn(true); + $imagick->method('supportsFormat')->willReturn(true); + + $finder = $this->createMock(IBinaryFinder::class); + $finder->method('findBinaryPath')->willReturn(false); + + $coordinator = $this->createMock(Coordinator::class); + $coordinator->method('getRegistrationContext')->willReturn(null); + + $manager = new PreviewManager( + $config, + $this->createMock(IRootFolder::class), + $this->createMock(IEventDispatcher::class), + $this->createMock(GeneratorHelper::class), + null, + $coordinator, + $this->createMock(ContainerInterface::class), + $finder, + $imagick, + ); + + $keys = array_keys($manager->getProviders()); + $heicIndex = array_search('/image\/(x-)?hei(f|c)/', $keys, true); + $imaginaryIndex = array_search(Imaginary::supportedMimeTypes(), $keys, true); + $jpegIndex = array_search('/image\/jpeg/', $keys, true); + + $this->assertNotFalse($heicIndex); + $this->assertNotFalse($imaginaryIndex); + $this->assertNotFalse($jpegIndex); + $this->assertLessThan($imaginaryIndex, $heicIndex); + $this->assertLessThan($jpegIndex, $imaginaryIndex); + } +} diff --git a/tests/playwright/e2e/settings/admin-settings-previews.spec.ts b/tests/playwright/e2e/settings/admin-settings-previews.spec.ts new file mode 100644 index 0000000000000..cf4ebed1d4d6c --- /dev/null +++ b/tests/playwright/e2e/settings/admin-settings-previews.spec.ts @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { test as adminTest } from '../../support/fixtures/admin-session.ts' +import { test as userTest } from '../../support/fixtures/random-user-session.ts' +import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts' +import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts' +import { pickSelectOption } from '../../support/utils/select.ts' + +function providerRow(page: Page, className: string) { + return page.getByRole('row').filter({ has: page.locator('code', { hasText: new RegExp(`^${className.replace(/\\/g, '\\\\')}$`) }) }) +} + +adminTest.describe('Settings: Previews admin page', () => { + adminTest('admin can open Administration → Previews and see all sections', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + const previewsNav = page.getByRole('navigation').getByRole('link', { name: 'Previews' }) + await expect(previewsNav).toBeVisible() + const previewsNavIcon = previewsNav.locator('img') + await expect(previewsNavIcon).toBeVisible() + await expect(previewsNavIcon).toHaveAttribute('src', /\/apps\/settings\/img\/previews\.svg/) + await expect(page.getByRole('heading', { name: /^Previews/ }).first()).toBeVisible() + await expect(page.getByRole('heading', { name: 'Providers' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Preview quality' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Performance' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Imaginary' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Movie' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Office' })).toBeVisible() + }) + + adminTest('enable previews and max width persist after reload', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + const enable = page.getByRole('switch', { name: 'Enable previews' }) + await expect(enable).toBeVisible() + if (!(await enable.isChecked())) { + await enable.click() + await handlePasswordConfirmation(page) + } + const maxX = page.getByRole('spinbutton', { name: 'Maximum preview width (pixels)' }) + await maxX.fill('2048') + await maxX.blur() + await handlePasswordConfirmation(page) + await page.reload() + await expect(page.getByRole('switch', { name: 'Enable previews' })).toBeChecked() + await expect(page.getByRole('spinbutton', { name: 'Maximum preview width (pixels)' })).toHaveValue('2048') + }) + + adminTest('providers list includes JPEG or Image related providers', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + const table = page.getByRole('table', { name: 'Preview providers' }) + await expect(table).toBeVisible() + await expect(table).toContainText(/JPEG|PNG|Image/i) + }) + + adminTest('providers table shows preview format and footnotes', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const table = page.getByRole('table', { name: 'Preview providers' }) + await expect(table.getByRole('columnheader', { name: 'Preview format' })).toBeVisible() + const jpegRow = providerRow(page, 'OC\\Preview\\JPEG') + const webpRow = providerRow(page, 'OC\\Preview\\WebP') + const pngRow = providerRow(page, 'OC\\Preview\\PNG') + await expect(jpegRow).toContainText('JPEG') + await expect(jpegRow).toContainText('Available') + await expect(webpRow).toContainText('WebP') + await expect(pngRow).toContainText('PNG') + const imaginaryRow = providerRow(page, 'OC\\Preview\\Imaginary') + await expect(imaginaryRow).toContainText(/JPEG|WebP/) + const mp3Row = providerRow(page, 'OC\\Preview\\MP3') + await expect(mp3Row).toContainText('Unsupported') + await expect(page.getByText(/Disabled by default due to security and performance concerns/)).toBeVisible() + await expect(page.getByText(/MP3 previews use the artwork embedded in the file/)).toBeVisible() + + const format = page.getByRole('combobox', { name: 'Preview output format' }) + await pickSelectOption(page, format, 'WebP') + await handlePasswordConfirmation(page) + await expect(imaginaryRow).toContainText('WebP') + await expect(page.getByText(/When (the )?preview output format is JPEG, Imaginary still writes PNG/i)).toHaveCount(0) + }) + + adminTest('source MIME filter shows providers that handle HEIC', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const mimeFilter = page.getByRole('combobox', { name: 'Source MIME type' }) + await pickSelectOption(page, mimeFilter, 'image/heic') + const table = page.getByRole('table', { name: 'Preview providers' }) + await expect(table.locator('code', { hasText: /^OC\\Preview\\HEIC$/ })).toBeVisible() + await expect(table.locator('code', { hasText: /^OC\\Preview\\Imaginary$/ })).toBeVisible() + await expect(table.locator('code', { hasText: /^OC\\Preview\\JPEG$/ })).toHaveCount(0) + }) + + adminTest('status filter can show unsupported providers', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const statusFilter = page.getByRole('combobox', { name: 'Status' }) + await pickSelectOption(page, statusFilter, 'Unsupported') + const table = page.getByRole('table', { name: 'Preview providers' }) + await expect(table.locator('code', { hasText: /^OC\\Preview\\MP3$/ })).toBeVisible() + await expect(table.locator('code', { hasText: /^OC\\Preview\\Movie$/ })).toBeVisible() + await expect(table.locator('code', { hasText: /^OC\\Preview\\JPEG$/ })).toHaveCount(0) + }) + + adminTest('reset to defaults restores provider order', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const table = page.getByRole('table', { name: 'Preview providers' }) + const pngRow = providerRow(page, 'OC\\Preview\\PNG') + await pngRow.getByRole('button', { name: 'Move down' }).click() + await handlePasswordConfirmation(page) + const moved = await table.locator('tbody code').allTextContents() + expect(moved.indexOf('OC\\Preview\\PNG')).toBeGreaterThan(moved.indexOf('OC\\Preview\\JPEG')) + await page.getByRole('button', { name: 'Reset to defaults' }).click() + await handlePasswordConfirmation(page) + const reset = await table.locator('tbody code').allTextContents() + expect(reset.indexOf('OC\\Preview\\PNG')).toBeLessThan(reset.indexOf('OC\\Preview\\JPEG')) + }) + + adminTest('unavailable providers cannot be enabled until their requirement is met', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const imaginaryRow = providerRow(page, 'OC\\Preview\\Imaginary') + const imaginarySwitch = imaginaryRow.getByRole('switch') + if (await imaginaryRow.getByText('Requires Imaginary URL').count()) { + await expect(imaginarySwitch).toBeDisabled() + await page.getByRole('textbox', { name: 'Imaginary URL' }).fill('http://127.0.0.1:9000') + await page.getByRole('textbox', { name: 'Imaginary URL' }).blur() + await handlePasswordConfirmation(page) + await expect(imaginaryRow.getByText('Requires Imaginary URL')).toHaveCount(0) + await expect(imaginaryRow).toContainText('Available') + await expect(imaginarySwitch).toBeEnabled() + } + + const movieRow = providerRow(page, 'OC\\Preview\\Movie') + const movieSwitch = movieRow.getByRole('switch') + if (await movieRow.getByText('Requires ffmpeg').count()) { + await expect(movieSwitch).toBeDisabled() + await page.getByRole('textbox', { name: 'ffmpeg path' }).fill('/usr/bin/ffmpeg') + await page.getByRole('textbox', { name: 'ffmpeg path' }).blur() + await handlePasswordConfirmation(page) + await expect(movieRow.getByText('Requires ffmpeg')).toHaveCount(0) + await expect(movieRow).toContainText(/Available|Unsupported/) + await expect(movieSwitch).toBeEnabled() + } + }) + + adminTest('requires availability chips link to the matching section', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Providers' }).scrollIntoViewIfNeeded() + const ffmpegLink = page.getByRole('link', { name: 'Go to the Movie section to configure ffmpeg' }) + if (await ffmpegLink.count()) { + await expect(ffmpegLink.first()).toHaveAttribute('href', '#previews-section-movie') + } + const officeLink = page.getByRole('link', { name: 'Go to the Office section to configure LibreOffice or OpenOffice' }) + if (await officeLink.count()) { + await expect(officeLink.first()).toHaveAttribute('href', '#previews-section-office') + } + const imaginaryLink = page.getByRole('link', { name: 'Go to the Imaginary section to set the URL' }) + if (await imaginaryLink.count()) { + await expect(imaginaryLink.first()).toHaveAttribute('href', '#previews-section-imaginary') + } + }) + + adminTest('performance concurrency warnings', async ({ page }) => { + await page.goto('/index.php/settings/admin/previews') + await page.getByRole('heading', { name: 'Performance' }).scrollIntoViewIfNeeded() + const cpuDetected = await page.getByText(/This server reports \d+ CPU cores/).isVisible() + const newConcurrency = page.getByRole('spinbutton', { name: 'New preview concurrency' }) + const totalConcurrency = page.getByRole('spinbutton', { name: 'Total preview concurrency' }) + await newConcurrency.fill('9999') + await newConcurrency.blur() + await handlePasswordConfirmation(page) + await totalConcurrency.fill('1') + await totalConcurrency.blur() + await handlePasswordConfirmation(page) + if (cpuDetected) { + await expect(page.getByText(/New preview concurrency is higher than the \d+ CPU cores/)).toBeVisible() + await expect(page.getByText(/Total preview concurrency should be greater than or equal to new preview concurrency/)).toBeVisible() + } else { + await expect(page.getByText(/New preview concurrency is higher than the \d+ CPU cores/)).toHaveCount(0) + await expect(page.getByText(/Total preview concurrency should be greater than or equal to new preview concurrency/)).toHaveCount(0) + } + }) +}) + +userTest.describe('Settings: Previews access – regular user', () => { + userTest('does not see the Previews admin section', async ({ page }) => { + await page.goto('/') + const accountMenu = new AccountMenuPage(page) + await accountMenu.open() + await accountMenu.entry('Settings').getByRole('link').click() + await expect(page).toHaveURL(/\/settings\/user$/) + const appNavigation = page.locator('#app-navigation-vue') + await expect(appNavigation.getByRole('list', { name: 'Administration' })).toHaveCount(0) + await expect(appNavigation.getByRole('link', { name: 'Previews' })).toHaveCount(0) + }) +})