Compare commits

...

4 Commits

Author SHA1 Message Date
provokateurin ad715aa1ae fixup! wip 2026-03-25 13:21:18 +01:00
provokateurin e564c61e16 wip 2026-03-24 08:03:14 +01:00
provokateurin 4888d7c30f fix(psalm-strict): Also check app tests
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-03-23 18:22:40 +01:00
provokateurin 2d729e1c3d feat(psalm): Enable PHPUnit plugin
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-03-23 18:22:40 +01:00
105 changed files with 8614 additions and 28 deletions
+1
View File
@@ -38,6 +38,7 @@ node_modules/
!/apps/profile
!/apps/provisioning_api
!/apps/settings
!/apps/sharing
!/apps/systemtags
!/apps/testing
!/apps/admin_audit
+1 -1
View File
@@ -400,7 +400,7 @@ SPDX-FileCopyrightText = "2019 Fabian Wiktor <https://www.pexels.com/photo/green
SPDX-License-Identifier = "CC0-1.0"
[[annotations]]
path = ["openapi.json", ".envrc", "flake.nix", "flake.lock", "build/eslint-baseline.json", "build/eslint-baseline-legacy.json"]
path = ["openapi.json", ".envrc", "flake.nix", "flake.lock", "build/eslint-baseline.json", "build/eslint-baseline-legacy.json", "apps/sharing/openapi.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"
@@ -95,4 +95,5 @@ return array(
'OCA\\Files\\Service\\ViewConfig' => $baseDir . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\AdminSettings' => $baseDir . '/../lib/Settings/AdminSettings.php',
'OCA\\Files\\Settings\\PersonalSettings' => $baseDir . '/../lib/Settings/PersonalSettings.php',
'OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType' => $baseDir . '/../lib/Sharing/SourceType/NodeShareSourceType.php',
);
@@ -110,6 +110,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Service\\ViewConfig' => __DIR__ . '/..' . '/../lib/Service/ViewConfig.php',
'OCA\\Files\\Settings\\AdminSettings' => __DIR__ . '/..' . '/../lib/Settings/AdminSettings.php',
'OCA\\Files\\Settings\\PersonalSettings' => __DIR__ . '/..' . '/../lib/Settings/PersonalSettings.php',
'OCA\\Files\\Sharing\\SourceType\\NodeShareSourceType' => __DIR__ . '/..' . '/../lib/Sharing/SourceType/NodeShareSourceType.php',
);
public static function getInitializer(ClassLoader $loader)
+4
View File
@@ -30,6 +30,8 @@ use OCA\Files\Search\FilesSearchProvider;
use OCA\Files\Service\TagService;
use OCA\Files\Service\UserConfig;
use OCA\Files\Service\ViewConfig;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCA\Sharing\Registry;
use OCP\Activity\IManager as IActivityManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -52,6 +54,7 @@ use OCP\IRequest;
use OCP\IServerContainer;
use OCP\ITagManager;
use OCP\IUserSession;
use OCP\Server;
use OCP\Share\IManager as IShareManager;
use OCP\Util;
use Psr\Container\ContainerInterface;
@@ -128,6 +131,7 @@ class Application extends App implements IBootstrap {
$context->registerConfigLexicon(ConfigLexicon::class);
Server::get(Registry::class)->registerSourceType(new NodeShareSourceType());
}
public function boot(IBootContext $context): void {
@@ -0,0 +1,35 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Files\Sharing\SourceType;
use OCA\Sharing\Model\IShareSourceType;
use OCP\Files\IRootFolder;
use OCP\IL10N;
use OCP\IUser;
use OCP\Server;
class NodeShareSourceType implements IShareSourceType {
public function getDisplayName(): string {
return Server::get(IL10N::class)->t('File or folder');
}
public function validateSource(IUser $owner, string $source): bool {
return Server::get(IRootFolder::class)->getUserFolder($owner->getUID())->getFirstNodeById((int)$source) !== null;
}
public function getSourceDisplayName(string $source): ?string {
$displayName = Server::get(IRootFolder::class)->getFirstNodeById((int)$source)?->getName();
if ($displayName === '') {
return null;
}
return $displayName;
}
}
@@ -36,7 +36,7 @@ use Psr\Log\LoggerInterface;
use Test\TestCase;
/**
* Class ApiController
* Class ApiV1Controller
*
* @package OCA\Files\Controller
*/
@@ -0,0 +1,76 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
use OC\Files\Filesystem;
use OC\User\Database;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCP\Files\IRootFolder;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Server;
use PHPUnit\Framework\Attributes\Group;
use Test\TestCase;
#[Group(name: 'DB')]
class NodeShareSourceTypeTest extends TestCase {
private IUser $user1;
private NodeShareSourceType $sourceType;
public function setUp(): void {
parent::setUp();
$userManager = Server::get(IUserManager::class);
$userManager->clearBackends();
$userManager->registerBackend(new Database());
$user1 = $userManager->createUser('user1', 'password');
$this->assertNotFalse($user1);
$this->user1 = $user1;
$this->sourceType = new NodeShareSourceType();
}
protected function tearDown(): void {
$this->user1->delete();
Filesystem::tearDown();
parent::tearDown();
}
public function testGetDisplayName(): void {
$this->overwriteService(IL10N::class, Server::get(IFactory::class)->get(''));
$this->assertEquals('File or folder', $this->sourceType->getDisplayName());
}
public function testValidateSource(): void {
$userFolder = Server::get(IRootFolder::class)->getUserFolder($this->user1->getUID());
$node = $userFolder->newFile('foo.txt', 'bar');
$source = (string)$node->getId();
$this->assertTrue($this->sourceType->validateSource($this->user1, $source));
$node->delete();
$this->assertFalse($this->sourceType->validateSource($this->user1, $source));
}
public function testGetSourceDisplayName(): void {
$userFolder = Server::get(IRootFolder::class)->getUserFolder($this->user1->getUID());
$node = $userFolder->newFile('foo.txt', 'bar');
$source = (string)$node->getId();
$this->assertEquals('foo.txt', $this->sourceType->getSourceDisplayName($source));
}
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>sharing</id>
<name>Sharing</name>
<summary>TODO</summary>
<description>TODO</description>
<version>1.0.0</version>
<licence>AGPL-3.0-or-later</licence>
<author>Kate Döen</author>
<namespace>Sharing</namespace>
<category>customization</category>
<bugs>https://github.com/nextcloud/server/issues</bugs>
<dependencies>
<nextcloud min-version="33" max-version="33"/>
</dependencies>
<commands>
<command>\OCA\Sharing\Command\Create</command>
<command>\OCA\Sharing\Command\Delete</command>
<command>\OCA\Sharing\Command\Get</command>
<command>\OCA\Sharing\Command\Update</command>
</commands>
</info>
+22
View File
@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitSharing::getLoader();
+13
View File
@@ -0,0 +1,13 @@
{
"config" : {
"vendor-dir": ".",
"optimize-autoloader": true,
"classmap-authoritative": true,
"autoloader-suffix": "Sharing"
},
"autoload" : {
"psr-4": {
"OCA\\Sharing\\": "../lib/"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d751713988987e9331980363e24189ce",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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 <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @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<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
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<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $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>|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>|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>|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>|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<string, self>
*/
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);
}
}
@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string>
*/
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<string>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
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<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $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<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}
+21
View File
@@ -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.
@@ -0,0 +1,40 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\Sharing\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\Sharing\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
'OCA\\Sharing\\Command\\Create' => $baseDir . '/../lib/Command/Create.php',
'OCA\\Sharing\\Command\\Delete' => $baseDir . '/../lib/Command/Delete.php',
'OCA\\Sharing\\Command\\Get' => $baseDir . '/../lib/Command/Get.php',
'OCA\\Sharing\\Command\\Update' => $baseDir . '/../lib/Command/Update.php',
'OCA\\Sharing\\Controller\\ApiV1Controller' => $baseDir . '/../lib/Controller/ApiV1Controller.php',
'OCA\\Sharing\\Exception\\AShareException' => $baseDir . '/../lib/Exception/AShareException.php',
'OCA\\Sharing\\Exception\\ShareConflictException' => $baseDir . '/../lib/Exception/ShareConflictException.php',
'OCA\\Sharing\\Exception\\ShareInvalidException' => $baseDir . '/../lib/Exception/ShareInvalidException.php',
'OCA\\Sharing\\Exception\\ShareInvalidOperationParameterException' => $baseDir . '/../lib/Exception/ShareInvalidOperationParameterException.php',
'OCA\\Sharing\\Exception\\ShareInvalidPropertiesException' => $baseDir . '/../lib/Exception/ShareInvalidPropertiesException.php',
'OCA\\Sharing\\Exception\\ShareNotFoundException' => $baseDir . '/../lib/Exception/ShareNotFoundException.php',
'OCA\\Sharing\\Exception\\ShareOperationNotAllowedException' => $baseDir . '/../lib/Exception/ShareOperationNotAllowedException.php',
'OCA\\Sharing\\Manager' => $baseDir . '/../lib/Manager.php',
'OCA\\Sharing\\Migration\\Version1000Date20250929161325' => $baseDir . '/../lib/Migration/Version1000Date20250929161325.php',
'OCA\\Sharing\\Model\\IShareFeature' => $baseDir . '/../lib/Model/IShareFeature.php',
'OCA\\Sharing\\Model\\IShareFeatureFilter' => $baseDir . '/../lib/Model/IShareFeatureFilter.php',
'OCA\\Sharing\\Model\\IShareFeatureModifyProperties' => $baseDir . '/../lib/Model/IShareFeatureModifyProperties.php',
'OCA\\Sharing\\Model\\IShareRecipientType' => $baseDir . '/../lib/Model/IShareRecipientType.php',
'OCA\\Sharing\\Model\\IShareRecipientTypeSearch' => $baseDir . '/../lib/Model/IShareRecipientTypeSearch.php',
'OCA\\Sharing\\Model\\IShareSourceType' => $baseDir . '/../lib/Model/IShareSourceType.php',
'OCA\\Sharing\\Model\\Share' => $baseDir . '/../lib/Model/Share.php',
'OCA\\Sharing\\Model\\ShareAccessContext' => $baseDir . '/../lib/Model/ShareAccessContext.php',
'OCA\\Sharing\\Model\\ShareOwner' => $baseDir . '/../lib/Model/ShareOwner.php',
'OCA\\Sharing\\Model\\ShareRecipient' => $baseDir . '/../lib/Model/ShareRecipient.php',
'OCA\\Sharing\\Model\\ShareRecipientSearchResult' => $baseDir . '/../lib/Model/ShareRecipientSearchResult.php',
'OCA\\Sharing\\Model\\ShareSource' => $baseDir . '/../lib/Model/ShareSource.php',
'OCA\\Sharing\\Registry' => $baseDir . '/../lib/Registry.php',
'OCA\\Sharing\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
);
@@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
);
@@ -0,0 +1,10 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
'OCA\\Sharing\\' => array($baseDir . '/../lib'),
);
@@ -0,0 +1,37 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInitSharing
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
spl_autoload_register(array('ComposerAutoloaderInitSharing', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInitSharing', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInitSharing::getInitializer($loader));
$loader->setClassMapAuthoritative(true);
$loader->register(true);
return $loader;
}
}
@@ -0,0 +1,66 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInitSharing
{
public static $prefixLengthsPsr4 = array (
'O' =>
array (
'OCA\\Sharing\\' => 12,
),
);
public static $prefixDirsPsr4 = array (
'OCA\\Sharing\\' =>
array (
0 => __DIR__ . '/..' . '/../lib',
),
);
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\Sharing\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\Sharing\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
'OCA\\Sharing\\Command\\Create' => __DIR__ . '/..' . '/../lib/Command/Create.php',
'OCA\\Sharing\\Command\\Delete' => __DIR__ . '/..' . '/../lib/Command/Delete.php',
'OCA\\Sharing\\Command\\Get' => __DIR__ . '/..' . '/../lib/Command/Get.php',
'OCA\\Sharing\\Command\\Update' => __DIR__ . '/..' . '/../lib/Command/Update.php',
'OCA\\Sharing\\Controller\\ApiV1Controller' => __DIR__ . '/..' . '/../lib/Controller/ApiV1Controller.php',
'OCA\\Sharing\\Exception\\AShareException' => __DIR__ . '/..' . '/../lib/Exception/AShareException.php',
'OCA\\Sharing\\Exception\\ShareConflictException' => __DIR__ . '/..' . '/../lib/Exception/ShareConflictException.php',
'OCA\\Sharing\\Exception\\ShareInvalidException' => __DIR__ . '/..' . '/../lib/Exception/ShareInvalidException.php',
'OCA\\Sharing\\Exception\\ShareInvalidOperationParameterException' => __DIR__ . '/..' . '/../lib/Exception/ShareInvalidOperationParameterException.php',
'OCA\\Sharing\\Exception\\ShareInvalidPropertiesException' => __DIR__ . '/..' . '/../lib/Exception/ShareInvalidPropertiesException.php',
'OCA\\Sharing\\Exception\\ShareNotFoundException' => __DIR__ . '/..' . '/../lib/Exception/ShareNotFoundException.php',
'OCA\\Sharing\\Exception\\ShareOperationNotAllowedException' => __DIR__ . '/..' . '/../lib/Exception/ShareOperationNotAllowedException.php',
'OCA\\Sharing\\Manager' => __DIR__ . '/..' . '/../lib/Manager.php',
'OCA\\Sharing\\Migration\\Version1000Date20250929161325' => __DIR__ . '/..' . '/../lib/Migration/Version1000Date20250929161325.php',
'OCA\\Sharing\\Model\\IShareFeature' => __DIR__ . '/..' . '/../lib/Model/IShareFeature.php',
'OCA\\Sharing\\Model\\IShareFeatureFilter' => __DIR__ . '/..' . '/../lib/Model/IShareFeatureFilter.php',
'OCA\\Sharing\\Model\\IShareFeatureModifyProperties' => __DIR__ . '/..' . '/../lib/Model/IShareFeatureModifyProperties.php',
'OCA\\Sharing\\Model\\IShareRecipientType' => __DIR__ . '/..' . '/../lib/Model/IShareRecipientType.php',
'OCA\\Sharing\\Model\\IShareRecipientTypeSearch' => __DIR__ . '/..' . '/../lib/Model/IShareRecipientTypeSearch.php',
'OCA\\Sharing\\Model\\IShareSourceType' => __DIR__ . '/..' . '/../lib/Model/IShareSourceType.php',
'OCA\\Sharing\\Model\\Share' => __DIR__ . '/..' . '/../lib/Model/Share.php',
'OCA\\Sharing\\Model\\ShareAccessContext' => __DIR__ . '/..' . '/../lib/Model/ShareAccessContext.php',
'OCA\\Sharing\\Model\\ShareOwner' => __DIR__ . '/..' . '/../lib/Model/ShareOwner.php',
'OCA\\Sharing\\Model\\ShareRecipient' => __DIR__ . '/..' . '/../lib/Model/ShareRecipient.php',
'OCA\\Sharing\\Model\\ShareRecipientSearchResult' => __DIR__ . '/..' . '/../lib/Model/ShareRecipientSearchResult.php',
'OCA\\Sharing\\Model\\ShareSource' => __DIR__ . '/..' . '/../lib/Model/ShareSource.php',
'OCA\\Sharing\\Registry' => __DIR__ . '/..' . '/../lib/Registry.php',
'OCA\\Sharing\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInitSharing::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInitSharing::$prefixDirsPsr4;
$loader->classMap = ComposerStaticInitSharing::$classMap;
}, null, ClassLoader::class);
}
}
@@ -0,0 +1,5 @@
{
"packages": [],
"dev": false,
"dev-package-names": []
}
@@ -0,0 +1,23 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '80e46b06f38dbb04b413d427a75ef60eb5b29e18',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'dev' => false,
),
'versions' => array(
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '80e46b06f38dbb04b413d427a75ef60eb5b29e18',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'dev_requirement' => false,
),
),
);
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\AppInfo;
use OCA\Sharing\Capabilities;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'sharing';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
}
public function boot(IBootContext $context): void {
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing;
use OCA\Sharing\AppInfo\Application;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareSourceType;
use OCP\Capabilities\ICapability;
/**
* @psalm-import-type SharingFeature from ResponseDefinitions
*/
class Capabilities implements ICapability {
public function __construct(
private readonly Registry $registry,
) {
}
/**
* @return array{
* sharing: array{
* api_versions: list<'v1'>,
* source_types: array<class-string<IShareSourceType>, non-empty-string>,
* recipient_types: array<class-string<IShareRecipientType>, non-empty-string>,
* features: array<class-string<IShareFeature>, SharingFeature>,
* },
* }
*/
public function getCapabilities(): array {
return [
Application::APP_ID => [
'api_versions' => ['v1'],
'source_types' => array_map(static fn (IShareSourceType $sourceType): string => $sourceType->getDisplayName(), $this->registry->getSourceTypes()),
'recipient_types' => array_map(static fn (IShareRecipientType $recipientType): string => $recipientType->getDisplayName(), $this->registry->getRecipientTypes()),
'features' => array_map(static fn (IShareFeature $feature): array => [
'compatibles' => $feature->getCompatibles(),
], $this->registry->getFeatures()),
],
];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Command;
use OCA\Sharing\Exception\AShareException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\Share;
use OCA\Sharing\Model\ShareAccessContext;
use OCA\Sharing\ResponseDefinitions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @psalm-import-type SharingPartialShare from ResponseDefinitions
*/
class Create extends Command {
public function __construct(
private readonly Manager $manager,
) {
parent::__construct();
}
protected function configure(): void {
$this
->setName('share:create')
->setDescription('create a new share')
->addArgument('data', InputArgument::REQUIRED, 'Share data');
}
public function execute(InputInterface $input, OutputInterface $output): int {
/** @var SharingPartialShare $data */
$data = json_decode((string)$input->getArgument('data'), true, 512, JSON_THROW_ON_ERROR);
$share = Share::fromArray($this->manager->completePartialShareData($data));
try {
$this->manager->insert($share);
$output->writeln(json_encode($this->manager->get(new ShareAccessContext(force: true), $share->id)->toArray(), JSON_THROW_ON_ERROR));
} catch (AShareException $aShareException) {
$output->writeln($aShareException->getMessage());
return 1;
}
return 0;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Command;
use OCA\Sharing\Exception\AShareException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\ShareAccessContext;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Delete extends Command {
public function __construct(
private readonly Manager $manager,
) {
parent::__construct();
}
protected function configure(): void {
$this
->setName('share:delete')
->setDescription('delete an existing share')
->addArgument('id', InputArgument::REQUIRED, 'Share ID');
}
public function execute(InputInterface $input, OutputInterface $output): int {
$id = (string)$input->getArgument('id');
try {
$this->manager->delete(new ShareAccessContext(force: true), $id);
} catch (AShareException $aShareException) {
$output->writeln($aShareException->getMessage());
return 1;
}
return 0;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Command;
use OCA\Sharing\Exception\AShareException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\ShareAccessContext;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Get extends Command {
public function __construct(
private readonly Manager $manager,
) {
parent::__construct();
}
protected function configure(): void {
$this
->setName('share:get')
->setDescription('get the data of a share')
->addArgument('id', InputArgument::REQUIRED, 'Share ID');
}
public function execute(InputInterface $input, OutputInterface $output): int {
$id = (string)$input->getArgument('id');
try {
$share = $this->manager->get(new ShareAccessContext(force: true), $id);
} catch (AShareException $aShareException) {
$output->writeln($aShareException->getMessage());
return 1;
}
$output->writeln(json_encode($share->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
return 0;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Command;
use OCA\Sharing\Exception\AShareException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\Share;
use OCA\Sharing\Model\ShareAccessContext;
use OCA\Sharing\ResponseDefinitions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @psalm-import-type SharingShare from ResponseDefinitions
*/
class Update extends Command {
public function __construct(
private readonly Manager $manager,
) {
parent::__construct();
}
protected function configure(): void {
$this
->setName('share:update')
->setDescription('update an existing share')
->addArgument('data', InputArgument::REQUIRED, 'Share data');
}
public function execute(InputInterface $input, OutputInterface $output): int {
/** @var SharingShare $data */
$data = json_decode((string)$input->getArgument('data'), true, 512, JSON_THROW_ON_ERROR);
$share = Share::fromArray($data);
try {
$this->manager->update(new ShareAccessContext(force: true), $share);
} catch (AShareException $aShareException) {
$output->writeln($aShareException->getMessage());
return 1;
}
try {
$share = $this->manager->get(new ShareAccessContext(force: true), $share->id);
} catch (AShareException $aShareException) {
$output->writeln($aShareException->getMessage());
return 1;
}
$output->writeln(json_encode($share->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
return 0;
}
}
@@ -0,0 +1,262 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Controller;
use OCA\Sharing\Exception\ShareConflictException;
use OCA\Sharing\Exception\ShareInvalidException;
use OCA\Sharing\Exception\ShareInvalidOperationParameterException;
use OCA\Sharing\Exception\ShareNotFoundException;
use OCA\Sharing\Exception\ShareOperationNotAllowedException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\IShareFeatureFilter;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareSourceType;
use OCA\Sharing\Model\Share;
use OCA\Sharing\Model\ShareAccessContext;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCA\Sharing\ResponseDefinitions;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
// TODO: Rate limit recipients during share create and update
// TODO: Add federation
/**
* @psalm-import-type SharingShare from ResponseDefinitions
* @psalm-import-type SharingPartialShare from ResponseDefinitions
* @psalm-import-type SharingFeature from ResponseDefinitions
* @psalm-import-type SharingRecipientSearchResult from ResponseDefinitions
*/
class ApiV1Controller extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly IUserSession $userSession,
private readonly Manager $manager,
) {
parent::__construct($appName, $request);
}
/**
* Searches for recipients
*
* @param ?class-string<IShareRecipientType> $recipientType Type of the recipients
* @param non-empty-string $query The query to search for
* @param int<1, 100> $limit The maximum number of participants
* @param non-negative-int $offset The offset of the participants
* @return DataResponse<Http::STATUS_OK, list<SharingRecipientSearchResult>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
*
* 200: Recipients returned
* 400: Bad recipient search parameters
*/
#[UserRateLimit(limit: 1, period: 1)]
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/recipients')]
public function searchRecipients(?string $recipientType, string $query, int $limit = 10, int $offset = 0): DataResponse {
/** @psalm-suppress TypeDoesNotContainType */
if ($query === '') {
throw new ShareInvalidOperationParameterException('The query is empty.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($limit < 1) {
throw new ShareInvalidOperationParameterException('The limit is too low.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($limit > 100) {
throw new ShareInvalidOperationParameterException('The limit is too high.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($offset < 0) {
throw new ShareInvalidOperationParameterException('The offset is too low.');
}
try {
$recipientSearchResults = $this->manager->searchRecipients($recipientType, $query, $limit, $offset);
} catch (ShareInvalidOperationParameterException $shareInvalidOperationParameterException) {
return new DataResponse($shareInvalidOperationParameterException->getMessage(), Http::STATUS_BAD_REQUEST);
}
return new DataResponse(array_map(static fn (ShareRecipientSearchResult $result): array => $result->toArray(), $recipientSearchResults));
}
/**
* Creates a new share
*
* @param SharingPartialShare $data The new share data
* @return DataResponse<Http::STATUS_CREATED, SharingShare, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_FORBIDDEN, string, array{}>
*
* 201: Share created successfully
* 400: Invalid share data
* 403: Creating the share is not allowed
*/
#[UserRateLimit(limit: 1, period: 5)]
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/v1/share')]
public function createShare(array $data): DataResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse('Not logged in', Http::STATUS_UNAUTHORIZED);
}
$share = Share::fromArray($this->manager->completePartialShareData($data));
try {
$this->manager->insert($share);
} catch (ShareInvalidException $e) {
return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST);
} catch (ShareOperationNotAllowedException $e) {
return new DataResponse($e->getMessage(), Http::STATUS_FORBIDDEN);
}
return new DataResponse($this->manager->get(new ShareAccessContext(force: true), $share->id)->toArray(), Http::STATUS_CREATED);
}
/**
* Gets a share
*
* @param string $id ID of the share
* @param array<class-string<IShareRecipientType|IShareFeatureFilter>, mixed> $arguments Arguments for accessing the share
* @return DataResponse<Http::STATUS_OK, SharingShare, array{}>|DataResponse<Http::STATUS_NOT_FOUND, string, array{}>
*
* 200: Share returned
* 404: Share not found
*/
#[UserRateLimit(limit: 1, period: 1)]
#[AnonRateLimit(limit: 1, period: 5)]
#[PublicPage]
// This should be a GET, but GET doesn't allow a request body which is required for the $arguments.
#[ApiRoute(verb: 'POST', url: '/api/v1/share/{id}')]
public function getShare(string $id, array $arguments = []): DataResponse {
$user = $this->userSession->getUser();
try {
$share = $this->manager->get(new ShareAccessContext($user, $arguments), $id);
} catch (ShareNotFoundException $shareNotFoundException) {
return new DataResponse($shareNotFoundException->getMessage(), Http::STATUS_NOT_FOUND);
}
return new DataResponse($share->toArray());
}
/**
* Gets all shares
*
* @param ?class-string<IShareSourceType> $sourceType Filter by source type.
* @param ?string $lastShareId The ID of the previous share. This is used as an offset and only shares with higher IDs are returned.
* @param int<1, 100> $limit The number of shares to return.
* @return DataResponse<Http::STATUS_OK, list<SharingShare>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, string, array{}>
*
* 200: Shares returned
*/
#[UserRateLimit(limit: 1, period: 5)]
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/v1/shares')]
public function getShares(?string $sourceType = null, ?string $lastShareId = null, int $limit = 100) {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse('Not logged in', Http::STATUS_UNAUTHORIZED);
}
/** @psalm-suppress DocblockTypeContradiction */
if ($limit < 1) {
throw new ShareInvalidOperationParameterException('The limit is too low.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($limit > 100) {
throw new ShareInvalidOperationParameterException('The limit is too high.');
}
$shares = $this->manager->list(new ShareAccessContext($user), $sourceType, $lastShareId, $limit);
return new DataResponse(array_map(static fn (Share $share): array => $share->toArray(), $shares));
}
/**
* Updates a share
*
* @param non-empty-string $id ID of the share
* @param SharingShare $data The updated share data
* @return DataResponse<Http::STATUS_OK, SharingShare, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_CONFLICT, string, array{}>
*
* 200: Share updated
* 400: Invalid share data
* 404: Share not found
* 409: The share has been updated in the meantime, so you cannot update it.
*/
#[UserRateLimit(limit: 1, period: 1)]
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/v1/share/{id}')]
public function updateShare(string $id, array $data): DataResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse('Not logged in', Http::STATUS_UNAUTHORIZED);
}
if ($data['id'] !== $id) {
return new DataResponse('Share IDs do not match', Http::STATUS_BAD_REQUEST);
}
$share = Share::fromArray($data);
try {
$this->manager->update(new ShareAccessContext($user), $share);
} catch (ShareInvalidException $e) {
return new DataResponse($e->getMessage(), Http::STATUS_BAD_REQUEST);
} catch (ShareNotFoundException $e) {
return new DataResponse($e->getMessage(), Http::STATUS_NOT_FOUND);
} catch (ShareConflictException $e) {
return new DataResponse($e->getMessage(), Http::STATUS_CONFLICT);
}
$share = $this->manager->get(new ShareAccessContext($user), $share->id);
return new DataResponse($share->toArray());
}
/**
* Deletes a share
*
* @param string $id ID of the share
* @return DataResponse<Http::STATUS_NO_CONTENT, list<empty>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, string, array{}>
*
* 204: Share deleted
* 404: Share not found
*/
#[UserRateLimit(limit: 1, period: 1)]
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/v1/share/{id}')]
public function deleteShare(string $id): DataResponse {
$user = $this->userSession->getUser();
if ($user === null) {
return new DataResponse('Not logged in', Http::STATUS_UNAUTHORIZED);
}
try {
$this->manager->delete(new ShareAccessContext($user), $id);
} catch (ShareNotFoundException $shareNotFoundException) {
return new DataResponse($shareNotFoundException->getMessage(), Http::STATUS_NOT_FOUND);
}
return new DataResponse([], Http::STATUS_NO_CONTENT);
}
}
@@ -0,0 +1,15 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
use Exception;
abstract class AShareException extends Exception {
}
@@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
class ShareConflictException extends AShareException {
public function __construct() {
parent::__construct('The share has been updated in the meantime, so you cannot update it.');
}
}
@@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
class ShareInvalidException extends AShareException {
public function __construct(string $message) {
parent::__construct('Invalid share: ' . $message);
}
}
@@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
class ShareInvalidOperationParameterException extends AShareException {
public function __construct(string $parameter) {
parent::__construct('Invalid operation parameter: ' . $parameter);
}
}
@@ -0,0 +1,21 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
use OCA\Sharing\Model\IShareFeature;
class ShareInvalidPropertiesException extends ShareInvalidException {
/**
* @param class-string<IShareFeature> $feature
*/
public function __construct(string $feature) {
parent::__construct('The properties for feature ' . $feature . ' are not valid.');
}
}
@@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
class ShareNotFoundException extends AShareException {
public function __construct(string $shareID) {
parent::__construct('Share ' . $shareID . ' not found.');
}
}
@@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Exception;
class ShareOperationNotAllowedException extends AShareException {
public function __construct() {
parent::__construct('Share operation not allowed.');
}
}
+585
View File
@@ -0,0 +1,585 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing;
use OCA\Sharing\Exception\ShareConflictException;
use OCA\Sharing\Exception\ShareInvalidException;
use OCA\Sharing\Exception\ShareInvalidOperationParameterException;
use OCA\Sharing\Exception\ShareInvalidPropertiesException;
use OCA\Sharing\Exception\ShareNotFoundException;
use OCA\Sharing\Exception\ShareOperationNotAllowedException;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareFeatureFilter;
use OCA\Sharing\Model\IShareFeatureModifyProperties;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareRecipientTypeSearch;
use OCA\Sharing\Model\IShareSourceType;
use OCA\Sharing\Model\Share;
use OCA\Sharing\Model\ShareAccessContext;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use OCP\Snowflake\ISnowflakeGenerator;
use RuntimeException;
// TODO: Add permission model
// TODO: Add reshares
// TODO: Add listeners to remove recipients and sources when they are deleted
// TODO: Update throws annotations and catch them in the controller
/**
* @psalm-import-type SharingShare from ResponseDefinitions
* @psalm-import-type SharingPartialShare from ResponseDefinitions
*/
class Manager {
public function __construct(
private readonly IDBConnection $connection,
private readonly Registry $registry,
private readonly IUserManager $userManager,
) {
}
/**
* @param SharingPartialShare $share
* @return SharingShare
*/
public function completePartialShareData(array $share): array {
$share['id'] = Server::get(ISnowflakeGenerator::class)->nextId();
$share['last_updated'] = $this->generateLastUpdated();
return $share;
}
/**
* @return non-negative-int
*/
private function generateLastUpdated(): int {
$time = (int)(microtime(true) * 1000);
if ($time < 0) {
throw new RuntimeException('Have you invented time travel?');
}
return $time;
}
/**
* @param ?class-string<IShareRecipientType> $recipientTypeClass
* @param non-empty-string $query
* @param positive-int $limit
* @param non-negative-int $offset
* @return list<ShareRecipientSearchResult>
* @throws ShareInvalidOperationParameterException
*/
public function searchRecipients(?string $recipientTypeClass, string $query, int $limit, int $offset): array {
$recipientTypes = $this->registry->getRecipientTypes();
if ($recipientTypeClass !== null) {
if (!isset($recipientTypes[$recipientTypeClass])) {
throw new ShareInvalidOperationParameterException('The recipient type is not registered.');
}
$recipientTypes = [($recipientTypes[$recipientTypeClass])];
}
$searchableRecipientTypes = array_values(array_filter(
$recipientTypes,
static fn (IShareRecipientType $recipientType): bool => $recipientType instanceof IShareRecipientTypeSearch,
));
return array_merge(...array_map(
static fn (IShareRecipientTypeSearch $recipientType): array => array_map(
static fn (ShareRecipientSearchResult $result): ShareRecipientSearchResult => $result->setType($recipientType::class),
$recipientType->searchRecipients($query, $limit, $offset),
),
$searchableRecipientTypes,
));
}
/**
* @throws ShareOperationNotAllowedException
*/
private function validateShareOwnerOperation(ShareAccessContext $accessContext, Share $share): void {
if ($accessContext->force) {
return;
}
if (!$accessContext->currentUser instanceof IUser) {
throw new ShareOperationNotAllowedException();
}
if ($share->owner->userId !== $accessContext->currentUser->getUID()) {
throw new ShareOperationNotAllowedException();
}
}
/**
* The share might be updated during insertion (e.g. display name changes),
* so if you use the share afterwards get it from the manager again.
*
* @throws ShareOperationNotAllowedException
* @throws ShareInvalidException
*/
public function insert(Share $share): void {
/** @psalm-suppress PossiblyNullReference Can't happen since share is valid */
$ownerDisplayName = $this->userManager->get($share->owner->userId)->getDisplayName();
$this->connection->beginTransaction();
$qb = $this->connection->getQueryBuilder();
$qb
->insert('sharing_share')
->values([
'id' => $qb->createNamedParameter((int)$share->id, IQueryBuilder::PARAM_INT),
'owner' => $qb->createNamedParameter($share->owner->userId),
'owner_display_name' => $qb->createNamedParameter($ownerDisplayName),
'last_updated' => $qb->createNamedParameter($share->lastUpdated),
])
->executeStatement();
$this->insertSources($share);
$this->insertRecipients($share);
$this->insertProperties($share);
try {
$this->connection->commit();
} catch (Exception $exception) {
$this->connection->rollBack();
throw $exception;
}
}
private function insertSources(Share $share): void {
$qb = $this->connection->getQueryBuilder()
->insert('sharing_share_sources');
foreach ($share->sources as $source) {
/** @psalm-suppress PossiblyNullReference Can't happen since share is valid */
$sourceDisplayName = $this->registry->getSourceTypes()[$source->type]->getSourceDisplayName($source->value) ?? $source->value;
$qb
->values([
'id' => $qb->createNamedParameter((int)$share->id, IQueryBuilder::PARAM_INT),
'source_type' => $qb->createNamedParameter($source->type),
'source_value' => $qb->createNamedParameter($source->value),
'source_display_name' => $qb->createNamedParameter($sourceDisplayName),
])
->executeStatement();
}
}
private function insertRecipients(Share $share): void {
$qb = $this->connection->getQueryBuilder()
->insert('sharing_share_recipients');
foreach ($share->recipients as $recipient) {
/** @psalm-suppress PossiblyNullReference Can't happen since share is valid */
$recipientDisplayName = $this->registry->getRecipientTypes()[$recipient->type]->getRecipientDisplayName($recipient->value) ?? $recipient->value;
$qb
->values([
'id' => $qb->createNamedParameter((int)$share->id, IQueryBuilder::PARAM_INT),
'recipient_type' => $qb->createNamedParameter($recipient->type),
'recipient_value' => $qb->createNamedParameter($recipient->value),
'recipient_display_name' => $qb->createNamedParameter($recipientDisplayName),
])
->executeStatement();
}
}
private function insertProperties(Share $share): void {
$features = $this->registry->getFeatures();
$qb = $this->connection->getQueryBuilder()
->insert('sharing_share_properties');
foreach ($share->properties as $featureClass => $properties) {
if ($features[$featureClass] instanceof IShareFeatureModifyProperties) {
// Properties are already validated when the Share object is created.
$properties = $features[$featureClass]->modifyProperties($properties);
if (!$features[$featureClass]->validateProperties($properties)) {
throw new ShareInvalidPropertiesException($featureClass);
}
}
foreach ($properties as $key => $values) {
foreach ($values as $value) {
$qb
->values([
'id' => $qb->createNamedParameter((int)$share->id, IQueryBuilder::PARAM_INT),
'feature' => $qb->createNamedParameter($featureClass),
'feature_key' => $qb->createNamedParameter($key),
'feature_value' => $qb->createNamedParameter($value),
])
->executeStatement();
}
}
}
}
/**
* The share might be updated during insertion (e.g. display name updates),
* so if you use the share afterwards get it from the manager again.
*
* @throws ShareConflictException
* @throws ShareInvalidException
* @throws ShareInvalidPropertiesException
* @throws ShareNotFoundException
* @throws ShareOperationNotAllowedException
*/
public function update(ShareAccessContext $accessContext, Share $share): void {
$originalShare = $this->get($accessContext, $share->id);
$this->validateShareOwnerOperation($accessContext, $originalShare);
if ($share->id !== $originalShare->id) {
throw new ShareInvalidException('The id cannot be updated.');
}
if ($share->lastUpdated !== $originalShare->lastUpdated) {
// TODO: Add test
throw new ShareConflictException();
}
/** @psalm-suppress PossiblyNullReference Can't happen since share is valid */
$ownerDisplayName = $this->userManager->get($share->owner->userId)->getDisplayName();
$this->connection->beginTransaction();
$qb = $this->connection->getQueryBuilder();
$qb
->update('sharing_share')
->set('owner', $qb->createNamedParameter($share->owner->userId))
->set('owner_display_name', $qb->createNamedParameter($ownerDisplayName))
->set('last_updated', $qb->createNamedParameter($this->generateLastUpdated()))
->where($qb->expr()->eq('id', $qb->createNamedParameter($share->id)))
->executeStatement();
$this->deleteSources($share->id);
$this->insertSources($share);
$this->deleteRecipients($share->id);
$this->insertRecipients($share);
$this->deleteProperties($share->id);
$this->insertProperties($share);
try {
$this->connection->commit();
} catch (Exception $exception) {
$this->connection->rollBack();
throw $exception;
}
}
/**
* @throws ShareNotFoundException
* @throws ShareOperationNotAllowedException
*/
public function delete(ShareAccessContext $accessContext, string $shareID): void {
$originalShare = $this->get($accessContext, $shareID);
$this->validateShareOwnerOperation($accessContext, $originalShare);
$this->connection->beginTransaction();
$qb = $this->connection->getQueryBuilder();
$qb
->delete('sharing_share')
->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareID, IQueryBuilder::PARAM_INT)))
->executeStatement();
$this->deleteSources($shareID);
$this->deleteRecipients($shareID);
$this->deleteProperties($shareID);
try {
$this->connection->commit();
} catch (Exception $exception) {
$this->connection->rollBack();
throw $exception;
}
}
private function deleteSources(string $shareID): void {
$qb = $this->connection->getQueryBuilder();
$qb
->delete('sharing_share_sources')
->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareID, IQueryBuilder::PARAM_INT)))
->executeStatement();
}
private function deleteRecipients(string $shareID): void {
$qb = $this->connection->getQueryBuilder();
$qb
->delete('sharing_share_recipients')
->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareID, IQueryBuilder::PARAM_INT)))
->executeStatement();
}
private function deleteProperties(string $shareID): void {
$qb = $this->connection->getQueryBuilder();
$qb
->delete('sharing_share_properties')
->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareID, IQueryBuilder::PARAM_INT)))
->executeStatement();
}
/**
* @throws ShareNotFoundException
*/
public function get(ShareAccessContext $accessContext, string $shareID): Share {
$shares = $this->internalList($accessContext, $shareID, null, null, null);
if (count($shares) !== 1) {
throw new ShareNotFoundException($shareID);
}
return $shares[0];
}
/**
* @param ?class-string<IShareSourceType> $sourceType
* @return list<Share>
*/
public function list(ShareAccessContext $accessContext, ?string $sourceType, ?string $lastShareId, ?int $limit): array {
return $this->internalList($accessContext, null, $sourceType, $lastShareId, $limit);
}
/**
* @param ?class-string<IShareSourceType> $sourceType
* @return list<Share>
*/
private function internalList(ShareAccessContext $accessContext, ?string $shareID, ?string $sourceType, ?string $lastShareId, ?int $limit): array {
// LEFT JOINing all tables works, but causes a lot of rows to be selected and would require deduplication.
/** @var list<IQueryBuilder> $queries */
$queries = [];
if ($accessContext->force) {
$queries[] = $this->connection->getQueryBuilder();
} else {
// Because doctrine has no UNION support, individual queries have to be used
if ($accessContext->currentUser instanceof IUser) {
$qb = $this->connection->getQueryBuilder();
$qb->where($qb->expr()->eq('s.owner', $qb->createNamedParameter($accessContext->currentUser->getUID())));
$queries[] = $qb;
}
/** @var array<class-string<IShareRecipientType>, list<string>> $recipients */
$recipients = [];
foreach ($this->registry->getRecipientTypes() as $recipientType) {
$recipientValues = $recipientType->getRecipientValues($accessContext->currentUser, $accessContext->arguments[$recipientType::class] ?? null);
if ($recipientValues !== []) {
$recipients[$recipientType::class] = $recipientValues;
}
}
// Do not add a query if no recipients matched, otherwise all shares will be returned.
if ($recipients !== []) {
$qb = $this->connection->getQueryBuilder();
$qb->innerJoin('s', 'sharing_share_recipients', 'sr', $qb->expr()->eq('sr.id', 's.id'));
foreach ($recipients as $recipientTypeClass => $recipientValues) {
$qb->orWhere($qb->expr()->andX(
$qb->expr()->eq('recipient_type', $qb->createNamedParameter($recipientTypeClass)),
// TODO: Add chunking
$qb->expr()->in('recipient_value', $qb->createNamedParameter($recipientValues, IQueryBuilder::PARAM_STR_ARRAY)),
));
}
$queries[] = $qb;
}
}
/** @var array<string, SharingShare> $shares */
$shares = [];
foreach ($queries as $qb) {
$qb
->select(
's.id',
's.owner',
's.owner_display_name',
's.last_updated',
)
->from('sharing_share', 's')
->orderBy('s.id', 'ASC');
if ($shareID !== null) {
$qb->andWhere($qb->expr()->eq('s.id', $qb->createNamedParameter((int)$shareID, IQueryBuilder::PARAM_INT)));
}
if ($sourceType !== null) {
$qb->innerJoin('s', 'sharing_share_sources', 'ss', $qb->expr()->andX(
$qb->expr()->eq('s.id', 'ss.id'),
$qb->expr()->eq('ss.source_type', $qb->createNamedParameter($sourceType)),
));
}
if ($lastShareId !== null) {
if (!ctype_digit($lastShareId)) {
throw new ShareInvalidOperationParameterException('The lastShareId is invalid.');
}
$qb->andWhere($qb->expr()->gt('s.id', $qb->createNamedParameter((int)$lastShareId, IQueryBuilder::PARAM_INT)));
}
if ($limit !== null) {
$qb->setMaxResults($limit);
}
$result = $qb->executeQuery();
$rows = $result->fetchAll();
foreach ($rows as $row) {
$id = (string)$row['id'];
$shares[$id] ??= [
'id' => $id,
'owner' => [
'user_id' => (string)$row['owner'],
'display_name' => (string)$row['owner_display_name'],
],
'last_updated' => (int)$row['last_updated'],
'sources' => [],
'recipients' => [],
'properties' => [],
];
}
}
// If multiple queries are used the shares are not automatically sorted already.
if (count($queries) > 1) {
ksort($shares);
}
// The queries are limited already, but could return more results in total, so discard them here.
if ($limit !== null) {
$shares = array_slice($shares, 0, $limit, true);
}
$chunks = array_chunk(array_keys($shares), 1000);
foreach ($chunks as $chunk) {
$qb = $this->connection->getQueryBuilder();
$qb
->select(
'id',
'recipient_type',
'recipient_value',
'recipient_display_name',
)
->from('sharing_share_recipients')
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
$result = $qb->executeQuery();
foreach ($result->fetchAll() as $row) {
$id = (string)$row['id'];
$shares[$id]['recipients'][] = [
'type' => (string)$row['recipient_type'],
'value' => (string)$row['recipient_value'],
'display_name' => (string)$row['recipient_display_name'],
];
}
}
foreach ($chunks as $chunk) {
$qb = $this->connection->getQueryBuilder();
$qb
->select(
'id',
'feature',
'feature_key',
'feature_value',
)
->from('sharing_share_properties')
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
$result = $qb->executeQuery();
foreach ($result->fetchAll() as $row) {
$id = (string)$row['id'];
$shares[$id]['properties'][(string)$row['feature']] ??= [];
$shares[$id]['properties'][(string)$row['feature']][(string)$row['feature_key']] ??= [];
$shares[$id]['properties'][(string)$row['feature']][(string)$row['feature_key']][] = (string)$row['feature_value'];
}
}
foreach ($chunks as $chunk) {
$qb = $this->connection->getQueryBuilder();
$qb
->select(
'id',
'source_type',
'source_value',
'source_display_name',
)
->from('sharing_share_sources')
->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)));
$result = $qb->executeQuery();
foreach ($result->fetchAll() as $row) {
$id = (string)$row['id'];
$shares[$id]['sources'][] = [
'type' => (string)$row['source_type'],
'value' => (string)$row['source_value'],
'display_name' => (string)$row['source_display_name'],
];
}
}
if (!$accessContext->force) {
$filterFeatures = array_filter($this->registry->getFeatures(), static fn (IShareFeature $feature): bool => $feature instanceof IShareFeatureFilter);
if ($filterFeatures !== []) {
// Some filtering needs more logic than the database is able to provide, so it is done in the backend.
// TODO: This can still be quite expensive for many shares, so caching the filter results might be sensible at some point.
$shares = array_filter($shares, function (array $share) use ($filterFeatures, $accessContext): bool {
if ($accessContext->currentUser?->getUID() === $share['owner']['user_id']) {
return true;
}
/** @var array<class-string<IShareSourceType>, bool> $shareSourceTypes */
$shareSourceTypes = [];
foreach ($share['sources'] as $source) {
$shareSourceTypes[$source['type']] = true;
}
/** @var array<class-string<IShareRecipientType>, bool> $shareRecipientTypes */
$shareRecipientTypes = [];
foreach ($share['recipients'] as $recipient) {
$shareRecipientTypes[$recipient['type']] = true;
}
foreach ($filterFeatures as $feature) {
$isCompatible = false;
foreach ($feature->getCompatibles() as $compatible) {
if (isset($shareSourceTypes[$compatible['source_type']], $shareRecipientTypes[$compatible['recipient_type']])) {
$isCompatible = true;
break;
}
}
if (!$isCompatible) {
continue;
}
if ($feature->isFiltered(
$accessContext->currentUser,
$accessContext->arguments[$feature::class] ?? null,
$share['properties'][$feature::class] ?? [],
)) {
return false;
}
}
return true;
});
}
}
/** @psalm-suppress ArgumentTypeCoercion */
return array_map(Share::fromArray(...), array_values($shares));
}
}
@@ -0,0 +1,59 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Migration;
use Closure;
use Doctrine\DBAL\Schema\SchemaException;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;
class Version1000Date20250929161325 extends SimpleMigrationStep {
/**
* @param Closure():ISchemaWrapper $schemaClosure
* @throws SchemaException
*/
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
$shareTable = $schema->createTable('sharing_share');
$shareTable->addColumn('id', Types::BIGINT);
$shareTable->addColumn('owner', Types::TEXT);
$shareTable->addColumn('owner_display_name', Types::TEXT, ['notnull' => false]);
$shareTable->addColumn('last_updated', Types::BIGINT);
$sourcesTable = $schema->createTable('sharing_share_sources');
$sourcesTable->addColumn('id', Types::BIGINT);
$sourcesTable->addColumn('source_type', Types::TEXT);
$sourcesTable->addColumn('source_value', Types::TEXT);
$sourcesTable->addColumn('source_display_name', Types::TEXT, ['notnull' => false]);
$recipientsTable = $schema->createTable('sharing_share_recipients');
$recipientsTable->addColumn('id', Types::BIGINT);
$recipientsTable->addColumn('recipient_type', Types::TEXT);
$recipientsTable->addColumn('recipient_value', Types::TEXT);
$recipientsTable->addColumn('recipient_display_name', Types::TEXT, ['notnull' => false]);
$propertiesTable = $schema->createTable('sharing_share_properties');
$propertiesTable->addColumn('id', Types::BIGINT);
$propertiesTable->addColumn('feature', Types::TEXT);
$propertiesTable->addColumn('feature_key', Types::TEXT);
$propertiesTable->addColumn('feature_value', Types::TEXT);
// TODO: Add primary keys, unique constraints and indices
// TODO: Handle unique constraint exceptions
return $schema;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\ResponseDefinitions;
/**
* @psalm-import-type SharingCompatible from ResponseDefinitions
* @psalm-import-type SharingFeature from ResponseDefinitions
*/
interface IShareFeature {
/**
* Get compatible source type and recipient type combinations.
*
* @return non-empty-list<SharingCompatible>
*/
public function getCompatibles(): array;
/**
* Validate properties of new shares.
*
* TODO: Maybe tighten to non-empty-list
* @param array<string, list<string>> $properties
*/
public function validateProperties(array $properties): bool;
}
@@ -0,0 +1,25 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCP\IUser;
interface IShareFeatureFilter extends IShareFeature {
/**
* Evaluates if a share should be filtered out.
*
* Only validated properties will be passed, so validating them again is not necessary.
*
* @param mixed $arguments Defaults to null if no arguments were passed to the Manager
*
* @param array<string, list<string>> $properties
*/
public function isFiltered(?IUser $currentUser, mixed $arguments, array $properties): bool;
}
@@ -0,0 +1,24 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
interface IShareFeatureModifyProperties extends IShareFeature {
/**
* Modify the properties whenever a share is created or updated.
*
* The properties will be passed to {@see IShareFeature::validateProperties()} before and after the invocation of this method.
* This means you don't need to validate the properties again in the implementation of this method.
*
* TODO: Maybe tighten to non-empty-list
* @param array<string, list<string>> $properties
* @return array<string, list<string>>
*/
public function modifyProperties(array $properties): array;
}
@@ -0,0 +1,42 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCP\IUser;
interface IShareRecipientType {
/**
* Returns a user friendly display name for this recipient type.
*
* @return non-empty-string
*/
public function getDisplayName(): string;
/**
* Validate that a recipient exists.
*
* @param non-empty-string $recipient
*/
public function validateRecipient(string $recipient): bool;
/**
* Get possible recipient values for the current user.
*
* @return list<string>
*/
// TODO: Add inverse of this method to get users for a recipient
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array;
/**
* @param non-empty-string $recipient
* @return ?non-empty-string
*/
public function getRecipientDisplayName(string $recipient): ?string;
}
@@ -0,0 +1,22 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
interface IShareRecipientTypeSearch extends IShareRecipientType {
/**
* Search for recipients.
*
* @param non-empty-string $query
* @param positive-int $limit
* @param non-negative-int $offset
* @return list<ShareRecipientSearchResult>
*/
public function searchRecipients(string $query, int $limit, int $offset): array;
}
@@ -0,0 +1,34 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCP\IUser;
interface IShareSourceType {
/**
* Returns a user friendly display name for this source type.
*
* @return non-empty-string
*/
public function getDisplayName(): string;
/*
* Validate that a source exists.
*
* @param non-empty-string $source
*/
public function validateSource(IUser $owner, string $source): bool;
/**
* @param non-empty-string $source
* @return ?non-empty-string
*/
public function getSourceDisplayName(string $source): ?string;
}
+178
View File
@@ -0,0 +1,178 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Exception\ShareInvalidException;
use OCA\Sharing\Exception\ShareInvalidPropertiesException;
use OCA\Sharing\Registry;
use OCA\Sharing\ResponseDefinitions;
use OCP\Server;
/**
* @psalm-import-type SharingShare from ResponseDefinitions
*/
readonly class Share {
public function __construct(
/** @var non-empty-string $id */
public string $id,
public ShareOwner $owner,
/** @var non-empty-list<ShareSource> $sources */
public array $sources,
/** @var non-empty-list<ShareRecipient> $recipients */
public array $recipients,
/** @var array<class-string<IShareFeature>, array<string, non-empty-list<string>>> */
public array $properties,
/** @var non-negative-int $lastUpdated Unix time in milliseconds */
public int $lastUpdated,
) {
// TODO: Some of these might need to be skipped when loading existing shares from the DB
/** @psalm-suppress DocblockTypeContradiction */
if ($id === '') {
throw new ShareInvalidException('The id is empty.');
}
/** {@see \OC\Snowflake\Decoder::decode} */
if (!ctype_digit($id)) {
throw new ShareInvalidException('The ID is not a valid Snowflake ID.');
}
$registry = Server::get(Registry::class);
/** @psalm-suppress DocblockTypeContradiction */
if ($sources === []) {
throw new ShareInvalidException('The sources are missing.');
}
if (!array_is_list($sources)) {
throw new ShareInvalidException('The sources are not a list.');
}
/** @var array<class-string<IShareSourceType>, bool> $shareSourceTypes */
$shareSourceTypes = [];
$sourceTypes = $registry->getSourceTypes();
foreach ($sources as $source) {
if (!isset($sourceTypes[$source->type])) {
throw new ShareInvalidException('The source type is not registered: ' . $source->type);
}
if (!$sourceTypes[$source->type]->validateSource($this->owner->getUser(), $source->value)) {
throw new ShareInvalidException('The source ' . $source->value . ' for ' . $source->type . ' is not valid.');
}
$shareSourceTypes[$source->type] = true;
}
/** @psalm-suppress DocblockTypeContradiction */
if ($recipients === []) {
throw new ShareInvalidException('The recipients are missing.');
}
if (!array_is_list($recipients)) {
throw new ShareInvalidException('The recipients are not a list.');
}
/** @var array<class-string<IShareRecipientType>, bool> $shareRecipientTypes */
$shareRecipientTypes = [];
$recipientTypes = $registry->getRecipientTypes();
foreach ($recipients as $recipient) {
if (!isset($recipientTypes[$recipient->type])) {
throw new ShareInvalidException('The recipient type is not registered: ' . $recipient->type);
}
if (!$recipientTypes[$recipient->type]->validateRecipient($recipient->value)) {
throw new ShareInvalidException('The recipient ' . $recipient->value . ' for ' . $recipient->type . ' is not valid.');
}
$shareRecipientTypes[$recipient->type] = true;
}
$features = $registry->getFeatures();
foreach ($properties as $featureClass => $featureProperties) {
/** @psalm-suppress DocblockTypeContradiction */
if (!is_string($featureClass)) {
throw new ShareInvalidException('The feature is not a string: ' . var_export($featureClass, true));
}
if (!isset($features[$featureClass])) {
throw new ShareInvalidException('The feature is not registered: ' . var_export($featureClass, true));
}
foreach ($features[$featureClass]->getCompatibles() as $compatible) {
if (!isset($shareSourceTypes[$compatible['source_type']], $shareRecipientTypes[$compatible['recipient_type']])) {
throw new ShareInvalidException('The feature is not compatible with the source types and/or recipient types of the share: ' . var_export($featureClass, true));
}
}
/** @psalm-suppress DocblockTypeContradiction */
if (!is_array($featureProperties)) {
throw new ShareInvalidException('The feature properties are not an array: ' . var_export($featureProperties, true));
}
foreach ($featureProperties as $key => $values) {
/** @psalm-suppress DocblockTypeContradiction */
if (!is_string($key)) {
throw new ShareInvalidException('The feature property key is not a string: ' . var_export($key, true));
}
if (!array_is_list($values)) {
throw new ShareInvalidException('The feature property values are not an array: ' . var_export($values, true));
}
foreach ($values as $value) {
/** @psalm-suppress DocblockTypeContradiction */
if (!is_string($value)) {
throw new ShareInvalidException('The feature property value is not a string: ' . var_export($value, true));
}
}
}
if (!$features[$featureClass]->validateProperties($featureProperties)) {
throw new ShareInvalidPropertiesException($featureClass);
}
}
}
/**
* @param SharingShare $share
*/
public static function fromArray(array $share): self {
return new self(
$share['id'],
new ShareOwner($share['owner']['user_id'], $share['owner']['display_name'] ?? null),
array_map(ShareSource::fromArray(...), $share['sources']),
array_map(ShareRecipient::fromArray(...), $share['recipients']),
$share['properties'],
$share['last_updated'],
);
}
/**
* @return SharingShare
*/
public function toArray(): array {
$owner = [
'user_id' => $this->owner->userId,
];
if ($this->owner->displayName !== null) {
$owner['display_name'] = $this->owner->displayName;
}
return [
'id' => $this->id,
'owner' => $owner,
'last_updated' => $this->lastUpdated,
'sources' => array_map(static fn (ShareSource $source): array => $source->toArray(), $this->sources),
'recipients' => array_map(static fn (ShareRecipient $recipient): array => $recipient->toArray(), $this->recipients),
'properties' => $this->properties,
];
}
}
@@ -0,0 +1,32 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Exception\ShareInvalidException;
use OCP\IUser;
readonly class ShareAccessContext {
public function __construct(
public ?IUser $currentUser = null,
/** @var array<class-string<IShareRecipientType|IShareFeatureFilter>, mixed> $arguments */
public array $arguments = [],
/**
* Ignore all checks and allow any operation. Only use it for admins and occ.
*/
public bool $force = false,
) {
foreach (array_keys($arguments) as $key) {
/** @psalm-suppress DocblockTypeContradiction */
if (!is_string($key)) {
throw new ShareInvalidException('The argument key is not a string: ' . var_export($key, true));
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Exception\ShareInvalidException;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
readonly class ShareOwner {
private IUser $user;
public function __construct(
/** @var non-empty-string $owner */
public string $userId,
/** @var ?non-empty-string $ownerDisplayName */
public ?string $displayName,
) {
/** @psalm-suppress DocblockTypeContradiction */
if ($this->userId === '') {
throw new ShareInvalidException('The userId is empty.');
}
// TODO: Will not work for remote owner
$ownerUser = Server::get(IUserManager::class)->get($this->userId);
if ($ownerUser === null) {
throw new ShareInvalidException('The userId does not exist: ' . $this->userId);
}
$this->user = $ownerUser;
/** @psalm-suppress DocblockTypeContradiction */
if ($displayName === '') {
throw new ShareInvalidException('The displayName is empty.');
}
}
public function getUser(): IUser {
return $this->user;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Registry;
use OCA\Sharing\ResponseDefinitions;
use OCP\Server;
use RuntimeException;
/**
* @psalm-import-type SharingShareRecipient from ResponseDefinitions
*/
readonly class ShareRecipient {
public function __construct(
/** @var class-string<IShareRecipientType> $type */
public string $type,
/** @var non-empty-string $value */
public string $value,
/** @var ?non-empty-string $displayName */
public ?string $displayName,
) {
if (!isset(Server::get(Registry::class)->getRecipientTypes()[$type])) {
throw new RuntimeException('The recipient type is not registered: ' . $type);
}
/** @psalm-suppress DocblockTypeContradiction */
if ($value === '') {
throw new RuntimeException('The value is empty.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($displayName === '') {
throw new RuntimeException('The displayName is empty.');
}
}
/**
* @param SharingShareRecipient $recipient
*/
public static function fromArray(array $recipient): self {
return new self(
$recipient['type'],
$recipient['value'],
$recipient['display_name'] ?? null,
);
}
/**
* @return SharingShareRecipient
*/
public function toArray(): array {
$out = [
'type' => $this->type,
'value' => $this->value,
];
if ($this->displayName !== null) {
$out['display_name'] = $this->displayName;
}
return $out;
}
}
@@ -0,0 +1,105 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Registry;
use OCA\Sharing\ResponseDefinitions;
use OCP\Server;
use RuntimeException;
/**
* @psalm-import-type SharingRecipientSearchResult from ResponseDefinitions
*/
class ShareRecipientSearchResult {
/** @var ?class-string<IShareRecipientType> $type */
private ?string $type = null;
public function __construct(
/** @var non-empty-string $value */
readonly public string $value,
/** @var non-empty-string $displayName */
readonly public string $displayName,
/** @var ?non-empty-string $displayNameUnique */
readonly public ?string $displayNameUnique,
/** @var ?non-empty-string $iconUrlLight */
readonly public ?string $iconUrlLight,
/** @var ?non-empty-string $iconUrlDark */
readonly public ?string $iconUrlDark,
) {
/** @psalm-suppress DocblockTypeContradiction */
if ($value === '') {
throw new RuntimeException('The value is empty.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($displayName === '') {
throw new RuntimeException('The displayName is empty.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($displayNameUnique === '') {
throw new RuntimeException('The displayNameUnique is empty.');
}
if ($iconUrlLight !== null && !preg_match('/^https?:\/\//', $iconUrlLight)) {
throw new RuntimeException('The iconUrlLight is not a valid absolute URL: ' . $iconUrlLight);
}
if ($iconUrlDark !== null && !preg_match('/^https?:\/\//', $iconUrlDark)) {
throw new RuntimeException('The iconUrlDark is not a valid absolute URL: ' . $iconUrlDark);
}
}
/**
* @param class-string<IShareRecipientType> $type
*/
public function setType(string $type): self {
if ($this->type !== null) {
throw new RuntimeException('The type is already set.');
}
if (!isset(Server::get(Registry::class)->getRecipientTypes()[$type])) {
throw new RuntimeException('The recipient type is not registered: ' . $type);
}
$this->type = $type;
return $this;
}
/**
* @return SharingRecipientSearchResult
*/
public function toArray(): array {
if ($this->type === null) {
throw new RuntimeException('The type has not been set.');
}
$out = [
'type' => $this->type,
'value' => $this->value,
'display_name' => $this->displayName,
];
if ($this->displayNameUnique !== null) {
$out['display_name_unique'] = $this->displayNameUnique;
}
if ($this->iconUrlLight !== null) {
$out['icon_url_light'] = $this->iconUrlLight;
}
if ($this->iconUrlDark !== null) {
$out['icon_url_dark'] = $this->iconUrlDark;
}
return $out;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Model;
use OCA\Sharing\Registry;
use OCA\Sharing\ResponseDefinitions;
use OCP\Server;
use RuntimeException;
/**
* @psalm-import-type SharingShareSource from ResponseDefinitions
*/
readonly class ShareSource {
public function __construct(
/** @var class-string<IShareSourceType> $type */
public string $type,
/** @var non-empty-string $value */
public string $value,
/** @var ?non-empty-string $displayName */
public ?string $displayName,
) {
if (!isset(Server::get(Registry::class)->getSourceTypes()[$type])) {
throw new RuntimeException('The source type is not registered: ' . $type);
}
/** @psalm-suppress DocblockTypeContradiction */
if ($value === '') {
throw new RuntimeException('The value is empty.');
}
/** @psalm-suppress DocblockTypeContradiction */
if ($displayName === '') {
throw new RuntimeException('The displayName is empty.');
}
}
/**
* @param SharingShareSource $source
*/
public static function fromArray(array $source): self {
return new self(
$source['type'],
$source['value'],
$source['display_name'] ?? null,
);
}
/**
* @return SharingShareSource
*/
public function toArray(): array {
$out = [
'type' => $this->type,
'value' => $this->value,
];
if ($this->displayName !== null) {
$out['display_name'] = $this->displayName;
}
return $out;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareSourceType;
use RuntimeException;
class Registry {
/** @var array<class-string<IShareSourceType>, IShareSourceType> */
private array $sourceTypes = [];
/** @var array<class-string<IShareRecipientType>, IShareRecipientType> */
private array $recipientTypes = [];
/** @var array<class-string<IShareFeature>, IShareFeature> */
private array $features = [];
public function clear(): void {
$this->sourceTypes = [];
$this->recipientTypes = [];
$this->features = [];
}
public function registerSourceType(IShareSourceType $sourceType): void {
$class = $sourceType::class;
if (isset($this->sourceTypes[$class])) {
throw new RuntimeException('Share source type ' . $class . ' is already registered');
}
$this->sourceTypes[$class] = $sourceType;
}
public function registerRecipientType(IShareRecipientType $recipientType): void {
$class = $recipientType::class;
if (isset($this->recipientTypes[$class])) {
throw new RuntimeException('Share recipient type ' . $class . ' is already registered');
}
$this->recipientTypes[$class] = $recipientType;
}
public function registerFeature(IShareFeature $feature): void {
$class = $feature::class;
if (isset($this->features[$class])) {
throw new RuntimeException('Share feature ' . $class . ' is already registered');
}
$this->features[$class] = $feature;
}
/**
* @return array<class-string<IShareSourceType>, IShareSourceType>
*/
public function getSourceTypes(): array {
return $this->sourceTypes;
}
/**
* @return array<class-string<IShareRecipientType>, IShareRecipientType>
*/
public function getRecipientTypes(): array {
return $this->recipientTypes;
}
/**
* @return array<class-string<IShareFeature>, IShareFeature>
*/
public function getFeatures(): array {
return $this->features;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareSourceType;
/**
* @psalm-type SharingShareSource = array{
* type: class-string<IShareSourceType>,
* value: non-empty-string,
* // Will be set by the server automatically
* display_name?: non-empty-string,
* }
*
* @psalm-type SharingShareRecipient = array{
* type: class-string<IShareRecipientType>,
* value: non-empty-string,
* // Will be set by the server automatically
* display_name?: non-empty-string,
* }
*
* @psalm-type SharingShareOwner = array{
* user_id: non-empty-string,
* // Will be set by the server automatically
* display_name?: non-empty-string,
* }
*
* @psalm-type SharingPartialShare = array{
* owner: SharingShareOwner,
* sources: non-empty-list<SharingShareSource>,
* recipients: non-empty-list<SharingShareRecipient>,
* properties: array<class-string<IShareFeature>, array<string, non-empty-list<string>>>,
* }
*
* @psalm-type SharingShare = SharingPartialShare&array{
* id: non-empty-string,
* // Unix time in milliseconds
* last_updated: non-negative-int,
* }
*
* @psalm-type SharingCompatible = array{
* source_type: class-string<IShareSourceType>,
* recipient_type: class-string<IShareRecipientType>,
* }
*
* @psalm-type SharingFeature = array{
* compatibles: non-empty-list<SharingCompatible>,
* }
*
* @psalm-type SharingRecipientSearchResult = array{
* type: class-string<IShareRecipientType>,
* value: non-empty-string,
* display_name: non-empty-string,
* // If multiple search results with the same display_name are returned, also show this display_name_unique.
* display_name_unique?: non-empty-string,
* // Absolute URL
* icon_url_light?: non-empty-string,
* // Absolute URL
* icon_url_dark?: non-empty-string,
* }
*/
class ResponseDefinitions {
}
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Exception\ShareNotFoundException;
use OCA\Sharing\Manager;
use OCA\Sharing\Model\Share;
use OCA\Sharing\Model\ShareAccessContext;
use OCA\Sharing\Registry;
use OCA\Sharing\ResponseDefinitions;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use PHPUnit\Framework\Attributes\Group;
use Test\TestCase;
/**
* @psalm-import-type SharingShare from ResponseDefinitions
* @psalm-import-type SharingPartialShare from ResponseDefinitions
*/
#[Group(name: 'DB')]
abstract class AbstractApiTests extends TestCase {
private Manager $manager;
protected Registry $registry;
protected IUser $owner1;
protected IUser $owner2;
public function setUp(): void {
parent::setUp();
$this->manager = Server::get(Manager::class);
$this->registry = Server::get(Registry::class);
$owner1 = Server::get(IUserManager::class)->createUser('owner1', 'password');
$this->assertNotFalse($owner1);
$this->owner1 = $owner1;
$this->owner1->setDisplayName('Owner 1');
$owner2 = Server::get(IUserManager::class)->createUser('owner2', 'password');
$this->assertNotFalse($owner2);
$this->owner2 = $owner2;
$this->owner2->setDisplayName('Owner 2');
}
protected function tearDown(): void {
foreach ($this->manager->list(new ShareAccessContext(force: true), null, null, null) as $share) {
$this->manager->delete(new ShareAccessContext(force: true), $share->id);
}
$this->registry->clear();
$this->owner1->delete();
$this->owner2->delete();
parent::tearDown();
}
private function register(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source1' => 'Source 1']));
$this->registry->registerSourceType(new TestShareSourceType2(['source2' => 'Source 2']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient1' => 'Recipient 1'], [], []));
$this->registry->registerRecipientType(new TestShareRecipientType2(['recipient2' => 'Recipient 2'], [], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], ['key1']));
$this->registry->registerFeature(new TestShareFeature2([['source_type' => TestShareSourceType2::class, 'recipient_type' => TestShareRecipientType2::class]], ['key2']));
}
/**
* @return SharingPartialShare
*/
private function getShareData(): array {
return [
'owner' => [
'user_id' => 'owner1',
],
'sources' => [
[
'type' => TestShareSourceType::class,
'value' => 'source1',
],
[
'type' => TestShareSourceType2::class,
'value' => 'source2',
],
],
'recipients' => [
[
'type' => TestShareRecipientType::class,
'value' => 'recipient1',
],
[
'type' => TestShareRecipientType2::class,
'value' => 'recipient2',
],
],
'properties' => [
TestShareFeature::class => [
'key1' => ['value1'],
],
TestShareFeature2::class => [
'key2' => ['value2'],
],
],
];
}
/**
* @return SharingPartialShare
*/
private function getShareDataWithDisplayNames(): array {
return [
'owner' => [
'user_id' => 'owner1',
'display_name' => 'Owner 1',
],
'sources' => [
[
'type' => TestShareSourceType::class,
'value' => 'source1',
'display_name' => 'Source 1',
],
[
'type' => TestShareSourceType2::class,
'value' => 'source2',
'display_name' => 'Source 2',
],
],
'recipients' => [
[
'type' => TestShareRecipientType::class,
'value' => 'recipient1',
'display_name' => 'Recipient 1',
],
[
'type' => TestShareRecipientType2::class,
'value' => 'recipient2',
'display_name' => 'Recipient 2',
],
],
'properties' => [
TestShareFeature::class => [
'key1' => ['value1'],
],
TestShareFeature2::class => [
'key2' => ['value2'],
],
],
];
}
/**
* @param SharingPartialShare $data
* @return SharingShare
*/
abstract protected function createShare(array $data): array;
public function testCreateShare(): void {
$this->register();
$data = $this->getShareData();
$response = $this->createShare($data);
$this->assertArrayHasKey('id', $response);
unset($response['id']);
$this->assertArrayHasKey('last_updated', $response);
unset($response['last_updated']);
$this->assertEquals($this->getShareDataWithDisplayNames(), $response);
}
/**
* @param non-empty-string $shareID
* @return SharingShare
*/
abstract protected function getShare(string $shareID): array;
public function testGetShare(): void {
$this->register();
$data = $this->manager->completePartialShareData($this->getShareData());
$this->manager->insert(Share::fromArray($data));
$response = $this->getShare($data['id']);
$this->assertArrayHasKey('id', $response);
unset($response['id']);
$this->assertArrayHasKey('last_updated', $response);
unset($response['last_updated']);
$this->assertEquals($this->getShareDataWithDisplayNames(), $response);
}
/**
* @param non-empty-string $shareID
*/
abstract protected function deleteShare(string $shareID): void;
public function testDeleteShare(): void {
$this->register();
$data = $this->manager->completePartialShareData($this->getShareData());
$this->manager->insert(Share::fromArray($data));
$this->deleteShare($data['id']);
$this->expectException(ShareNotFoundException::class);
$this->manager->get(new ShareAccessContext(force: true), $data['id']);
}
/**
* @param SharingShare $data
* @return SharingShare
*/
abstract protected function updateShare(array $data): array;
public function testUpdateShare(): void {
$this->register();
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source1']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient1']],
'properties' => [TestShareFeature::class => ['key1' => ['key1']]],
];
$data = $this->manager->completePartialShareData($data);
$this->manager->insert(Share::fromArray($data));
$response = $this->manager->get(new ShareAccessContext(force: true), $data['id'])->toArray();
$this->assertArrayHasKey('last_updated', $response);
$lastUpdated = $response['last_updated'];
$data['owner'] = ['user_id' => $this->owner2->getUID()];
$data['sources'] = [['type' => TestShareSourceType2::class, 'value' => 'source2']];
$data['recipients'] = [['type' => TestShareRecipientType2::class, 'value' => 'recipient2']];
$data['properties'] = [TestShareFeature2::class => ['key2' => ['value2']]];
$response = $this->updateShare($data);
$this->assertArrayHasKey('id', $response);
unset($response['id']);
$this->assertArrayHasKey('last_updated', $response);
$this->assertGreaterThan($lastUpdated, $response['last_updated']);
unset($response['last_updated']);
$this->assertEquals([
'owner' => [
'user_id' => 'owner2',
'display_name' => 'Owner 2',
],
'sources' => [
[
'type' => TestShareSourceType2::class,
'value' => 'source2',
'display_name' => 'Source 2',
],
],
'recipients' => [
[
'type' => TestShareRecipientType2::class,
'value' => 'recipient2',
'display_name' => 'Recipient 2',
],
],
'properties' => [
TestShareFeature2::class => [
'key2' => ['value2'],
],
],
], $response);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
use OCA\Sharing\AppInfo\Application;
use OCA\Sharing\Capabilities;
use OCA\Sharing\Registry;
use OCA\Sharing\Tests\TestShareFeature;
use OCA\Sharing\Tests\TestShareFeature2;
use OCA\Sharing\Tests\TestShareRecipientType;
use OCA\Sharing\Tests\TestShareRecipientType2;
use OCA\Sharing\Tests\TestShareSourceType;
use OCA\Sharing\Tests\TestShareSourceType2;
use OCP\Server;
use Test\TestCase;
class CapabilitiesTest extends TestCase {
private Registry $registry;
private Capabilities $capabilities;
public function setUp(): void {
parent::setUp();
$this->registry = Server::get(Registry::class);
$this->capabilities = Server::get(Capabilities::class);
}
protected function tearDown(): void {
$this->registry->clear();
parent::tearDown();
}
public function testGetCapabilities(): void {
$this->registry->registerSourceType(new TestShareSourceType([]));
$this->registry->registerSourceType(new TestShareSourceType2([]));
$this->registry->registerRecipientType(new TestShareRecipientType([], [], []));
$this->registry->registerRecipientType(new TestShareRecipientType2([], [], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], []));
$this->registry->registerFeature(new TestShareFeature2([['source_type' => TestShareSourceType2::class, 'recipient_type' => TestShareRecipientType2::class]], []));
$this->assertEquals(
[
Application::APP_ID => [
'api_versions' => ['v1'],
'source_types' => [
TestShareSourceType::class => 'TestShareSourceType',
TestShareSourceType2::class => 'TestShareSourceType2',
],
'recipient_types' => [
TestShareRecipientType::class => 'TestShareRecipientType',
TestShareRecipientType2::class => 'TestShareRecipientType2',
],
'features' => [
TestShareFeature::class => [
'compatibles' => [
[
'source_type' => TestShareSourceType::class,
'recipient_type' => TestShareRecipientType::class,
],
],
],
TestShareFeature2::class => [
'compatibles' => [
[
'source_type' => TestShareSourceType2::class,
'recipient_type' => TestShareRecipientType2::class,
],
],
],
],
],
],
$this->capabilities->getCapabilities(),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
use OCA\Sharing\Command\Create;
use OCA\Sharing\Command\Delete;
use OCA\Sharing\Command\Get;
use OCA\Sharing\Command\Update;
use OCA\Sharing\ResponseDefinitions;
use OCA\Sharing\Tests\AbstractApiTests;
use OCP\Server;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Input\Input;
use Symfony\Component\Console\Output\Output;
/**
* @psalm-import-type SharingShare from ResponseDefinitions
*/
#[Group(name: 'DB')]
class CommandTest extends AbstractApiTests {
private Input&MockObject $input;
private Output&MockObject $output;
private string $stdout = '';
public function setUp(): void {
parent::setUp();
$this->input = $this->createMock(Input::class);
$this->output = $this->createMock(Output::class);
$this->output
->method('writeln')
->willReturnCallback(function (string $message): void {
$this->stdout .= $message . "\n";
});
}
protected function createShare(array $data): array {
$this->input
->expects($this->once())
->method('getArgument')
->with('data')
->willReturn(json_encode($data, JSON_THROW_ON_ERROR));
$exitCode = Server::get(Create::class)->execute($this->input, $this->output);
$this->assertEquals(0, $exitCode);
/** @var SharingShare $out */
$out = json_decode($this->stdout, true, 512, JSON_THROW_ON_ERROR);
return $out;
}
protected function getShare(string $shareID): array {
$this->input
->expects($this->once())
->method('getArgument')
->with('id')
->willReturn($shareID);
$exitCode = Server::get(Get::class)->execute($this->input, $this->output);
$this->assertEquals(0, $exitCode);
/** @var SharingShare $out */
$out = json_decode($this->stdout, true, 512, JSON_THROW_ON_ERROR);
return $out;
}
protected function deleteShare(string $shareID): void {
$this->input
->expects($this->once())
->method('getArgument')
->with('id')
->willReturn($shareID);
$exitCode = Server::get(Delete::class)->execute($this->input, $this->output);
$this->assertEquals(0, $exitCode);
}
protected function updateShare(array $data): array {
$this->input
->expects($this->once())
->method('getArgument')
->with('data')
->willReturn(json_encode($data, JSON_THROW_ON_ERROR));
$exitCode = Server::get(Update::class)->execute($this->input, $this->output);
$this->assertEquals(0, $exitCode);
/** @var SharingShare $out */
$out = json_decode($this->stdout, true, 512, JSON_THROW_ON_ERROR);
return $out;
}
}
@@ -0,0 +1,534 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
use OCA\Sharing\Controller\ApiV1Controller;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCA\Sharing\ResponseDefinitions;
use OCA\Sharing\Tests\AbstractApiTests;
use OCA\Sharing\Tests\TestShareFeature;
use OCA\Sharing\Tests\TestShareFeature2;
use OCA\Sharing\Tests\TestShareFeatureFilter;
use OCA\Sharing\Tests\TestShareFeatureModifyProperties;
use OCA\Sharing\Tests\TestShareRecipientType;
use OCA\Sharing\Tests\TestShareRecipientType2;
use OCA\Sharing\Tests\TestShareRecipientTypeArguments;
use OCA\Sharing\Tests\TestShareSourceType;
use OCA\Sharing\Tests\TestShareSourceType2;
use OCP\AppFramework\Http;
use OCP\Server;
use PHPUnit\Framework\Attributes\Group;
/**
* The abstract tests are executed as the owner, allowing all operations.
*
* @psalm-import-type SharingShare from ResponseDefinitions
*/
#[Group(name: 'DB')]
class ApiV1ControllerTest extends AbstractApiTests {
private ApiV1Controller $controller;
public function setUp(): void {
parent::setUp();
self::loginAsUser($this->owner1->getUID());
$this->controller = Server::get(ApiV1Controller::class);
}
protected function createShare(array $data): array {
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
return $responseData;
}
protected function getShare(string $shareID): array {
$response = $this->controller->getShare($shareID);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
return $responseData;
}
protected function deleteShare(string $shareID): void {
$response = $this->controller->deleteShare($shareID);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_NO_CONTENT, $response->getStatus(), var_export($responseData, true));
}
protected function updateShare(array $data): array {
$response = $this->controller->updateShare($data['id'], $data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
return $responseData;
}
public function testSearchRecipients(): void {
$displayNames = [
'recipient1a' => 'Recipient 1A',
'recipient1b' => 'Recipient 1B',
'recipient1c' => 'Recipient 1C',
'recipient2a' => 'Recipient 2A',
'recipient2b' => 'Recipient 2B',
'recipient2c' => 'Recipient 2C',
];
$recipient1a = new ShareRecipientSearchResult('recipient1a', $displayNames['recipient1a'], null, null, null);
$recipient1b = new ShareRecipientSearchResult('recipient1b', $displayNames['recipient1b'], null, null, null);
$recipient1c = new ShareRecipientSearchResult('recipient1c', $displayNames['recipient1c'], null, null, null);
$recipient2a = new ShareRecipientSearchResult('recipient2a', $displayNames['recipient2a'], null, null, null);
$recipient2b = new ShareRecipientSearchResult('recipient2b', $displayNames['recipient2b'], null, null, null);
$recipient2c = new ShareRecipientSearchResult('recipient2c', $displayNames['recipient2c'], null, null, null);
$this->registry->registerRecipientType(new TestShareRecipientType([], [], [$recipient1a, $recipient1b, $recipient1c]));
$this->registry->registerRecipientType(new TestShareRecipientType2([], [], [$recipient2a, $recipient2b, $recipient2c]));
$response = $this->controller->searchRecipients(null, 'recipient');
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertEquals([
['type' => TestShareRecipientType::class, 'value' => 'recipient1a', 'display_name' => $displayNames['recipient1a']],
['type' => TestShareRecipientType::class, 'value' => 'recipient1b', 'display_name' => $displayNames['recipient1b']],
['type' => TestShareRecipientType::class, 'value' => 'recipient1c', 'display_name' => $displayNames['recipient1c']],
['type' => TestShareRecipientType2::class, 'value' => 'recipient2a', 'display_name' => $displayNames['recipient2a']],
['type' => TestShareRecipientType2::class, 'value' => 'recipient2b', 'display_name' => $displayNames['recipient2b']],
['type' => TestShareRecipientType2::class, 'value' => 'recipient2c', 'display_name' => $displayNames['recipient2c']],
], $responseData);
$response = $this->controller->searchRecipients(TestShareRecipientType::class, 'recipient');
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertEquals([
['type' => TestShareRecipientType::class, 'value' => 'recipient1a', 'display_name' => $displayNames['recipient1a']],
['type' => TestShareRecipientType::class, 'value' => 'recipient1b', 'display_name' => $displayNames['recipient1b']],
['type' => TestShareRecipientType::class, 'value' => 'recipient1c', 'display_name' => $displayNames['recipient1c']],
], $responseData);
$response = $this->controller->searchRecipients(TestShareRecipientType::class, 'recipient', 1);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertEquals([
['type' => TestShareRecipientType::class, 'value' => 'recipient1a', 'display_name' => $displayNames['recipient1a']],
], $responseData);
$response = $this->controller->searchRecipients(TestShareRecipientType::class, 'recipient', offset: 1);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertEquals([
['type' => TestShareRecipientType::class, 'value' => 'recipient1b', 'display_name' => $displayNames['recipient1b']],
['type' => TestShareRecipientType::class, 'value' => 'recipient1c', 'display_name' => $displayNames['recipient1c']],
], $responseData);
/** @psalm-suppress ArgumentTypeCoercion */
$response = $this->controller->searchRecipients('abc', 'recipient');
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus(), var_export($responseData, true));
$this->assertEquals('Invalid operation parameter: The recipient type is not registered.', $responseData);
}
public function testCreateShareModifyProperties(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient1' => 'Recipient 1', 'recipient2' => 'Recipient 2'], ['recipient1'], []));
$this->registry->registerFeature(new TestShareFeatureModifyProperties([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]]));
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient1', 'display_name' => 'Recipient 1'], ['type' => TestShareRecipientType::class, 'value' => 'recipient2', 'display_name' => 'Recipient 2']],
'properties' => [TestShareFeatureModifyProperties::class => ['before' => ['valid']]],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$this->assertEquals([TestShareFeatureModifyProperties::class => ['after' => ['valid']]], $responseData['properties']);
}
public function testGetShareAsRecipient(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient1' => 'Recipient 1', 'recipient2' => 'Recipient 2'], ['recipient1'], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], ['key']));
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient1', 'display_name' => 'Recipient 1'], ['type' => TestShareRecipientType::class, 'value' => 'recipient2', 'display_name' => 'Recipient 2']],
'properties' => [TestShareFeature::class => ['key' => ['value']]],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$id = $responseData['id'];
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
}
public function testGetShareAsRecipientWithRecipientArguments(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientTypeArguments());
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientTypeArguments::class, 'value' => 'secret', 'display_name' => 'secret']],
'properties' => [],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$id = $responseData['id'];
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus(), var_export($responseData, true));
$this->assertEquals('Share ' . $id . ' not found.', $responseData);
$response = $this->controller->getShare($id, [TestShareRecipientTypeArguments::class => 'secret']);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
}
public function testGetShareAsNonRecipient(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient' => 'Recipient'], [], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], ['key']));
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient', 'display_name' => 'Recipient']],
'properties' => [TestShareFeature::class => ['key' => ['value']]],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$id = $responseData['id'];
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus(), var_export($responseData, true));
$this->assertEquals('Share ' . $id . ' not found.', $responseData);
}
public function testGetShareFilteredProperties(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient' => 'Recipient'], ['recipient'], []));
$this->registry->registerFeature(new TestShareFeatureFilter([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]]));
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient', 'display_name' => 'Recipient']],
'properties' => [TestShareFeatureFilter::class => ['filtered' => ['false']]],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$id = $responseData['id'];
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
$lastUpdated = $responseData['last_updated'];
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::loginAsUser($this->owner1->getUID());
$data['id'] = $id;
$data['last_updated'] = $lastUpdated;
$data['properties'] = [TestShareFeatureFilter::class => ['filtered' => ['true']]];
$response = $this->controller->updateShare($id, $data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
unset($data['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus(), var_export($responseData, true));
$this->assertEquals('Share ' . $id . ' not found.', $responseData);
}
public function testGetShareFilteredArguments(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source' => 'Source']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient' => 'Recipient'], ['recipient'], []));
$this->registry->registerFeature(new TestShareFeatureFilter([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]]));
$data = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [['type' => TestShareSourceType::class, 'value' => 'source', 'display_name' => 'Source']],
'recipients' => [['type' => TestShareRecipientType::class, 'value' => 'recipient', 'display_name' => 'Recipient']],
'properties' => [],
];
$response = $this->controller->createShare($data);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
$id = $responseData['id'];
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
self::logout();
$response = $this->controller->getShare($id, [TestShareFeatureFilter::class => 'filtered']);
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus(), var_export($responseData, true));
$this->assertEquals('Share ' . $id . ' not found.', $responseData);
$response = $this->controller->getShare($id);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data, $responseData);
}
public function testGetShares(): void {
$this->registry->registerSourceType(new TestShareSourceType(['source1' => 'Source 1']));
$this->registry->registerSourceType(new TestShareSourceType2(['source2' => 'Source 2']));
$this->registry->registerRecipientType(new TestShareRecipientType(['recipient1' => 'Recipient 1'], [], []));
$this->registry->registerRecipientType(new TestShareRecipientType2(['recipient2' => 'Recipient 2'], [], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], ['key1']));
$this->registry->registerFeature(new TestShareFeature2([['source_type' => TestShareSourceType2::class, 'recipient_type' => TestShareRecipientType2::class]], ['key2']));
$data1 = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [
[
'type' => TestShareSourceType::class,
'value' => 'source1',
'display_name' => 'Source 1',
],
[
'type' => TestShareSourceType2::class,
'value' => 'source2',
'display_name' => 'Source 2',
],
],
'recipients' => [
[
'type' => TestShareRecipientType::class,
'value' => 'recipient1',
'display_name' => 'Recipient 1',
],
[
'type' => TestShareRecipientType2::class,
'value' => 'recipient2',
'display_name' => 'Recipient 2',
],
],
'properties' => [
TestShareFeature::class => [
'key1' => ['value1'],
],
TestShareFeature2::class => [
'key2' => ['value2'],
],
],
];
$response = $this->controller->createShare($data1);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data1, $responseData);
$data2 = [
'owner' => [
'user_id' => $this->owner1->getUID(),
'display_name' => $this->owner1->getDisplayName(),
],
'sources' => [
[
'type' => TestShareSourceType::class,
'value' => 'source1',
'display_name' => 'Source 1',
],
[
'type' => TestShareSourceType2::class,
'value' => 'source2',
'display_name' => 'Source 2',
],
],
'recipients' => [
[
'type' => TestShareRecipientType::class,
'value' => 'recipient1',
'display_name' => 'Recipient 1',
],
[
'type' => TestShareRecipientType2::class,
'value' => 'recipient2',
'display_name' => 'Recipient 2',
],
],
'properties' => [
TestShareFeature::class => [
'key1' => ['value1'],
],
TestShareFeature2::class => [
'key2' => ['value2'],
],
],
];
$response = $this->controller->createShare($data2);
/** @var SharingShare $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus(), var_export($responseData, true));
$this->assertArrayHasKey('id', $responseData);
unset($responseData['id']);
$this->assertArrayHasKey('last_updated', $responseData);
unset($responseData['last_updated']);
$this->assertEquals($data2, $responseData);
$response = $this->controller->getShares();
/** @var list<SharingShare> $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertCount(2, $responseData);
$this->assertArrayHasKey('id', $responseData[0]);
$this->assertArrayHasKey('last_updated', $responseData[0]);
$this->assertArrayHasKey('id', $responseData[1]);
$this->assertArrayHasKey('last_updated', $responseData[1]);
unset($responseData[0]['id'], $responseData[0]['last_updated'], $responseData[1]['id'], $responseData[1]['last_updated']);
$this->assertEquals($data1, $responseData[0]);
$this->assertEquals($data2, $responseData[1]);
$response = $this->controller->getShares(TestShareSourceType::class);
/** @var list<SharingShare> $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertCount(2, $responseData);
$this->assertArrayHasKey('id', $responseData[0]);
$this->assertArrayHasKey('last_updated', $responseData[0]);
$this->assertArrayHasKey('id', $responseData[1]);
$this->assertArrayHasKey('last_updated', $responseData[1]);
unset($responseData[0]['id'], $responseData[0]['last_updated'], $responseData[1]['id'], $responseData[1]['last_updated']);
$this->assertEquals($data1, $responseData[0]);
$this->assertEquals($data2, $responseData[1]);
/** @psalm-suppress ArgumentTypeCoercion */
$response = $this->controller->getShares('invalid');
/** @var list<SharingShare> $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertCount(0, $responseData);
$response = $this->controller->getShares(limit: 1);
/** @var list<SharingShare> $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertCount(1, $responseData);
$this->assertArrayHasKey('id', $responseData[0]);
$this->assertArrayHasKey('last_updated', $responseData[0]);
$lastShareId = $responseData[0]['id'];
unset($responseData[0]['id'], $responseData[0]['last_updated']);
$this->assertEquals($data1, $responseData[0]);
$response = $this->controller->getShares(lastShareId: $lastShareId);
/** @var list<SharingShare> $responseData */
$responseData = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus(), var_export($responseData, true));
$this->assertCount(1, $responseData);
$this->assertArrayHasKey('id', $responseData[0]);
$this->assertArrayHasKey('last_updated', $responseData[0]);
unset($responseData[0]['id'], $responseData[0]['last_updated']);
$this->assertEquals($data2, $responseData[0]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
use OCA\Sharing\Registry;
use OCA\Sharing\Tests\TestShareFeature;
use OCA\Sharing\Tests\TestShareRecipientType;
use OCA\Sharing\Tests\TestShareSourceType;
use OCP\Server;
use Test\TestCase;
class RegistryTest extends TestCase {
private Registry $registry;
public function setUp(): void {
parent::setUp();
$this->registry = Server::get(Registry::class);
}
protected function tearDown(): void {
$this->registry->clear();
parent::tearDown();
}
public function testRegisterSourceType(): void {
$sourceType = new TestShareSourceType([]);
$this->registry->registerSourceType($sourceType);
$this->assertEquals([TestShareSourceType::class => $sourceType], $this->registry->getSourceTypes());
$this->expectException(RuntimeException::class);
$this->registry->registerSourceType($sourceType);
}
public function testRegisterRecipientType(): void {
$recipientType = new TestShareRecipientType([], [], []);
$this->registry->registerRecipientType($recipientType);
$this->assertEquals([TestShareRecipientType::class => $recipientType], $this->registry->getRecipientTypes());
$this->expectException(RuntimeException::class);
$this->registry->registerRecipientType($recipientType);
}
public function testRegisterFeature(): void {
$feature = new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], []);
$this->registry->registerFeature($feature);
$this->assertEquals([TestShareFeature::class => $feature], $this->registry->getFeatures());
$this->expectException(RuntimeException::class);
$this->registry->registerFeature($feature);
}
public function testClear(): void {
$this->registry->registerSourceType(new TestShareSourceType([]));
$this->registry->registerRecipientType(new TestShareRecipientType([], [], []));
$this->registry->registerFeature(new TestShareFeature([['source_type' => TestShareSourceType::class, 'recipient_type' => TestShareRecipientType::class]], []));
$this->registry->clear();
$this->assertEquals([], $this->registry->getFeatures());
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\ResponseDefinitions;
/**
* @psalm-import-type SharingCompatible from ResponseDefinitions
*/
class TestShareFeature implements IShareFeature {
public function __construct(
/** @var non-empty-list<SharingCompatible> $compatibles */
private readonly array $compatibles,
/** @var list<string> $validProperties */
private readonly array $validProperties,
) {
}
/**
* @return non-empty-list<SharingCompatible>
*/
public function getCompatibles(): array {
return $this->compatibles;
}
public function validateProperties(array $properties): bool {
return array_intersect(array_keys($properties), $this->validProperties) !== [];
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
class TestShareFeature2 extends TestShareFeature {
}
@@ -0,0 +1,41 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareFeatureFilter;
use OCA\Sharing\ResponseDefinitions;
use OCP\IUser;
/**
* @psalm-import-type SharingCompatible from ResponseDefinitions
*/
class TestShareFeatureFilter implements IShareFeatureFilter {
public function __construct(
/** @var non-empty-list<SharingCompatible> $compatibles */
private readonly array $compatibles,
) {
}
/**
* @return non-empty-list<SharingCompatible>
*/
public function getCompatibles(): array {
return $this->compatibles;
}
public function validateProperties(array $properties): bool {
return true;
}
public function isFiltered(?IUser $currentUser, mixed $arguments, array $properties): bool {
return $arguments === 'filtered' || (($properties['filtered'] ?? ['false'])[0] === 'true');
}
}
@@ -0,0 +1,45 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareFeatureModifyProperties;
use OCA\Sharing\ResponseDefinitions;
/**
* @psalm-import-type SharingCompatible from ResponseDefinitions
*/
class TestShareFeatureModifyProperties implements IShareFeatureModifyProperties {
public function __construct(
/** @var non-empty-list<SharingCompatible> $compatibles */
private readonly array $compatibles,
) {
}
/**
* @return non-empty-list<SharingCompatible>
*/
public function getCompatibles(): array {
return $this->compatibles;
}
public function validateProperties(array $properties): bool {
return (array_keys($properties) === ['before'] && count($properties['before']) === 1 && $properties['before'][0] === 'valid')
|| (array_keys($properties) === ['after'] && count($properties['after']) === 1 && $properties['after'][0] === 'valid');
}
/**
* @param array<string, list<string>> $properties
* @return array<string, list<string>>
*/
public function modifyProperties(array $properties): array {
return ['after' => $properties['before']];
}
}
@@ -0,0 +1,52 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareRecipientTypeSearch;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCP\IUser;
class TestShareRecipientType implements IShareRecipientType, IShareRecipientTypeSearch {
public function __construct(
/** @var array<string, non-empty-string> $validRecipients */
private readonly array $validRecipients,
/** @var list<non-empty-string> $recipients */
private readonly array $recipients,
/** @var list<ShareRecipientSearchResult> $searchRecipients */
private readonly array $searchRecipients,
) {
}
public function getDisplayName(): string {
/** @var non-empty-list<non-empty-string> $parts */
$parts = explode('\\', static::class);
return end($parts);
}
public function validateRecipient(string $recipient): bool {
return array_key_exists($recipient, $this->validRecipients);
}
/**
* @return list<string>
*/
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array {
return $this->recipients;
}
public function getRecipientDisplayName(string $recipient): ?string {
return $this->validRecipients[$recipient];
}
public function searchRecipients(string $query, int $limit, int $offset): array {
return array_slice(array_map(static fn (ShareRecipientSearchResult $result): ShareRecipientSearchResult => clone $result, $this->searchRecipients), $offset, $limit);
}
}
@@ -0,0 +1,13 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
class TestShareRecipientType2 extends TestShareRecipientType {
}
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareRecipientType;
use OCP\IUser;
class TestShareRecipientTypeArguments implements IShareRecipientType {
public function getDisplayName(): string {
/** @var non-empty-list<non-empty-string> $parts */
$parts = explode('\\', static::class);
return end($parts);
}
public function validateRecipient(string $recipient): bool {
return true;
}
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array {
if (is_string($arguments)) {
return [$arguments];
}
return [];
}
public function getRecipientDisplayName(string $recipient): ?string {
return null;
}
}
@@ -0,0 +1,35 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
use OCA\Sharing\Model\IShareSourceType;
use OCP\IUser;
class TestShareSourceType implements IShareSourceType {
public function __construct(
/** @var array<string, non-empty-string> $validSources */
private readonly array $validSources,
) {
}
public function getDisplayName(): string {
/** @var non-empty-list<non-empty-string> $parts */
$parts = explode('\\', static::class);
return end($parts);
}
public function validateSource(IUser $owner, string $source): bool {
return array_key_exists($source, $this->validSources);
}
public function getSourceDisplayName(string $source): ?string {
return $this->validSources[$source];
}
}
@@ -0,0 +1,13 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OCA\Sharing\Tests;
class TestShareSourceType2 extends TestShareSourceType {
}
+5
View File
@@ -24,6 +24,11 @@ return (require __DIR__ . '/rector-shared.php')
$nextcloudDir . '/build/psalm/ITypedQueryBuilderTest.php',
$nextcloudDir . '/lib/private/DB/QueryBuilder/TypedQueryBuilder.php',
$nextcloudDir . '/lib/public/DB/QueryBuilder/ITypedQueryBuilder.php',
$nextcloudDir . '/apps/files/lib/Sharing',
$nextcloudDir . '/apps/files/tests/Sharing',
$nextcloudDir . '/apps/sharing',
$nextcloudDir . '/core/Sharing',
$nextcloudDir . '/tests/Core/Sharing',
])
->withPreparedSets(
deadCode: true,
+15
View File
@@ -22,8 +22,14 @@ use OC\Core\Listener\AddMissingPrimaryKeyListener;
use OC\Core\Listener\BeforeTemplateRenderedListener;
use OC\Core\Listener\PasswordUpdatedListener;
use OC\Core\Notification\CoreNotifier;
use OC\Core\Sharing\Feature\ExpirationShareFeature;
use OC\Core\Sharing\Feature\LabelShareFeature;
use OC\Core\Sharing\Feature\NoteShareFeature;
use OC\Core\Sharing\RecipientType\GroupShareRecipientType;
use OC\Core\Sharing\RecipientType\UserShareRecipientType;
use OC\OCM\OCMDiscoveryHandler;
use OC\TagManager;
use OCA\Sharing\Registry;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -32,6 +38,7 @@ use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent;
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\DB\Events\AddMissingPrimaryKeyEvent;
use OCP\Server;
use OCP\User\Events\BeforeUserDeletedEvent;
use OCP\User\Events\PasswordUpdatedEvent;
use OCP\User\Events\UserDeletedEvent;
@@ -88,6 +95,14 @@ class Application extends App implements IBootstrap {
$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
$context->registerCapability(Capabilities::class);
$registry = Server::get(Registry::class);
$registry->registerRecipientType(new GroupShareRecipientType());
$registry->registerRecipientType(new UserShareRecipientType());
$registry->registerFeature(new ExpirationShareFeature());
$registry->registerFeature(new LabelShareFeature());
$registry->registerFeature(new NoteShareFeature());
}
public function boot(IBootContext $context): void {
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Sharing\Feature;
use DateTimeImmutable;
use Exception;
use OC\Core\Sharing\RecipientType\GroupShareRecipientType;
use OC\Core\Sharing\RecipientType\UserShareRecipientType;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareFeatureFilter;
use OCP\IUser;
class ExpirationShareFeature implements IShareFeature, IShareFeatureFilter {
public function getCompatibles(): array {
$compatibles = [];
foreach ([NodeShareSourceType::class] as $sourceType) {
foreach ([UserShareRecipientType::class, GroupShareRecipientType::class] as $recipientType) {
$compatibles[] = [
'source_type' => $sourceType,
'recipient_type' => $recipientType,
];
}
}
return $compatibles;
}
public function validateProperties(array $properties): bool {
if (array_keys($properties) !== ['date'] || count($properties['date']) !== 1 || $properties['date'][0] === '') {
return false;
}
try {
new DateTimeImmutable($properties['date'][0]);
return true;
} catch (Exception) {
return false;
}
}
/**
* @throws Exception
*/
public function isFiltered(?IUser $currentUser, mixed $arguments, array $properties): bool {
return (new DateTimeImmutable($properties['date'][0]))->diff(new DateTimeImmutable())->invert === 0;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Sharing\Feature;
use OC\Core\Sharing\RecipientType\GroupShareRecipientType;
use OC\Core\Sharing\RecipientType\UserShareRecipientType;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCA\Sharing\Model\IShareFeature;
class LabelShareFeature implements IShareFeature {
public function getCompatibles(): array {
$compatibles = [];
foreach ([NodeShareSourceType::class] as $sourceType) {
foreach ([UserShareRecipientType::class, GroupShareRecipientType::class] as $recipientType) {
$compatibles[] = [
'source_type' => $sourceType,
'recipient_type' => $recipientType,
];
}
}
return $compatibles;
}
public function validateProperties(array $properties): bool {
return array_keys($properties) === ['text'] && count($properties['text']) === 1 && $properties['text'][0] !== '';
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Sharing\Feature;
use OC\Core\Sharing\RecipientType\GroupShareRecipientType;
use OC\Core\Sharing\RecipientType\UserShareRecipientType;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCA\Sharing\Model\IShareFeature;
class NoteShareFeature implements IShareFeature {
public function getCompatibles(): array {
$compatibles = [];
foreach ([NodeShareSourceType::class] as $sourceType) {
foreach ([UserShareRecipientType::class, GroupShareRecipientType::class] as $recipientType) {
$compatibles[] = [
'source_type' => $sourceType,
'recipient_type' => $recipientType,
];
}
}
return $compatibles;
}
public function validateProperties(array $properties): bool {
return array_keys($properties) === ['text'] && count($properties['text']) === 1 && $properties['text'][0] !== '';
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Sharing\Feature;
use Exception;
use OC\Core\Sharing\RecipientType\TokenShareRecipientType;
use OCA\Files\Sharing\SourceType\NodeShareSourceType;
use OCA\Sharing\Model\IShareFeature;
use OCA\Sharing\Model\IShareFeatureFilter;
use OCA\Sharing\Model\IShareFeatureModifyProperties;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IUser;
use OCP\Security\Events\ValidatePasswordPolicyEvent;
use OCP\Security\IHasher;
use OCP\Security\PasswordContext;
use OCP\Server;
class PasswordShareFeature implements IShareFeature, IShareFeatureModifyProperties, IShareFeatureFilter {
public function getCompatibles(): array {
$compatibles = [];
foreach ([NodeShareSourceType::class] as $sourceType) {
foreach ([TokenShareRecipientType::class] as $recipientType) {
$compatibles[] = [
'source_type' => $sourceType,
'recipient_type' => $recipientType,
];
}
}
return $compatibles;
}
public function validateProperties(array $properties): bool {
if (array_keys($properties) === ['hash']) {
if (count($properties['hash']) !== 1) {
return false;
}
return Server::get(IHasher::class)->validate($properties['hash'][0]);
}
if (array_keys($properties) === ['password']) {
if (count($properties['password']) !== 1) {
return false;
}
try {
Server::get(IEventDispatcher::class)->dispatchTyped(new ValidatePasswordPolicyEvent($properties['password'][0], PasswordContext::SHARING));
return true;
} catch (Exception) {
return false;
}
}
return false;
}
public function modifyProperties(array $properties): array {
if (array_keys($properties) === ['hash']) {
return $properties;
}
return ['hash' => [Server::get(IHasher::class)->hash($properties['password'][0])]];
}
/**
* @throws Exception
*/
public function isFiltered(?IUser $currentUser, mixed $arguments, array $properties): bool {
if (!is_string($arguments)) {
return true;
}
// TODO: Check if the hash has to be updated and save it.
return !Server::get(IHasher::class)->verify($arguments, $properties['hash'][0]);
}
}
@@ -0,0 +1,100 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OC\Core\Sharing\RecipientType;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareRecipientTypeSearch;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCP\Collaboration\Collaborators\ISearch;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IUser;
use OCP\Server;
use OCP\Share\IShare;
use RuntimeException;
class GroupShareRecipientType implements IShareRecipientType, IShareRecipientTypeSearch {
public function getDisplayName(): string {
return Server::get(IL10N::class)->t('Group');
}
public function validateRecipient(string $recipient): bool {
return Server::get(IGroupManager::class)->groupExists($recipient);
}
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array {
if (!$currentUser instanceof IUser) {
return [];
}
return Server::get(IGroupManager::class)->getUserGroupIds($currentUser);
}
public function getRecipientDisplayName(string $recipient): ?string {
$displayName = Server::get(IGroupManager::class)->getDisplayName($recipient);
if ($displayName === '') {
return null;
}
return $displayName;
}
/**
* @return list<ShareRecipientSearchResult>
*/
public function searchRecipients(string $query, int $limit, int $offset): array {
// TODO: Maybe enable lookup?
/** @var array{array{groups: list<array>}, bool} $results */
$results = Server::get(ISearch::class)->search($query, [IShare::TYPE_GROUP], false, $limit, $offset);
$results = $results[0]['groups'];
return array_map(static function (array $result): ShareRecipientSearchResult {
if (!isset($result['value'])) {
throw new RuntimeException('The value is missing.');
}
if (!is_array($result['value'])) {
throw new RuntimeException('The value is not an array.');
}
if (!isset($result['value']['shareWith'])) {
throw new RuntimeException('The shareWith is missing.');
}
if (!is_string($result['value']['shareWith'])) {
throw new RuntimeException('The shareWith is not a string.');
}
if ($result['value']['shareWith'] === '') {
throw new RuntimeException('The shareWith is empty.');
}
if (!isset($result['label'])) {
throw new RuntimeException('The label is missing.');
}
if (!is_string($result['label'])) {
throw new RuntimeException('The label is not a string.');
}
if ($result['label'] === '') {
throw new RuntimeException('The label is empty.');
}
return new ShareRecipientSearchResult(
$result['value']['shareWith'],
$result['label'],
null,
null,
null,
);
}, $results);
}
}
@@ -0,0 +1,38 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OC\Core\Sharing\RecipientType;
use OC\Share\Constants;
use OCA\Sharing\Model\IShareRecipientType;
use OCP\IL10N;
use OCP\IUser;
use OCP\Server;
class TokenShareRecipientType implements IShareRecipientType {
public function getDisplayName(): string {
return Server::get(IL10N::class)->t('Public link');
}
public function validateRecipient(string $recipient): bool {
return preg_match('/^[a-z0-9-]{' . Constants::MIN_TOKEN_LENGTH . ',' . Constants::MAX_TOKEN_LENGTH . '}$/i', $recipient) === 1;
}
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array {
if (is_string($arguments)) {
return [$arguments];
}
return [];
}
public function getRecipientDisplayName(string $recipient): ?string {
return null;
}
}
@@ -0,0 +1,121 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace OC\Core\Sharing\RecipientType;
use OCA\Sharing\Model\IShareRecipientType;
use OCA\Sharing\Model\IShareRecipientTypeSearch;
use OCA\Sharing\Model\ShareRecipientSearchResult;
use OCP\Collaboration\Collaborators\ISearch;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use OCP\Share\IShare;
use RuntimeException;
class UserShareRecipientType implements IShareRecipientType, IShareRecipientTypeSearch {
public function getDisplayName(): string {
return Server::get(IL10N::class)->t('User');
}
public function validateRecipient(string $recipient): bool {
return Server::get(IUserManager::class)->userExists($recipient);
}
public function getRecipientValues(?IUser $currentUser, mixed $arguments): array {
if (!$currentUser instanceof IUser) {
return [];
}
return [$currentUser->getUID()];
}
public function getRecipientDisplayName(string $recipient): ?string {
return Server::get(IUserManager::class)->getDisplayName($recipient);
}
/**
* @return list<ShareRecipientSearchResult>
*/
public function searchRecipients(string $query, int $limit, int $offset): array {
// TODO: Maybe enable lookup?
/** @var array{array{users: list<array>}, bool} $results */
$results = Server::get(ISearch::class)->search($query, [IShare::TYPE_USER], false, $limit, $offset);
$results = $results[0]['users'];
$urlGenerator = Server::get(IURLGenerator::class);
return array_map(static function (array $result) use ($urlGenerator): ShareRecipientSearchResult {
if (!isset($result['value'])) {
throw new RuntimeException('The value is missing.');
}
if (!is_array($result['value'])) {
throw new RuntimeException('The value is not an array.');
}
if (!isset($result['value']['shareWith'])) {
throw new RuntimeException('The shareWith is missing.');
}
if (!is_string($result['value']['shareWith'])) {
throw new RuntimeException('The shareWith is not a string.');
}
if ($result['value']['shareWith'] === '') {
throw new RuntimeException('The shareWith is empty.');
}
if (!isset($result['label'])) {
throw new RuntimeException('The label is missing.');
}
if (!is_string($result['label'])) {
throw new RuntimeException('The label is not a string.');
}
if ($result['label'] === '') {
throw new RuntimeException('The label is empty.');
}
if (!isset($result['shareWithDisplayNameUnique'])) {
throw new RuntimeException('The shareWithDisplayNameUnique is missing.');
}
if (!is_string($result['shareWithDisplayNameUnique'])) {
throw new RuntimeException('The shareWithDisplayNameUnique is not a string.');
}
if ($result['shareWithDisplayNameUnique'] === '') {
throw new RuntimeException('The shareWithDisplayNameUnique is empty.');
}
$uid = $result['value']['shareWith'];
$iconUrlLight = $urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $uid, 'size' => 64]);
if ($iconUrlLight === '') {
throw new RuntimeException('The iconUrlLight is empty.');
}
$iconUrlDark = $urlGenerator->linkToRouteAbsolute('core.avatar.getAvatarDark', ['userId' => $uid, 'size' => 64]);
if ($iconUrlDark === '') {
throw new RuntimeException('The iconUrlDark is empty.');
}
return new ShareRecipientSearchResult(
$uid,
$result['label'],
$result['shareWithDisplayNameUnique'],
$iconUrlLight,
$iconUrlDark,
);
}, $results);
}
}
+2
View File
@@ -37,6 +37,7 @@
"serverinfo",
"settings",
"sharebymail",
"sharing",
"support",
"survey_client",
"suspicious_login",
@@ -109,6 +110,7 @@
"profile",
"provisioning_api",
"settings",
"sharing",
"theming",
"twofactor_backupcodes",
"viewer",
+2 -2
View File
@@ -277,7 +277,7 @@ class InstalledVersions
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' && is_file(__DIR__ . '/installed.php')) {
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
@@ -378,7 +378,7 @@ class InstalledVersions
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' && is_file(__DIR__ . '/installed.php')) {
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
@@ -1601,6 +1601,13 @@ return array(
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',
'OC\\Core\\Sharing\\Feature\\ExpirationShareFeature' => $baseDir . '/core/Sharing/Feature/ExpirationShareFeature.php',
'OC\\Core\\Sharing\\Feature\\LabelShareFeature' => $baseDir . '/core/Sharing/Feature/LabelShareFeature.php',
'OC\\Core\\Sharing\\Feature\\NoteShareFeature' => $baseDir . '/core/Sharing/Feature/NoteShareFeature.php',
'OC\\Core\\Sharing\\Feature\\PasswordShareFeature' => $baseDir . '/core/Sharing/Feature/PasswordShareFeature.php',
'OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType' => $baseDir . '/core/Sharing/RecipientType/GroupShareRecipientType.php',
'OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType' => $baseDir . '/core/Sharing/RecipientType/TokenShareRecipientType.php',
'OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType' => $baseDir . '/core/Sharing/RecipientType/UserShareRecipientType.php',
'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php',
'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php',
'OC\\DB\\AdapterOCI8' => $baseDir . '/lib/private/DB/AdapterOCI8.php',
@@ -1642,6 +1642,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',
'OC\\Core\\Sharing\\Feature\\ExpirationShareFeature' => __DIR__ . '/../../..' . '/core/Sharing/Feature/ExpirationShareFeature.php',
'OC\\Core\\Sharing\\Feature\\LabelShareFeature' => __DIR__ . '/../../..' . '/core/Sharing/Feature/LabelShareFeature.php',
'OC\\Core\\Sharing\\Feature\\NoteShareFeature' => __DIR__ . '/../../..' . '/core/Sharing/Feature/NoteShareFeature.php',
'OC\\Core\\Sharing\\Feature\\PasswordShareFeature' => __DIR__ . '/../../..' . '/core/Sharing/Feature/PasswordShareFeature.php',
'OC\\Core\\Sharing\\RecipientType\\GroupShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/RecipientType/GroupShareRecipientType.php',
'OC\\Core\\Sharing\\RecipientType\\TokenShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/RecipientType/TokenShareRecipientType.php',
'OC\\Core\\Sharing\\RecipientType\\UserShareRecipientType' => __DIR__ . '/../../..' . '/core/Sharing/RecipientType/UserShareRecipientType.php',
'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php',
'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php',
'OC\\DB\\AdapterOCI8' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterOCI8.php',
+2 -2
View File
@@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '671cec33f134e670bb21c5e3c49c685bd78fc339',
'reference' => 'e564c61e163f17a4ad349f0474a184e49eeeef5a',
'type' => 'library',
'install_path' => __DIR__ . '/../../../',
'aliases' => array(),
@@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => '671cec33f134e670bb21c5e3c49c685bd78fc339',
'reference' => 'e564c61e163f17a4ad349f0474a184e49eeeef5a',
'type' => 'library',
'install_path' => __DIR__ . '/../../../',
'aliases' => array(),
+3 -1
View File
@@ -41,12 +41,14 @@ class Group implements IGroup {
private bool $usersLoaded = false;
public function __construct(
/** @var non-empty-string $gid */
private string $gid,
/** @var list<GroupInterface> */
private array $backends,
private IEventDispatcher $dispatcher,
private IUserManager $userManager,
private ?PublicEmitter $emitter = null,
/** @var ?non-empty-string $displayName */
protected ?string $displayName = null,
) {
}
@@ -60,7 +62,7 @@ class Group implements IGroup {
foreach ($this->backends as $backend) {
if ($backend instanceof IGetDisplayNameBackend) {
$displayName = $backend->getDisplayName($this->gid);
if (trim($displayName) !== '') {
if ($displayName !== '') {
$this->displayName = $displayName;
return $this->displayName;
}
-9
View File
@@ -54,15 +54,6 @@ class L10N implements IL10N {
return $this->locale ?? '';
}
/**
* Translating
* @param string $text The text we need a translation for
* @param array|string $parameters default:array() Parameters for sprintf
* @return string Translation or the same text
*
* Returns the translation. If no translation is found, $text will be
* returned.
*/
public function t(string $text, $parameters = []): string {
if (!\is_array($parameters)) {
$parameters = [$parameters];
+9 -1
View File
@@ -19,6 +19,9 @@ class L10NString implements \JsonSerializable {
) {
}
/**
* @return non-empty-string
*/
public function __toString(): string {
$translations = $this->l10n->getTranslations();
$identityTranslator = $this->l10n->getIdentityTranslator();
@@ -51,7 +54,12 @@ class L10NString implements \JsonSerializable {
// $count as %count% as per \Symfony\Contracts\Translation\TranslatorInterface
$text = $identityTranslator->trans($identity, $parameters);
return vsprintf($text, $this->parameters);
$text = vsprintf($text, $this->parameters);
if ($text === '') {
throw new \RuntimeException('The translated text is empty: ' . $this->text);
}
return $text;
}
public function jsonSerialize(): string {
@@ -13,6 +13,7 @@ use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\Snowflake\ISnowflakeGenerator;
use Override;
use RuntimeException;
/**
* Nextcloud Snowflake ID generator
@@ -68,6 +69,9 @@ final readonly class SnowflakeGenerator implements ISnowflakeGenerator {
/**
* Mostly copied from Symfony:
* https://github.com/symfony/symfony/blob/v7.3.4/src/Symfony/Component/Uid/BinaryUtil.php#L49
*
* @param non-empty-list<non-negative-int> $bytes
* @return non-empty-string
*/
private function convertToDecimal(array $bytes): string {
$base = 10;
@@ -91,6 +95,10 @@ final readonly class SnowflakeGenerator implements ISnowflakeGenerator {
$bytes = $quotient;
}
if ($digits === '') {
throw new RuntimeException('Empty digits: ' . var_export($bytes, true));
}
return $digits;
}
+3
View File
@@ -37,6 +37,9 @@ class DisplayNameCache implements IEventListener {
$this->memCache = $cacheFactory->createDistributed('displayNameMappingCache');
}
/**
* @return ?non-empty-string
*/
public function getDisplayName(string $userId): ?string {
if (isset($this->cache[$userId])) {
return $this->cache[$userId];
+2
View File
@@ -16,8 +16,10 @@ class LazyUser implements IUser {
private ?IUser $user = null;
public function __construct(
/** @var non-empty-string $uid */
private string $uid,
private IUserManager $userManager,
/** @var ?non-empty-string $displayName */
private ?string $displayName = null,
private ?UserInterface $backend = null,
) {
+2
View File
@@ -54,6 +54,7 @@ class User implements IUser {
private IURLGenerator $urlGenerator;
protected ?IAccountManager $accountManager = null;
/** @var ?non-empty-string $displayName */
private ?string $displayName = null;
private ?bool $enabled = null;
private ?string $home = null;
@@ -63,6 +64,7 @@ class User implements IUser {
private ?IAvatarManager $avatarManager = null;
public function __construct(
/** @var non-empty-string $uid */
private string $uid,
private ?UserInterface $backend,
private IEventDispatcher $dispatcher,
+2 -2
View File
@@ -15,7 +15,7 @@ namespace OCP;
*/
interface IGroup {
/**
* @return string
* @return non-empty-string
* @since 8.0.0
*/
public function getGID(): string;
@@ -23,7 +23,7 @@ interface IGroup {
/**
* Returns the group display name
*
* @return string
* @return non-empty-string
* @since 12.0.0
*/
public function getDisplayName(): string;
+3 -3
View File
@@ -19,9 +19,9 @@ namespace OCP;
interface IL10N {
/**
* Translating
* @param string $text The text we need a translation for
* @param array|string $parameters default:array() Parameters for sprintf
* @return string Translation or the same text
* @param non-empty-string $text The text we need a translation for
* @param array|non-empty-string $parameters default:array() Parameters for sprintf
* @return non-empty-string Translation or the same text
*
* Returns the translation. If no translation is found, $text will be
* returned.
+2 -2
View File
@@ -26,7 +26,7 @@ interface IUser {
/**
* get the user id
*
* @return string
* @return non-empty-string
* @since 8.0.0
*/
public function getUID();
@@ -34,7 +34,7 @@ interface IUser {
/**
* get the display name for the user, if no specific display name is set it will fallback to the user id
*
* @return string
* @return non-empty-string
* @since 8.0.0
*/
public function getDisplayName();
+1 -1
View File
@@ -75,7 +75,7 @@ interface IUserManager {
* Get the display name of a user
*
* @param string $uid
* @return string|null
* @return non-empty-string|null
* @since 25.0.0
*/
public function getDisplayName(string $uid): ?string;
@@ -40,6 +40,8 @@ interface ISnowflakeGenerator {
*
* Each call to this method is guaranteed to return a different ID.
*
* @return non-empty-string
*
* @since 33.0
*/
public function nextId(): string;
+1240
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -15,6 +15,9 @@
findUnusedVariablesAndParams="true"
phpVersion="8.2"
>
<plugins>
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
</plugins>
<projectFiles>
<file name="core/BackgroundJobs/ExpirePreviewsJob.php"/>
<file name="lib/public/IContainer.php"/>
@@ -32,18 +35,25 @@
<file name="lib/public/DB/QueryBuilder/ITypedQueryBuilder.php"/>
<file name="lib/private/Share20/ShareHelper.php"/>
<file name="lib/public/Share/IShareHelper.php"/>
<directory name="apps/files/lib/Sharing"/>
<directory name="apps/files/tests/Sharing"/>
<directory name="apps/sharing"/>
<directory name="core/Sharing"/>
<directory name="tests/Core/Sharing"/>
<ignoreFiles>
<directory name="apps/**/composer"/>
<directory name="apps/**/tests"/>
<directory name="lib/composer"/>
<directory name="lib/l10n"/>
<directory name="3rdparty"/>
<file name="lib/public/AppFramework/OCSController.php"/>
</ignoreFiles>
</projectFiles>
<extraFiles>
<directory name="apps/dav/lib"/>
<directory name="apps/settings/lib"/>
<directory name="3rdparty"/>
<directory name="tests/lib"/>
<directory name="vendor-bin/phpunit/vendor/phpunit/phpunit"/>
</extraFiles>
<stubs>
<!-- Psalm does not find methods in here through <extraFiles/> 🤷‍♀️ -->
+2
View File
@@ -18,6 +18,7 @@
<plugin filename="build/psalm/AppFrameworkTainter.php" />
<plugin filename="build/psalm/AttributeNamedParameters.php" />
<plugin filename="build/psalm/LogicalOperatorChecker.php" />
<pluginClass class="Psalm\PhpUnitPlugin\Plugin"/>
</plugins>
<projectFiles>
<directory name="apps/admin_audit"/>
@@ -41,6 +42,7 @@
<directory name="apps/provisioning_api"/>
<directory name="apps/settings"/>
<directory name="apps/sharebymail"/>
<directory name="apps/sharing"/>
<directory name="apps/systemtags"/>
<directory name="apps/testing"/>
<directory name="apps/theming"/>
@@ -0,0 +1,50 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace Tests\Core\Sharing\Feature;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use OC\Core\Sharing\Feature\ExpirationShareFeature;
use Test\TestCase;
class ExpirationShareFeatureTest extends TestCase {
private ExpirationShareFeature $feature;
public function setUp(): void {
parent::setUp();
$this->feature = new ExpirationShareFeature();
}
public function testValidateProperties(): void {
$now = (new DateTimeImmutable())->format(DateTimeInterface::ATOM);
$this->assertTrue($this->feature->validateProperties(['date' => [$now]]));
$this->assertFalse($this->feature->validateProperties([]));
$this->assertFalse($this->feature->validateProperties(['a' => []]));
$this->assertFalse($this->feature->validateProperties(['date' => [$now], 'a' => ['']]));
$this->assertFalse($this->feature->validateProperties(['date' => []]));
$this->assertFalse($this->feature->validateProperties(['date' => ['']]));
$this->assertFalse($this->feature->validateProperties(['date' => [$now, $now]]));
}
public function testIsFiltered(): void {
$now = new DateTimeImmutable();
$future = $now->add(new DateInterval('PT1M'))->format(DateTimeInterface::ATOM);
$past = $now->sub(new DateInterval('PT1M'))->format(DateTimeInterface::ATOM);
$this->assertFalse($this->feature->isFiltered(null, null, ['date' => [$future]]));
$this->assertTrue($this->feature->isFiltered(null, null, ['date' => [$now->format(DateTimeInterface::ATOM)]]));
$this->assertTrue($this->feature->isFiltered(null, null, ['date' => [$past]]));
}
}
@@ -0,0 +1,38 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace Tests\Core\Sharing\Feature;
use OC\Core\Sharing\Feature\LabelShareFeature;
use OCA\Sharing\Model\IShareFeature;
use Test\TestCase;
class LabelShareFeatureTest extends TestCase {
private IShareFeature $feature;
public function setUp(): void {
parent::setUp();
$this->feature = new LabelShareFeature();
}
public function testValidateProperties(): void {
$this->assertTrue($this->feature->validateProperties(['text' => ['a']]));
$this->assertFalse($this->feature->validateProperties([]));
$this->assertFalse($this->feature->validateProperties(['a' => ['a']]));
$this->assertFalse($this->feature->validateProperties(['text' => ['a'], 'a' => ['a']]));
$this->assertFalse($this->feature->validateProperties(['text' => []]));
$this->assertFalse($this->feature->validateProperties(['text' => ['a', 'b']]));
$this->assertFalse($this->feature->validateProperties(['text' => ['']]));
}
}
@@ -0,0 +1,38 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace Tests\Core\Sharing\Feature;
use OC\Core\Sharing\Feature\NoteShareFeature;
use OCA\Sharing\Model\IShareFeature;
use Test\TestCase;
class NoteShareFeatureTest extends TestCase {
private IShareFeature $feature;
public function setUp(): void {
parent::setUp();
$this->feature = new NoteShareFeature();
}
public function testValidateProperties(): void {
$this->assertTrue($this->feature->validateProperties(['text' => ['a']]));
$this->assertFalse($this->feature->validateProperties([]));
$this->assertFalse($this->feature->validateProperties(['a' => ['a']]));
$this->assertFalse($this->feature->validateProperties(['text' => ['a'], 'a' => ['a']]));
$this->assertFalse($this->feature->validateProperties(['text' => []]));
$this->assertFalse($this->feature->validateProperties(['text' => ['a', 'b']]));
$this->assertFalse($this->feature->validateProperties(['text' => ['']]));
}
}
@@ -0,0 +1,85 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
declare(strict_types=1);
namespace Tests\Core\Sharing\Feature;
use OC\Core\Sharing\Feature\PasswordShareFeature;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\HintException;
use OCP\Security\Events\ValidatePasswordPolicyEvent;
use OCP\Security\IHasher;
use OCP\Server;
use Test\TestCase;
class PasswordShareFeatureTest extends TestCase {
private PasswordShareFeature $feature;
private IEventDispatcher $eventDispatcher;
/**
* @var callable(ValidatePasswordPolicyEvent):void $validatePasswordPolicyEventListener
*/
private $validatePasswordPolicyEventListener;
public function setUp(): void {
parent::setUp();
$this->feature = new PasswordShareFeature();
$this->eventDispatcher = Server::get(IEventDispatcher::class);
$this->validatePasswordPolicyEventListener = static function (ValidatePasswordPolicyEvent $event): void {
if ($event->getPassword() !== 'secure') {
throw new HintException('insecure');
}
};
$this->eventDispatcher->addListener(ValidatePasswordPolicyEvent::class, $this->validatePasswordPolicyEventListener);
}
protected function tearDown(): void {
$this->eventDispatcher->removeListener(ValidatePasswordPolicyEvent::class, $this->validatePasswordPolicyEventListener);
parent::tearDown();
}
public function testValidateProperties(): void {
$hash = Server::get(IHasher::class)->hash('secure');
$this->assertTrue($this->feature->validateProperties(['hash' => [$hash]]));
$this->assertTrue($this->feature->validateProperties(['password' => ['secure']]));
$this->assertFalse($this->feature->validateProperties([]));
$this->assertFalse($this->feature->validateProperties(['a' => []]));
$this->assertFalse($this->feature->validateProperties(['hash' => [$hash], 'a' => ['']]));
$this->assertFalse($this->feature->validateProperties(['password' => ['secure'], 'a' => ['']]));
$this->assertFalse($this->feature->validateProperties(['hash' => []]));
$this->assertFalse($this->feature->validateProperties(['password' => []]));
$this->assertFalse($this->feature->validateProperties(['hash' => ['']]));
$this->assertFalse($this->feature->validateProperties(['password' => ['']]));
$this->assertFalse($this->feature->validateProperties(['hash' => [$hash, $hash]]));
$this->assertFalse($this->feature->validateProperties(['password' => ['secure', 'secure']]));
$this->assertFalse($this->feature->validateProperties(['password' => ['insecure']]));
}
public function testModifyProperties(): void {
$properties = $this->feature->modifyProperties(['password' => ['123']]);
$this->assertEquals(['hash'], array_keys($properties));
$this->assertCount(1, $properties['hash']);
$this->assertTrue(Server::get(IHasher::class)->validate($properties['hash'][0]));
$this->assertTrue(Server::get(IHasher::class)->verify('123', $properties['hash'][0]));
}
public function testIsFiltered(): void {
$this->assertFalse($this->feature->isFiltered(null, '123', ['hash' => [Server::get(IHasher::class)->hash('123')]]));
$this->assertTrue($this->feature->isFiltered(null, '456', ['hash' => [Server::get(IHasher::class)->hash('123')]]));
$this->assertTrue($this->feature->isFiltered(null, null, ['hash' => [Server::get(IHasher::class)->hash('123')]]));
}
}

Some files were not shown because too many files have changed in this diff Show More