@ -22,6 +22,7 @@ use OCA\DAV\Db\PropertyMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUser;
use Override;
use Sabre\CalDAV\Schedule\Inbox;
use Sabre\CalDAV\Schedule\Inbox;
use Sabre\DAV\Exception as DavException;
use Sabre\DAV\Exception as DavException;
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
@ -98,6 +99,7 @@ class CustomPropertiesBackend implements BackendInterface {
/**
/**
* Map of custom XML elements to parse when trying to deserialize an instance of
* Map of custom XML elements to parse when trying to deserialize an instance of
* \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
* \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
* @var array< string , class-string >
*/
*/
private const COMPLEX_XML_ELEMENT_MAP = [
private const COMPLEX_XML_ELEMENT_MAP = [
'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
'{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
@ -105,6 +107,7 @@ class CustomPropertiesBackend implements BackendInterface {
/**
/**
* Map of well-known property names to default values
* Map of well-known property names to default values
* @var array< string , string >
*/
*/
private const PROPERTY_DEFAULT_VALUES = [
private const PROPERTY_DEFAULT_VALUES = [
'{http://owncloud.org/ns}calendar-enabled' => '1',
'{http://owncloud.org/ns}calendar-enabled' => '1',
@ -118,17 +121,15 @@ class CustomPropertiesBackend implements BackendInterface {
private XmlService $xmlService;
private XmlService $xmlService;
/**
/**
* @param Tree $tree node tree
* @param IDBConnection $connection database connection
* @param IUser $user owner of the tree and properties
* @param IUser $user owner of the tree and properties
*/
*/
public function __construct(
public function __construct(
private Server $server,
private readonly Server $server,
private Tree $tree,
private readonly Tree $tree,
private IDBConnection $connection,
private readonly IDBConnection $connection,
private IUser $user,
private readonly IUser $user,
private PropertyMapper $propertyMapper,
private readonly PropertyMapper $propertyMapper,
private DefaultCalendarValidator $defaultCalendarValidator,
private readonly DefaultCalendarValidator $defaultCalendarValidator,
) {
) {
$this->xmlService = new XmlService();
$this->xmlService = new XmlService();
$this->xmlService->elementMap = array_merge(
$this->xmlService->elementMap = array_merge(
@ -142,9 +143,9 @@ class CustomPropertiesBackend implements BackendInterface {
*
*
* @param string $path
* @param string $path
* @param PropFind $propFind
* @param PropFind $propFind
* @return void
*/
*/
public function propFind($path, PropFind $propFind) {
#[Override]
public function propFind($path, PropFind $propFind): void {
$requestedProps = $propFind->get404Properties();
$requestedProps = $propFind->get404Properties();
$requestedProps = array_filter(
$requestedProps = array_filter(
@ -257,12 +258,10 @@ class CustomPropertiesBackend implements BackendInterface {
* Updates properties for a path
* Updates properties for a path
*
*
* @param string $path
* @param string $path
* @param PropPatch $propPatch
*
* @return void
*/
*/
public function propPatch($path, PropPatch $propPatch) {
#[Override]
$propPatch->handleRemaining(function ($changedProps) use ($path) {
public function propPatch($path, PropPatch $propPatch): void {
$propPatch->handleRemaining(function (array $changedProps) use ($path) {
return $this->updateProperties($path, $changedProps);
return $this->updateProperties($path, $changedProps);
});
});
}
}
@ -272,13 +271,13 @@ class CustomPropertiesBackend implements BackendInterface {
*
*
* @param string $path path of node for which to delete properties
* @param string $path path of node for which to delete properties
*/
*/
public function delete($path) {
#[Override]
$statement = $this->connection->prepare(
public function delete($path): void {
'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
$qb = $this->connection->getQueryBuilder();
);
$qb->delete('properties' )
$statement->execute([$this->user->getUID(), $this->formatPath($path)]);
->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
$statement->closeCursor( );
->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path))) );
$qb->executeStatement();
unset($this->userCache[$path]);
unset($this->userCache[$path]);
}
}
@ -287,16 +286,15 @@ class CustomPropertiesBackend implements BackendInterface {
*
*
* @param string $source
* @param string $source
* @param string $destination
* @param string $destination
*
* @return void
*/
*/
public function move($source, $destination) {
#[Override]
$statement = $this->connection->prepare(
public function move($source, $destination): void {
'UPDATE `*PREFIX*properties` SET `propertypath` = ?'
$qb = $this->connection->getQueryBuilder();
. ' WHERE `userid` = ? AND `propertypath` = ?'
$qb->update('properties')
);
->set('propertypath', $qb->createNamedParameter($this->formatPath($destination)))
$statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID())))
$statement->closeCursor();
->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($source))));
$qb->executeStatement();
}
}
/**
/**
@ -325,10 +323,10 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
/**
/**
* @param string $path
* @param string[] $requestedProperties
* @param string[] $requestedProperties
*
*
* @return array
* @return array< string , mixed | Complex | Href | string >
* @throws \OCP\DB\Exception
*/
*/
private function getPublishedProperties(string $path, array $requestedProperties): array {
private function getPublishedProperties(string $path, array $requestedProperties): array {
$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
$allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
@ -356,7 +354,7 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
/**
/**
* p refetch all user properties in a directory
* P refetch all user properties in a directory
*/
*/
private function cacheDirectory(string $path, Directory $node): void {
private function cacheDirectory(string $path, Directory $node): void {
$prefix = ltrim($path . '/', '/');
$prefix = ltrim($path . '/', '/');
@ -449,45 +447,44 @@ class CustomPropertiesBackend implements BackendInterface {
/**
/**
* Returns a list of properties for the given path and current user
* Returns a list of properties for the given path and current user
*
*
* @param string $path
* @param array $requestedProperties requested properties or empty array for "all"
* @param array $requestedProperties requested properties or empty array for "all"
* @return array
* @return array< string , mixed >
* @note The properties list is a list of propertynames the client
* @note The properties list is a list of propertynames the client
* requested, encoded as xmlnamespace#tagName, for example:
* requested, encoded as xmlnamespace#tagName, for example:
* http://www.example.org/namespace#author If the array is empty, all
* http://www.example.org/namespace#author If the array is empty, all
* properties should be returned
* properties should be returned
*/
*/
private function getUserProperties(string $path, array $requestedProperties) {
private function getUserProperties(string $path, array $requestedProperties): array {
if (isset($this->userCache[$path])) {
if (isset($this->userCache[$path])) {
return $this->userCache[$path];
return $this->userCache[$path];
}
}
// TODO: chunking if more than 1000 properties
$props = [];
$sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
$whereValues = [$this->user->getUID(), $this->formatPath($path)];
$qb = $this->connection->getQueryBuilder();
$whereTypes = [null, null];
$qb->select('*')
->from('properties')
->where($qb->expr()->eq('userid', $qb->createNamedParameter($this->user->getUID(), IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->eq('propertypath', $qb->createNamedParameter($this->formatPath($path), IQueryBuilder::PARAM_STR)));
if (!empty($requestedProperties)) {
if (!empty($requestedProperties)) {
// request only a subset
// request only a subset
$sql .= ' AND `propertyname` in (?)' ;
$qb->andWhere($qb->expr()->in('propertyname', $qb->createParameter('requestedProperties'))) ;
$whereValues[] = $requestedProperties ;
$chunks = array_chunk($requestedProperties, 1000) ;
$whereTypes[] = IQueryBuilder::PARAM_STR_ARRAY;
foreach ($chunks as $chunk) {
}
$qb->setParameter('requestedProperties', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
$result = $qb->executeQuery();
$result = $this->connection->executeQuery(
while ($row = $result->fetch()) {
$sql,
$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
$whereValues,
}
$whereTypes
}
);
} else {
$result = $qb->executeQuery();
$props = [];
while ($row = $result->fetch()) {
while ($row = $result->fetch()) {
$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
$props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
}
}
}
$result->closeCursor();
$this->userCache[$path] = $props;
$this->userCache[$path] = $props;
return $props;
return $props;
}
}
@ -501,6 +498,7 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
/**
/**
* @param array< string , string > $properties
* @throws Exception
* @throws Exception
*/
*/
private function updateProperties(string $path, array $properties): bool {
private function updateProperties(string $path, array $properties): bool {
@ -558,10 +556,7 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
/**
/**
* long paths are hashed to ensure they fit in the database
* Long paths are hashed to ensure they fit in the database
*
* @param string $path
* @return string
*/
*/
private function formatPath(string $path): string {
private function formatPath(string $path): string {
if (strlen($path) > 250) {
if (strlen($path) > 250) {
@ -616,20 +611,15 @@ class CustomPropertiesBackend implements BackendInterface {
/**
/**
* @return mixed|Complex|string
* @return mixed|Complex|string
*/
*/
private function decodeValueFromDatabase(string $value, int $valueType) {
private function decodeValueFromDatabase(string $value, int $valueType): mixed {
switch ($valueType) {
return match ($valueType) {
case self::PROPERTY_TYPE_XML:
self::PROPERTY_TYPE_XML => new Complex($value),
return new Complex($value);
self::PROPERTY_TYPE_HREF => new Href($value),
case self::PROPERTY_TYPE_HREF:
// some databases can not handel null characters, these are custom encoded during serialization
return new Href($value);
// this custom encoding needs to be first reversed before unserializing
case self::PROPERTY_TYPE_OBJECT:
self::PROPERTY_TYPE_OBJECT => unserialize(str_replace('\x00', chr(0), $value)),
// some databases can not handel null characters, these are custom encoded during serialization
default => $value,
// this custom encoding needs to be first reversed before unserializing
};
return unserialize(str_replace('\x00', chr(0), $value));
case self::PROPERTY_TYPE_STRING:
default:
return $value;
}
}
}
private function encodeDefaultCalendarUrl(Href $value): Href {
private function encodeDefaultCalendarUrl(Href $value): Href {