Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad715aa1ae | |||
| e564c61e16 | |||
| 4888d7c30f | |||
| 2d729e1c3d |
@@ -38,6 +38,7 @@ node_modules/
|
||||
!/apps/profile
|
||||
!/apps/provisioning_api
|
||||
!/apps/settings
|
||||
!/apps/sharing
|
||||
!/apps/systemtags
|
||||
!/apps/testing
|
||||
!/apps/admin_audit
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"config" : {
|
||||
"vendor-dir": ".",
|
||||
"optimize-autoloader": true,
|
||||
"classmap-authoritative": true,
|
||||
"autoloader-suffix": "Sharing"
|
||||
},
|
||||
"autoload" : {
|
||||
"psr-4": {
|
||||
"OCA\\Sharing\\": "../lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) !== [];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] !== '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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/> 🤷♀️ -->
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user