From 5e9b01602ce72c3bdd2ba14d38b96d0c34cec35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:30:44 +0200 Subject: [PATCH] feat(viewer): keep the user's viewer settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/viewer becomes a minimal, always enabled app again, the server side of the viewer. It keeps the user's viewer settings the way the Files app keeps its own: a UserConfig service listing the allowed keys, an OCS endpoint to change one, and the settings handed to the viewer as initial state. They are the slideshow delay, and the volume of videos and audio and whether they start muted. Core keeps loading the viewer itself. It is no longer an abandoned app to clean up after, and continues the old app's versions at 9.0.0-dev.0, after 8.0.0 in 35. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- apps/viewer/.noopenapi | 0 apps/viewer/appinfo/info.xml | 21 + apps/viewer/composer/autoload.php | 22 + apps/viewer/composer/composer.json | 13 + apps/viewer/composer/composer.lock | 18 + apps/viewer/composer/composer/ClassLoader.php | 579 ++++++++++++++++++ .../composer/composer/InstalledVersions.php | 396 ++++++++++++ apps/viewer/composer/composer/LICENSE | 21 + .../composer/composer/autoload_classmap.php | 15 + .../composer/composer/autoload_namespaces.php | 9 + .../composer/composer/autoload_psr4.php | 10 + .../composer/composer/autoload_real.php | 37 ++ .../composer/composer/autoload_static.php | 41 ++ apps/viewer/composer/composer/installed.json | 5 + apps/viewer/composer/composer/installed.php | 23 + apps/viewer/lib/AppInfo/Application.php | 38 ++ .../lib/Controller/ConfigController.php | 50 ++ apps/viewer/lib/Event/LoadViewer.php | 3 - apps/viewer/lib/Listener/SettingsListener.php | 47 ++ apps/viewer/lib/Service/UserConfig.php | 117 ++++ apps/viewer/openapi.json | 235 +++++++ apps/viewer/openapi.json.license | 2 + .../tests/Controller/ConfigControllerTest.php | 45 ++ .../tests/Listener/SettingsListenerTest.php | 67 ++ apps/viewer/tests/LoadViewerTest.php | 4 +- apps/viewer/tests/Service/UserConfigTest.php | 96 +++ build/autoloaderchecker.sh | 3 +- .../features/provisioning-v1.feature | 1 + build/translation-checker.php | 2 +- composer.json | 1 - core/shipped.json | 3 + lib/composer/composer/autoload_classmap.php | 1 - lib/composer/composer/autoload_psr4.php | 1 - lib/composer/composer/autoload_static.php | 6 - lib/private/Repair/CleanUpAbandonedApps.php | 2 +- openapi.json | 183 ++++++ tests/lib/App/AppManagerTest.php | 5 + tests/lib/AppTest.php | 9 +- 38 files changed, 2111 insertions(+), 20 deletions(-) delete mode 100644 apps/viewer/.noopenapi create mode 100644 apps/viewer/appinfo/info.xml create mode 100644 apps/viewer/composer/autoload.php create mode 100644 apps/viewer/composer/composer.json create mode 100644 apps/viewer/composer/composer.lock create mode 100644 apps/viewer/composer/composer/ClassLoader.php create mode 100644 apps/viewer/composer/composer/InstalledVersions.php create mode 100644 apps/viewer/composer/composer/LICENSE create mode 100644 apps/viewer/composer/composer/autoload_classmap.php create mode 100644 apps/viewer/composer/composer/autoload_namespaces.php create mode 100644 apps/viewer/composer/composer/autoload_psr4.php create mode 100644 apps/viewer/composer/composer/autoload_real.php create mode 100644 apps/viewer/composer/composer/autoload_static.php create mode 100644 apps/viewer/composer/composer/installed.json create mode 100644 apps/viewer/composer/composer/installed.php create mode 100644 apps/viewer/lib/AppInfo/Application.php create mode 100644 apps/viewer/lib/Controller/ConfigController.php create mode 100644 apps/viewer/lib/Listener/SettingsListener.php create mode 100644 apps/viewer/lib/Service/UserConfig.php create mode 100644 apps/viewer/openapi.json create mode 100644 apps/viewer/openapi.json.license create mode 100644 apps/viewer/tests/Controller/ConfigControllerTest.php create mode 100644 apps/viewer/tests/Listener/SettingsListenerTest.php create mode 100644 apps/viewer/tests/Service/UserConfigTest.php diff --git a/apps/viewer/.noopenapi b/apps/viewer/.noopenapi deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/apps/viewer/appinfo/info.xml b/apps/viewer/appinfo/info.xml new file mode 100644 index 0000000000000..9032a1c3fd5f9 --- /dev/null +++ b/apps/viewer/appinfo/info.xml @@ -0,0 +1,21 @@ + + + + viewer + Viewer + Simple file viewer with slideshow for media + + 9.0.0-dev.0 + agpl + John Molakvoæ + Viewer + tools + https://github.com/nextcloud/server/issues + + + + diff --git a/apps/viewer/composer/autoload.php b/apps/viewer/composer/autoload.php new file mode 100644 index 0000000000000..0c53925d94213 --- /dev/null +++ b/apps/viewer/composer/autoload.php @@ -0,0 +1,22 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/apps/viewer/composer/composer/InstalledVersions.php b/apps/viewer/composer/composer/InstalledVersions.php new file mode 100644 index 0000000000000..2052022fd8e1e --- /dev/null +++ b/apps/viewer/composer/composer/InstalledVersions.php @@ -0,0 +1,396 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to + * @internal + */ + private static $selfDir = null; + + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool + */ + private static $installedIsLocalDir; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return string + */ + private static function getSelfDir() + { + if (self::$selfDir === null) { + self::$selfDir = strtr(__DIR__, '\\', '/'); + } + + return self::$selfDir; + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + $copiedLocalDir = false; + + if (self::$canGetVendors) { + $selfDir = self::getSelfDir(); + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; + } + } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array() && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/apps/viewer/composer/composer/LICENSE b/apps/viewer/composer/composer/LICENSE new file mode 100644 index 0000000000000..f27399a042d95 --- /dev/null +++ b/apps/viewer/composer/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/apps/viewer/composer/composer/autoload_classmap.php b/apps/viewer/composer/composer/autoload_classmap.php new file mode 100644 index 0000000000000..9f973b06237b5 --- /dev/null +++ b/apps/viewer/composer/composer/autoload_classmap.php @@ -0,0 +1,15 @@ + $vendorDir . '/composer/InstalledVersions.php', + 'OCA\\Viewer\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', + 'OCA\\Viewer\\Controller\\ConfigController' => $baseDir . '/../lib/Controller/ConfigController.php', + 'OCA\\Viewer\\Event\\LoadViewer' => $baseDir . '/../lib/Event/LoadViewer.php', + 'OCA\\Viewer\\Listener\\SettingsListener' => $baseDir . '/../lib/Listener/SettingsListener.php', + 'OCA\\Viewer\\Service\\UserConfig' => $baseDir . '/../lib/Service/UserConfig.php', +); diff --git a/apps/viewer/composer/composer/autoload_namespaces.php b/apps/viewer/composer/composer/autoload_namespaces.php new file mode 100644 index 0000000000000..3f5c929625125 --- /dev/null +++ b/apps/viewer/composer/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($baseDir . '/../lib'), +); diff --git a/apps/viewer/composer/composer/autoload_real.php b/apps/viewer/composer/composer/autoload_real.php new file mode 100644 index 0000000000000..6aafd38f01d47 --- /dev/null +++ b/apps/viewer/composer/composer/autoload_real.php @@ -0,0 +1,37 @@ +setClassMapAuthoritative(true); + $loader->register(true); + + return $loader; + } +} diff --git a/apps/viewer/composer/composer/autoload_static.php b/apps/viewer/composer/composer/autoload_static.php new file mode 100644 index 0000000000000..320d7dbb88139 --- /dev/null +++ b/apps/viewer/composer/composer/autoload_static.php @@ -0,0 +1,41 @@ + + array ( + 'OCA\\Viewer\\' => 11, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'OCA\\Viewer\\' => + array ( + 0 => __DIR__ . '/..' . '/../lib', + ), + ); + + public static $classMap = array ( + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'OCA\\Viewer\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', + 'OCA\\Viewer\\Controller\\ConfigController' => __DIR__ . '/..' . '/../lib/Controller/ConfigController.php', + 'OCA\\Viewer\\Event\\LoadViewer' => __DIR__ . '/..' . '/../lib/Event/LoadViewer.php', + 'OCA\\Viewer\\Listener\\SettingsListener' => __DIR__ . '/..' . '/../lib/Listener/SettingsListener.php', + 'OCA\\Viewer\\Service\\UserConfig' => __DIR__ . '/..' . '/../lib/Service/UserConfig.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInitViewer::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInitViewer::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInitViewer::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/apps/viewer/composer/composer/installed.json b/apps/viewer/composer/composer/installed.json new file mode 100644 index 0000000000000..f20a6c47c6d4f --- /dev/null +++ b/apps/viewer/composer/composer/installed.json @@ -0,0 +1,5 @@ +{ + "packages": [], + "dev": false, + "dev-package-names": [] +} diff --git a/apps/viewer/composer/composer/installed.php b/apps/viewer/composer/composer/installed.php new file mode 100644 index 0000000000000..1a66c7f2416b6 --- /dev/null +++ b/apps/viewer/composer/composer/installed.php @@ -0,0 +1,23 @@ + array( + 'name' => '__root__', + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b', + 'type' => 'library', + 'install_path' => __DIR__ . '/../', + 'aliases' => array(), + 'dev' => false, + ), + 'versions' => array( + '__root__' => array( + 'pretty_version' => 'dev-master', + 'version' => 'dev-master', + 'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b', + 'type' => 'library', + 'install_path' => __DIR__ . '/../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/apps/viewer/lib/AppInfo/Application.php b/apps/viewer/lib/AppInfo/Application.php new file mode 100644 index 0000000000000..b4220d3ab9c82 --- /dev/null +++ b/apps/viewer/lib/AppInfo/Application.php @@ -0,0 +1,38 @@ +registerEventListener(BeforeTemplateRenderedEvent::class, SettingsListener::class); + } + + #[\Override] + public function boot(IBootContext $context): void { + } +} diff --git a/apps/viewer/lib/Controller/ConfigController.php b/apps/viewer/lib/Controller/ConfigController.php new file mode 100644 index 0000000000000..8fae498985c7d --- /dev/null +++ b/apps/viewer/lib/Controller/ConfigController.php @@ -0,0 +1,50 @@ +|DataResponse + * + * 200: Setting saved + * 400: Unknown setting, or a value it does not accept + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/v1/config/{key}')] + public function setConfig(string $key, int|bool $value): DataResponse { + try { + $this->userConfig->setConfig($key, $value); + } catch (\InvalidArgumentException $e) { + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(['key' => $key, 'value' => $value]); + } +} diff --git a/apps/viewer/lib/Event/LoadViewer.php b/apps/viewer/lib/Event/LoadViewer.php index 2aa57db0c2c9d..af4e664980dbc 100644 --- a/apps/viewer/lib/Event/LoadViewer.php +++ b/apps/viewer/lib/Event/LoadViewer.php @@ -15,9 +15,6 @@ * Nothing dispatches it any more: the viewer is on every page already, see * \OC\Template\LoadViewerListener. * - * apps/viewer is not an app, only where the legacy viewer API waits to be - * removed: the server autoloads this class, see composer.json. - * * @since 17.0.0 * @deprecated 36.0.0 Dispatching it does nothing. Register your handler with the `@nextcloud/viewer` package and load it with `\OCP\Util::addInitScript()` instead. It will be removed in Nextcloud 39. */ diff --git a/apps/viewer/lib/Listener/SettingsListener.php b/apps/viewer/lib/Listener/SettingsListener.php new file mode 100644 index 0000000000000..7a2c1f0025932 --- /dev/null +++ b/apps/viewer/lib/Listener/SettingsListener.php @@ -0,0 +1,47 @@ + + */ +class SettingsListener implements IEventListener { + public function __construct( + private IInitialState $initialState, + private IUserSession $userSession, + private UserConfig $userConfig, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent) + || $event->getResponse()->getRenderAs() === TemplateResponse::RENDER_AS_ERROR) { + return; + } + + // A guest has nowhere to keep them, and no settings tells the viewer + // not to save any + if ($this->userSession->getUser() === null) { + return; + } + $this->initialState->provideInitialState('config', $this->userConfig->getConfigs()); + } +} diff --git a/apps/viewer/lib/Service/UserConfig.php b/apps/viewer/lib/Service/UserConfig.php new file mode 100644 index 0000000000000..c7e8195f7977e --- /dev/null +++ b/apps/viewer/lib/Service/UserConfig.php @@ -0,0 +1,117 @@ + 'slideshow_delay', + 'default' => 5, + 'min' => 1, + 'max' => 60, + ], + [ + // Volume of videos and audio, in percent + 'key' => 'volume', + 'default' => 100, + 'min' => 0, + 'max' => 100, + ], + [ + // Whether videos and audio start muted + 'key' => 'muted', + 'default' => false, + ], + ]; + + public function __construct( + private IUserConfig $userConfig, + private IUserSession $userSession, + ) { + } + + /** + * Get the list of all allowed user config keys + * + * @return list + */ + public function getAllowedConfigKeys(): array { + return array_column(self::ALLOWED_CONFIGS, 'key'); + } + + /** + * Set a user config + * + * @throws \InvalidArgumentException for an unknown key, or a value it does not accept + * @throws \RuntimeException when no user is logged in + */ + public function setConfig(string $key, int|bool $value): void { + $config = $this->getConfig($key); + if (is_bool($config['default'])) { + if (!is_bool($value)) { + throw new \InvalidArgumentException('Invalid config value'); + } + $this->userConfig->setValueBool($this->getUserId(), Application::APP_ID, $key, $value); + return; + } + + if (!is_int($value) || $value < $config['min'] || $value > $config['max']) { + throw new \InvalidArgumentException('Invalid config value'); + } + $this->userConfig->setValueInt($this->getUserId(), Application::APP_ID, $key, $value); + } + + /** + * Get the current user configs, the defaults for what they never set + * + * @return array + * @throws \RuntimeException when no user is logged in + */ + public function getConfigs(): array { + $userId = $this->getUserId(); + $configs = []; + foreach (self::ALLOWED_CONFIGS as $config) { + $configs[$config['key']] = is_bool($config['default']) + ? $this->userConfig->getValueBool($userId, Application::APP_ID, $config['key'], $config['default']) + : $this->userConfig->getValueInt($userId, Application::APP_ID, $config['key'], $config['default']); + } + return $configs; + } + + /** + * @return array{key: string, default: int, min: int, max: int}|array{key: string, default: bool} + * @throws \InvalidArgumentException for an unknown key + */ + private function getConfig(string $key): array { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config; + } + } + throw new \InvalidArgumentException('Unknown config key'); + } + + private function getUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new \RuntimeException('No user logged in'); + } + return $user->getUID(); + } +} diff --git a/apps/viewer/openapi.json b/apps/viewer/openapi.json new file mode 100644 index 0000000000000..a666866faf1aa --- /dev/null +++ b/apps/viewer/openapi.json @@ -0,0 +1,235 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "viewer", + "version": "0.0.1", + "description": "Simple file viewer with slideshow for media", + "license": { + "name": "agpl" + } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + }, + "bearer_auth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "OCSMeta": { + "type": "object", + "required": [ + "status", + "statuscode" + ], + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "totalitems": { + "type": "string" + }, + "itemsperpage": { + "type": "string" + } + } + } + } + }, + "paths": { + "/ocs/v2.php/apps/viewer/api/v1/config/{key}": { + "put": { + "operationId": "config-set-config", + "summary": "Set one of the user's viewer settings", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "Its new value: a number for a setting with a range, true or false for a toggle", + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "description": "The setting to change", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Setting saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Unknown setting, or a value it does not accept", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + } + }, + "tags": [] +} diff --git a/apps/viewer/openapi.json.license b/apps/viewer/openapi.json.license new file mode 100644 index 0000000000000..8c5ffd5249446 --- /dev/null +++ b/apps/viewer/openapi.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/viewer/tests/Controller/ConfigControllerTest.php b/apps/viewer/tests/Controller/ConfigControllerTest.php new file mode 100644 index 0000000000000..0b4a6b54b3f82 --- /dev/null +++ b/apps/viewer/tests/Controller/ConfigControllerTest.php @@ -0,0 +1,45 @@ +controller = $this->createInstanceWithMocks(ConfigController::class, ['appName' => 'viewer']); + } + + public function testSavesTheSetting(): void { + $this->getAutoMock(UserConfig::class)->expects($this->once()) + ->method('setConfig') + ->with('slideshow_delay', 10); + + $response = $this->controller->setConfig('slideshow_delay', 10); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame(['key' => 'slideshow_delay', 'value' => 10], $response->getData()); + } + + public function testAnswersBadRequestForWhatTheSettingRefuses(): void { + $this->getAutoMock(UserConfig::class)->method('setConfig') + ->willThrowException(new \InvalidArgumentException('Invalid config value')); + + $response = $this->controller->setConfig('slideshow_delay', 0); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $this->assertSame(['message' => 'Invalid config value'], $response->getData()); + } +} diff --git a/apps/viewer/tests/Listener/SettingsListenerTest.php b/apps/viewer/tests/Listener/SettingsListenerTest.php new file mode 100644 index 0000000000000..99deee456bdfe --- /dev/null +++ b/apps/viewer/tests/Listener/SettingsListenerTest.php @@ -0,0 +1,67 @@ + */ + private array $provided = []; + private SettingsListener $listener; + + protected function setUp(): void { + parent::setUp(); + + $this->getAutoMock(IInitialState::class)->method('provideInitialState') + ->willReturnCallback(function (string $key, mixed $data): void { + $this->provided[$key] = $data; + }); + $this->getAutoMock(UserConfig::class)->method('getConfigs') + ->willReturn(['slideshow_delay' => 10]); + + $this->listener = $this->createInstanceWithMocks(SettingsListener::class); + } + + private function signIn(): void { + $this->getAutoMock(IUserSession::class)->method('getUser') + ->willReturn($this->createStub(IUser::class)); + } + + private function page(string $renderAs = TemplateResponse::RENDER_AS_USER): BeforeTemplateRenderedEvent { + return new BeforeTemplateRenderedEvent(true, new TemplateResponse('core', 'empty', [], $renderAs)); + } + + public function testHandsTheViewerTheUsersSettings(): void { + $this->signIn(); + $this->listener->handle($this->page()); + + $this->assertSame(['config' => ['slideshow_delay' => 10]], $this->provided); + } + + public function testHasNoSettingsForAGuest(): void { + $this->listener->handle($this->page()); + + $this->assertSame([], $this->provided); + } + + public function testLeavesTheErrorPageAlone(): void { + $this->signIn(); + $this->listener->handle($this->page(TemplateResponse::RENDER_AS_ERROR)); + + $this->assertSame([], $this->provided); + } +} diff --git a/apps/viewer/tests/LoadViewerTest.php b/apps/viewer/tests/LoadViewerTest.php index 07e68ac747aea..24489175ce9ec 100644 --- a/apps/viewer/tests/LoadViewerTest.php +++ b/apps/viewer/tests/LoadViewerTest.php @@ -15,8 +15,8 @@ use Test\TestCase; /** - * The viewer app is gone, but apps still construct its event without - * checking that it exists: that has to keep working until 39. + * Nothing listens to the viewer's event any more, but apps still construct + * it without checking that it exists: that has to keep working until 39. */ class LoadViewerTest extends TestCase { public function testAppsCanStillDispatchIt(): void { diff --git a/apps/viewer/tests/Service/UserConfigTest.php b/apps/viewer/tests/Service/UserConfigTest.php new file mode 100644 index 0000000000000..f685df36d25b7 --- /dev/null +++ b/apps/viewer/tests/Service/UserConfigTest.php @@ -0,0 +1,96 @@ +config = $this->createInstanceWithMocks(UserConfig::class); + } + + private function signIn(): void { + $user = $this->createStub(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->getAutoMock(IUserSession::class)->method('getUser')->willReturn($user); + } + + public static function dataInRange(): array { + return [ + 'the shortest delay' => ['slideshow_delay', 1], + 'a delay' => ['slideshow_delay', 10], + 'the longest delay' => ['slideshow_delay', 60], + 'no volume' => ['volume', 0], + 'full volume' => ['volume', 100], + ]; + } + + #[DataProvider('dataInRange')] + public function testKeepsANumberInRange(string $key, int $value): void { + $this->signIn(); + $this->getAutoMock(IUserConfig::class)->expects($this->once()) + ->method('setValueInt') + ->with('alice', 'viewer', $key, $value); + + $this->config->setConfig($key, $value); + } + + public function testKeepsAToggle(): void { + $this->signIn(); + $this->getAutoMock(IUserConfig::class)->expects($this->once()) + ->method('setValueBool') + ->with('alice', 'viewer', 'muted', true); + + $this->config->setConfig('muted', true); + } + + public static function dataRefused(): array { + return [ + 'too short' => ['slideshow_delay', 0, 'Invalid config value'], + 'too long' => ['slideshow_delay', 61, 'Invalid config value'], + 'too loud' => ['volume', 101, 'Invalid config value'], + 'a toggle for a number' => ['volume', true, 'Invalid config value'], + 'a number for a toggle' => ['muted', 1, 'Invalid config value'], + 'an unknown setting' => ['theme', 1, 'Unknown config key'], + ]; + } + + #[DataProvider('dataRefused')] + public function testRefusesWhatItDoesNotKnow(string $key, int|bool $value, string $message): void { + $this->signIn(); + $this->getAutoMock(IUserConfig::class)->expects($this->never())->method('setValueInt'); + $this->getAutoMock(IUserConfig::class)->expects($this->never())->method('setValueBool'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + $this->config->setConfig($key, $value); + } + + public function testGivesTheDefaultsForWhatWasNeverSet(): void { + $this->signIn(); + $this->getAutoMock(IUserConfig::class)->method('getValueInt')->willReturnArgument(3); + $this->getAutoMock(IUserConfig::class)->method('getValueBool')->willReturnArgument(3); + + $this->assertSame(['slideshow_delay' => 5, 'volume' => 100, 'muted' => false], $this->config->getConfigs()); + } + + public function testNeedsAUser(): void { + $this->expectException(\RuntimeException::class); + $this->config->getConfigs(); + } +} diff --git a/build/autoloaderchecker.sh b/build/autoloaderchecker.sh index a0c031fd11642..af80db60cac7a 100755 --- a/build/autoloaderchecker.sh +++ b/build/autoloaderchecker.sh @@ -47,8 +47,7 @@ for app in ${REPODIR}/apps/*; do echo "${app} is not shipped. Ignoring autoloader regeneration" continue fi - # apps/viewer is not an app and has no autoloader of its own, the server's covers it - if [[ -d $app && -f $app/appinfo/info.xml ]]; then + if [[ -d $app ]]; then echo echo "Regenerating composer files for ${app}" $COMPOSER_COMMAND i --no-dev -d ${app}/composer || exit 1 diff --git a/build/integration/features/provisioning-v1.feature b/build/integration/features/provisioning-v1.feature index 2730581534e74..cb9006474ed32 100644 --- a/build/integration/features/provisioning-v1.feature +++ b/build/integration/features/provisioning-v1.feature @@ -658,6 +658,7 @@ Feature: provisioning | updatenotification | | user_ldap | | user_status | + | viewer | | workflowengine | | webhook_listeners | | weather_status | diff --git a/build/translation-checker.php b/build/translation-checker.php index b9cf51c8560e5..00d618920ef5d 100644 --- a/build/translation-checker.php +++ b/build/translation-checker.php @@ -16,7 +16,7 @@ $untranslatedApps = [ 'testing', - // Not an app, only the legacy viewer API until Nextcloud 39 + // No strings of its own: the viewer's are in the @nextcloud/viewer package 'viewer', ]; diff --git a/composer.json b/composer.json index 2e58d09b7c995..4bbb3d65630b7 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ "OC\\": "lib/private", "OC\\Core\\": "core/", "OCP\\": "lib/public", - "OCA\\Viewer\\": "apps/viewer/lib", "NCU\\": "lib/unstable" } }, diff --git a/core/shipped.json b/core/shipped.json index a068fa8f61569..81f734cfeb2cd 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -53,6 +53,7 @@ "updatenotification", "user_ldap", "user_status", + "viewer", "weather_status", "webhook_listeners", "workflowengine" @@ -101,6 +102,7 @@ "twofactor_totp", "updatenotification", "user_status", + "viewer", "weather_status", "webhook_listeners" ], @@ -118,6 +120,7 @@ "sharing", "theming", "twofactor_backupcodes", + "viewer", "workflowengine" ] } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 3e97fa37854aa..9e0e07ff07f36 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -84,7 +84,6 @@ 'NCU\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => $baseDir . '/lib/unstable/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php', 'NCU\\WorkflowEngine\\RuntimeOperation' => $baseDir . '/lib/unstable/WorkflowEngine/RuntimeOperation.php', 'NCU\\WorkflowEngine\\RuntimeScope' => $baseDir . '/lib/unstable/WorkflowEngine/RuntimeScope.php', - 'OCA\\Viewer\\Event\\LoadViewer' => $baseDir . '/apps/viewer/lib/Event/LoadViewer.php', 'OCP\\Accounts\\IAccount' => $baseDir . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => $baseDir . '/lib/public/Accounts/IAccountManager.php', 'OCP\\Accounts\\IAccountProperty' => $baseDir . '/lib/public/Accounts/IAccountProperty.php', diff --git a/lib/composer/composer/autoload_psr4.php b/lib/composer/composer/autoload_psr4.php index a1095050c7df6..b07b2c0074bf5 100644 --- a/lib/composer/composer/autoload_psr4.php +++ b/lib/composer/composer/autoload_psr4.php @@ -9,7 +9,6 @@ 'OC\\Core\\' => array($baseDir . '/core'), 'OC\\' => array($baseDir . '/lib/private'), 'OCP\\' => array($baseDir . '/lib/public'), - 'OCA\\Viewer\\' => array($baseDir . '/apps/viewer/lib'), 'NCU\\' => array($baseDir . '/lib/unstable'), '' => array($baseDir . '/lib/private/legacy'), ); diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index d728162291c78..49c7ec0dc070e 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -16,7 +16,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\' => 8, 'OC\\' => 3, 'OCP\\' => 4, - 'OCA\\Viewer\\' => 11, ), 'N' => array ( @@ -37,10 +36,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), - 'OCA\\Viewer\\' => - array ( - 0 => __DIR__ . '/../../..' . '/apps/viewer/lib', - ), 'NCU\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', @@ -130,7 +125,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php', 'NCU\\WorkflowEngine\\RuntimeOperation' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/RuntimeOperation.php', 'NCU\\WorkflowEngine\\RuntimeScope' => __DIR__ . '/../../..' . '/lib/unstable/WorkflowEngine/RuntimeScope.php', - 'OCA\\Viewer\\Event\\LoadViewer' => __DIR__ . '/../../..' . '/apps/viewer/lib/Event/LoadViewer.php', 'OCP\\Accounts\\IAccount' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountManager.php', 'OCP\\Accounts\\IAccountProperty' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountProperty.php', diff --git a/lib/private/Repair/CleanUpAbandonedApps.php b/lib/private/Repair/CleanUpAbandonedApps.php index 7eeac3e313008..be08b210e5691 100644 --- a/lib/private/Repair/CleanUpAbandonedApps.php +++ b/lib/private/Repair/CleanUpAbandonedApps.php @@ -13,7 +13,7 @@ use OCP\Migration\IRepairStep; class CleanUpAbandonedApps implements IRepairStep { - protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer', 'viewer']; + protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer']; public function __construct( private readonly IConfig $config, diff --git a/openapi.json b/openapi.json index d2f20fb8d4b35..64f1ead2bf5ec 100644 --- a/openapi.json +++ b/openapi.json @@ -45220,6 +45220,189 @@ } } }, + "/ocs/v2.php/apps/viewer/api/v1/config/{key}": { + "put": { + "operationId": "viewer-config-set-config", + "summary": "Set one of the user's viewer settings", + "tags": [ + "viewer/config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "Its new value: a number for a setting with a range, true or false for a toggle", + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "description": "The setting to change", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Setting saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Unknown setting, or a value it does not accept", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/weather_status/api/v1/mode": { "put": { "operationId": "weather_status-weather_status-set-mode", diff --git a/tests/lib/App/AppManagerTest.php b/tests/lib/App/AppManagerTest.php index 9f6577cdb030f..26c5f9d60165c 100644 --- a/tests/lib/App/AppManagerTest.php +++ b/tests/lib/App/AppManagerTest.php @@ -564,6 +564,7 @@ public function testGetEnabledApps(): void { 'test3', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ]; $this->assertEquals($apps, $this->manager->getEnabledApps()); @@ -596,6 +597,7 @@ public function testGetAppsForUser(): void { 'test3', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForUser($user)); @@ -636,6 +638,7 @@ public function testGetAppsNeedingUpgrade(): void { 'sharing' => ['id' => 'sharing'], 'theming' => ['id' => 'theming'], 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], + 'viewer' => ['id' => 'viewer'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], ]; @@ -700,6 +703,7 @@ public function testGetIncompatibleApps(): void { 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], + 'viewer' => ['id' => 'viewer'], ]; $manager->expects($this->any()) @@ -747,6 +751,7 @@ public function testGetEnabledAppsForGroup(): void { 'test3', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForGroup($group)); diff --git a/tests/lib/AppTest.php b/tests/lib/AppTest.php index 20349093c8c3e..23cb10f0477e9 100644 --- a/tests/lib/AppTest.php +++ b/tests/lib/AppTest.php @@ -109,6 +109,7 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ], false @@ -134,6 +135,7 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ], false @@ -160,6 +162,7 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ], false @@ -186,6 +189,7 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ], false, @@ -212,6 +216,7 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', + 'viewer', 'workflowengine', ], true, @@ -274,11 +279,11 @@ public function testEnabledAppsCache(): void { ); $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); // mock should not be called again here $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); $this->restoreAppConfig(); $userSession->setUser(null);