Compare commits

...

35 Commits

Author SHA1 Message Date
Kostiantyn Miakshyn 9928813a72 feat(files_sharing): Improve user experience when user attempts to download folder with non-downloadable files (optimized approach using generators)
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
2026-02-28 00:28:40 +01:00
Kostiantyn Miakshyn 6b6214a045 feat(files_sharing): Improve user experience when user attempts to download folder with non-downloadable files
Signed-off-by: Kostiantyn Miakshyn <molodchick@gmail.com>
2026-02-27 19:31:46 +01:00
Marcel Müller 00af1217ff Merge pull request #58601 from nextcloud/fix/noid/default-loglevel-with-match-condition
fix: Use configured loglevel even when log.condition matches is set
2026-02-27 16:00:49 +01:00
Ferdinand Thiessen 694e2312fd Merge pull request #58589 from nextcloud/fix/external-missing
fix(files_external): boolean comparison of array
2026-02-27 15:40:14 +01:00
Ferdinand Thiessen 69a5db46bd fix: code style
Co-authored-by: Carl Schwan <carl@carlschwan.eu>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-02-27 14:31:42 +01:00
Kent Delante d6eade0119 Merge pull request #58582 from nextcloud/leftybournes/fix/files_external_delete_objects
fix: pass only object key to deleteObjects call
2026-02-27 11:28:19 +08:00
Kent Delante 8d1cb50048 fix: pass only object key to deleteObjects call
Some S3-compatible object storage hosts don't like the ETag being included in
the request and return a MalformedXML response. In the AWS API documentation,
only the object key is required so just pass that in.

Signed-off-by: Kent Delante <kent.delante@proton.me>
2026-02-27 10:54:23 +08:00
Nextcloud bot b4b328cf61 fix(l10n): Update translations from Transifex
Signed-off-by: Nextcloud bot <bot@nextcloud.com>
2026-02-27 00:19:48 +00:00
Carl Schwan e47195a334 Merge pull request #58597 from nextcloud/bugfix/58594/files_sharing-disabled
fix(repair): Fix repair steps when files_sharing is disabled
2026-02-27 00:35:03 +01:00
Marcel Müller a56607e91f fix: Use configured loglevel even when log.condition matches is set
Signed-off-by: Marcel Müller <marcel-mueller@gmx.de>
2026-02-26 23:12:20 +01:00
github-actions[bot] 4d00f49757 Merge pull request #58556 from nextcloud/dependabot/npm_and_yarn/build/frontend-legacy/multi-5543462fab
chore(deps): Bump bn.js in /build/frontend-legacy
2026-02-26 21:58:10 +01:00
Andy Scherzinger 83d795dd18 Merge pull request #58595 from nextcloud/fix/db-occ-pending-migrations-typo
fix(db): pending migrations in `occ migrations:status`
2026-02-26 21:56:27 +01:00
Joas Schilling a6b9483a5f fix(repair): Fix repair steps when files_sharing is disabled
Signed-off-by: Joas Schilling <coding@schilljs.com>
2026-02-26 21:51:13 +01:00
Josh 636345bac8 fix(db): pending migrations in occ migrations:status
Fixes #58569

Signed-off-by: Josh <josh.t.richards@gmail.com>
2026-02-26 12:38:00 -05:00
Benjamin Gaussorgues 79d4953e64 Merge pull request #58535 from nextcloud/fix/do-not-send-headers-on-cli 2026-02-26 16:20:52 +01:00
Sebastian Krupinski ca8050b94e Merge pull request #58008 from nextcloud/feat/calendar-federation-readwrite
feat: calendar read and write federation
2026-02-26 09:01:25 -05:00
Carl Schwan 47b08a07d8 Merge pull request #58586 from nextcloud/carl/sharehelper-typing
refactor(typing): Correct typing of IShareHelper
2026-02-26 14:21:53 +01:00
Ferdinand Thiessen d50fbfe37d fix(files_external): boolean comparison of array
To check if there are no missing required dependencies we need to check
if the required dependencies are **empty** because `!array` is still
true.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2026-02-26 14:21:50 +01:00
Kate 77c070bc93 Merge pull request #58559 from nextcloud/fix/itypedquerybuilder/chained-calls 2026-02-26 13:44:40 +01:00
Kent Delante 09c9241b30 Merge pull request #58042 from nextcloud/feat/clear_password
feat(occ): allow admins to clear account passwords
2026-02-26 20:29:26 +08:00
Kent Delante 711bd2bc6d feat(occ): allow admins to clear account passwords
Signed-off-by: Kent Delante <kent.delante@proton.me>
2026-02-26 19:36:22 +08:00
Carl Schwan 2a81cba978 refactor(typing): Correct typing of IShareHelper
Signed-off-by: Carl Schwan <carlschwan@kde.org>
2026-02-26 12:22:44 +01:00
Andy Scherzinger 6df490942c Merge pull request #58525 from nextcloud/fix/fix-decryption-failure-false-positive
fix(encryption): Improve type strictness on decryption check
2026-02-26 12:10:01 +01:00
Andy Scherzinger 0b8e7bb4f0 Merge pull request #58205 from nextcloud/bug-show-configuration-options-for-again
fix: show configuration options for external storage backends
2026-02-26 11:56:41 +01:00
Salvatore Martire 7e264ba58e Merge pull request #58571 from nextcloud/fix/regional-languages
fix(L10N): stop stripping _ from language codes
2026-02-25 16:54:18 +01:00
SebastianKrupinski 64f319ab4e feat: calendar federation write
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
2026-02-25 10:35:27 -05:00
Salvatore Martire ec0ed788fa fix(L10N): stop stripping _ from language codes
Stripping the underscore breaks support for all languages like de_AT,
de_DE and so on...

Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
2026-02-25 15:28:34 +01:00
Daniel Kesselberg 67d1fac6f6 fix: show configuration options for external storage backends
The occ files_external_backends command is supposed to list available backends along with their configuration options.

For the SMB backend, only the timeout option is currently shown, while options like host, share, root, and domain are missing. This makes configuring external storage via occ complicated.

Since the timeout option is marked as hidden but still shown, while the other options are not, the logic needs to be inverted so that all relevant configuration options are displayed correctly.

Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
2026-02-25 15:24:20 +01:00
Kate af98eed523 Merge pull request #58548 from nextcloud/artonge/feat/ai_pr_template 2026-02-25 11:52:12 +01:00
Louis Chmn bfac9e7023 feat: Add AI checkbox to pull request template
Request committers to be transparent with their usage of AI.

Signed-off-by: Louis <louis@chmn.me>
Signed-off-by: Louis Chmn <louis@chmn.me>
2026-02-25 10:41:38 +01:00
provokateurin 237d5156b6 fix(ITypedQueryBuilder): Add correct return type and add note about Psalm bug
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-02-25 09:52:48 +01:00
provokateurin 40c39270c0 fix((ITypedQueryBuilder): Fix chained calls of non-select methods
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-02-25 09:51:59 +01:00
dependabot[bot] f5b18dd7fd chore(deps): Bump bn.js in /build/frontend-legacy
Bumps  and [bn.js](https://github.com/indutny/bn.js). These dependencies needed to be updated together.

Updates `bn.js` from 5.2.2 to 5.2.3
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v5.2.2...v5.2.3)

Updates `bn.js` from 4.12.2 to 4.12.3
- [Release notes](https://github.com/indutny/bn.js/releases)
- [Changelog](https://github.com/indutny/bn.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/indutny/bn.js/compare/v5.2.2...v5.2.3)

---
updated-dependencies:
- dependency-name: bn.js
  dependency-version: 5.2.3
  dependency-type: indirect
- dependency-name: bn.js
  dependency-version: 4.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-24 21:02:56 +00:00
Côme Chilliet f885d7292f fix(occ): Do not attempt to send headers on CLI
This avoids errors like 'Cannot modify header information - headers already sent',
 when using --debug-log with occ.

Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2026-02-24 10:22:13 +01:00
Côme Chilliet e4244c5fc8 fix(encryption): Improve type strictness on decryption check
Otherwise decrypting a falsy value like '0' would be seen as a
 decryption failure.

Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2026-02-23 14:49:03 +01:00
44 changed files with 1774 additions and 350 deletions
+4
View File
@@ -23,3 +23,7 @@
- [ ] [Backports requested](https://github.com/nextcloud/backportbot/#usage) where applicable (ex: critical bugfixes)
- [ ] [Labels added](https://github.com/nextcloud/server/labels) where applicable (ex: bug/enhancement, `3. to review`, feature component)
- [ ] [Milestone added](https://github.com/nextcloud/server/milestones) for target branch/version (ex: 32.x for `stable32`)
## AI (if applicable)
- [ ] The content of this PR was partly or fully generated using AI
@@ -77,6 +77,7 @@ return array(
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => $baseDir . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => $baseDir . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
@@ -92,6 +92,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
+2 -4
View File
@@ -43,9 +43,9 @@ class CalendarProvider implements ICalendarProvider {
});
}
$additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
$iCalendars = [];
$additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
foreach ($calendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
@@ -60,9 +60,7 @@ class CalendarProvider implements ICalendarProvider {
);
}
$additionalFederatedProps = $this->getAdditionalPropertiesForCalendars(
$federatedCalendarInfos,
);
$additionalFederatedProps = $this->getAdditionalPropertiesForCalendars($federatedCalendarInfos);
foreach ($federatedCalendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
@@ -104,9 +104,10 @@ class CalendarFederationProvider implements ICloudFederationProvider {
);
}
// TODO: implement read-write sharing
// convert access to permissions
$permissions = match ($access) {
DavSharingBackend::ACCESS_READ => Constants::PERMISSION_READ,
DavSharingBackend::ACCESS_READ_WRITE => Constants::PERMISSION_READ | Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE,
default => throw new ProviderCouldNotAddShareException(
"Unsupported access value: $access",
'',
@@ -122,20 +123,27 @@ class CalendarFederationProvider implements ICloudFederationProvider {
$sharedWithPrincipal = 'principals/users/' . $share->getShareWith();
// Delete existing incoming federated share first
$this->federatedCalendarMapper->deleteByUri($sharedWithPrincipal, $calendarUri);
$calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri);
$calendar = new FederatedCalendarEntity();
$calendar->setPrincipaluri($sharedWithPrincipal);
$calendar->setUri($calendarUri);
$calendar->setRemoteUrl($calendarUrl);
$calendar->setDisplayName($displayName);
$calendar->setColor($color);
$calendar->setToken($share->getShareSecret());
$calendar->setSharedBy($share->getSharedBy());
$calendar->setSharedByDisplayName($share->getSharedByDisplayName());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
$calendar = $this->federatedCalendarMapper->insert($calendar);
if ($calendar === null) {
$calendar = new FederatedCalendarEntity();
$calendar->setPrincipaluri($sharedWithPrincipal);
$calendar->setUri($calendarUri);
$calendar->setRemoteUrl($calendarUrl);
$calendar->setDisplayName($displayName);
$calendar->setColor($color);
$calendar->setToken($share->getShareSecret());
$calendar->setSharedBy($share->getSharedBy());
$calendar->setSharedByDisplayName($share->getSharedByDisplayName());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
$calendar = $this->federatedCalendarMapper->insert($calendar);
} else {
$calendar->setToken($share->getShareSecret());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
$this->federatedCalendarMapper->update($calendar);
}
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
@@ -10,29 +10,289 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCP\IConfig;
use OCP\IL10N;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend;
use OCP\Constants;
use Sabre\CalDAV\ICalendar;
use Sabre\CalDAV\Plugin;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\IMultiGet;
use Sabre\DAV\INode;
use Sabre\DAV\IProperties;
use Sabre\DAV\PropPatch;
class FederatedCalendar implements ICalendar, IProperties, IMultiGet {
private const CALENDAR_TYPE = CalDavBackend::CALENDAR_TYPE_FEDERATED;
private const DAV_PROPERTY_CALENDAR_LABEL = '{DAV:}displayname';
private const DAV_PROPERTY_CALENDAR_COLOR = '{http://apple.com/ns/ical/}calendar-color';
private string $principalUri;
private string $calendarUri;
private ?array $calendarACL = null;
private FederatedCalendarEntity $federationInfo;
class FederatedCalendar extends Calendar {
public function __construct(
Backend\BackendInterface $caldavBackend,
$calendarInfo,
IL10N $l10n,
IConfig $config,
LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly FederatedCalendarSyncService $federatedCalendarService,
private readonly CalDavBackend $caldavBackend,
$calendarInfo,
) {
parent::__construct($caldavBackend, $calendarInfo, $l10n, $config, $logger);
$this->principalUri = $calendarInfo['principaluri'];
$this->calendarUri = $calendarInfo['uri'];
$this->federationInfo = $federatedCalendarMapper->findByUri($this->principalUri, $this->calendarUri);
}
public function delete() {
$this->federatedCalendarMapper->deleteById($this->getResourceId());
public function getResourceId(): int {
return $this->federationInfo->getId();
}
public function getName(): string {
return $this->federationInfo->getUri();
}
/**
* @param string $name Name of the file
*/
public function setName($name): void {
throw new MethodNotAllowed('Renaming federated calendars is not allowed');
}
protected function getCalendarType(): int {
return CalDavBackend::CALENDAR_TYPE_FEDERATED;
return self::CALENDAR_TYPE;
}
public function getPrincipalURI(): string {
return $this->federationInfo->getPrincipaluri();
}
public function getOwner(): ?string {
return $this->federationInfo->getSharedByPrincipal();
}
public function getGroup(): ?string {
return null;
}
/**
* @return array<array-key, mixed>
*/
public function getACL(): array {
if ($this->calendarACL !== null) {
return $this->calendarACL;
}
$permissions = $this->federationInfo->getPermissions();
// default permission
$acl = [
// read object permission
[
'privilege' => '{DAV:}read',
'principal' => $this->principalUri,
'protected' => true,
],
// read acl permission
[
'privilege' => '{DAV:}read-acl',
'principal' => $this->principalUri,
'protected' => true,
],
// write properties permission (calendar name, color)
[
'privilege' => '{DAV:}write-properties',
'principal' => $this->principalUri,
'protected' => true,
],
];
// create permission
if ($permissions & Constants::PERMISSION_CREATE) {
$acl[] = [
'privilege' => '{DAV:}bind',
'principal' => $this->principalUri,
'protected' => true,
];
}
// update permission
if ($permissions & Constants::PERMISSION_UPDATE) {
$acl[] = [
'privilege' => '{DAV:}write-content',
'principal' => $this->principalUri,
'protected' => true,
];
}
// delete permission
if ($permissions & Constants::PERMISSION_DELETE) {
$acl[] = [
'privilege' => '{DAV:}unbind',
'principal' => $this->principalUri,
'protected' => true,
];
}
// cache the calculated ACL for later use
$this->calendarACL = $acl;
return $acl;
}
public function setACL(array $acl): void {
throw new MethodNotAllowed('Changing ACLs on federated calendars is not allowed');
}
public function getSupportedPrivilegeSet(): ?array {
return null;
}
/**
* @return array<string, mixed> properties array, with property name as key
*/
public function getProperties($properties): array {
return [
self::DAV_PROPERTY_CALENDAR_LABEL => $this->federationInfo->getDisplayName(),
self::DAV_PROPERTY_CALENDAR_COLOR => $this->federationInfo->getColor(),
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(explode(',', $this->federationInfo->getComponents())),
];
}
public function propPatch(PropPatch $propPatch): void {
$mutations = $propPatch->getMutations();
if (count($mutations) > 0) {
// evaluate if name was changed
if (isset($mutations[self::DAV_PROPERTY_CALENDAR_LABEL])) {
$this->federationInfo->setDisplayName($mutations[self::DAV_PROPERTY_CALENDAR_LABEL]);
$propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_LABEL, 200);
}
// evaluate if color was changed
if (isset($mutations[self::DAV_PROPERTY_CALENDAR_COLOR])) {
$this->federationInfo->setColor($mutations[self::DAV_PROPERTY_CALENDAR_COLOR]);
$propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_COLOR, 200);
}
$this->federatedCalendarMapper->update($this->federationInfo);
}
}
public function getChildACL(): array {
return $this->getACL();
}
public function getLastModified(): ?int {
return $this->federationInfo->getLastSync();
}
public function delete(): void {
$this->federatedCalendarMapper->deleteById($this->getResourceId());
}
/**
* @param string $name Name of the file
*/
public function createDirectory($name): void {
throw new MethodNotAllowed('Creating nested collection is not allowed');
}
public function calendarQuery(array $filters): array {
$uris = $this->caldavBackend->calendarQuery($this->federationInfo->getId(), $filters, $this->getCalendarType());
return $uris;
}
/**
* @param string $name Name of the file
*/
public function getChild($name): INode {
$obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
if ($obj === null) {
throw new NotFound('Calendar object not found');
}
return new FederatedCalendarObject($this, $obj);
}
/**
* @return array<INode>
*/
public function getChildren(): array {
$objs = $this->caldavBackend->getCalendarObjects($this->federationInfo->getId(), $this->getCalendarType());
$children = [];
foreach ($objs as $obj) {
$children[] = new FederatedCalendarObject($this, $obj);
}
return $children;
}
/**
* @param array<string> $paths Names of the files
*
* @return array<INode>
*/
public function getMultipleChildren(array $paths): array {
$objs = $this->caldavBackend->getMultipleCalendarObjects($this->federationInfo->getId(), $paths, $this->getCalendarType());
$children = [];
foreach ($objs as $obj) {
$children[] = new FederatedCalendarObject($this, $obj);
}
return $children;
}
/**
* @param string $name Name of the file
*/
public function childExists($name): bool {
$obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
return $obj !== null;
}
/**
* @param string $name Name of the file
* @param resource|string $data Initial payload
*/
public function createFile($name, $data = null): string {
if (is_resource($data)) {
$data = stream_get_contents($data);
}
// Create on remote server first
$etag = $this->federatedCalendarService->createCalendarObject($this->federationInfo, $name, $data);
if (empty($etag)) {
throw new \Exception('Failed to create calendar object on remote server');
}
// Then store locally
return $this->caldavBackend->createCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType());
}
/**
* @param string $name Name of the file
* @param resource|string $data Initial payload
*/
public function updateFile($name, $data): string {
if (is_resource($data)) {
$data = stream_get_contents($data);
}
// Update remote calendar first
$etag = $this->federatedCalendarService->updateCalendarObject($this->federationInfo, $name, $data);
if (empty($etag)) {
throw new \Exception('Failed to update calendar object on remote server');
}
// Then update locally
return $this->caldavBackend->updateCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType());
}
public function deleteFile(string $name): void {
// Delete from remote server first
$this->federatedCalendarService->deleteCalendarObject($this->federationInfo, $name);
// Then delete locally
$this->caldavBackend->deleteCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
}
}
@@ -94,8 +94,8 @@ class FederatedCalendarEntity extends Entity {
'{' . \Sabre\CalDAV\Plugin::NS_CALENDARSERVER . '}getctag' => $this->getSyncTokenForSabre(),
'{' . \Sabre\CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => $this->getSupportedCalendarComponentSet(),
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->getSharedByPrincipal(),
// TODO: implement read-write sharing
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => 1
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => ($this->getPermissions() & \OCP\Constants::PERMISSION_UPDATE) === 0 ? 1 : 0,
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}permissions' => $this->getPermissions(),
];
}
}
@@ -9,34 +9,23 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\IConfig;
use OCP\IL10N;
use OCP\L10N\IFactory as IL10NFactory;
use Psr\Log\LoggerInterface;
class FederatedCalendarFactory {
private readonly IL10N $l10n;
public function __construct(
private readonly CalDavBackend $caldavBackend,
private readonly IConfig $config,
private readonly LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
IL10NFactory $l10nFactory,
private readonly FederatedCalendarSyncService $federatedCalendarService,
private readonly CalDavBackend $caldavBackend,
) {
$this->l10n = $l10nFactory->get(Application::APP_ID);
}
public function createFederatedCalendar(array $calendarInfo): FederatedCalendar {
return new FederatedCalendar(
$this->federatedCalendarMapper,
$this->federatedCalendarService,
$this->caldavBackend,
$calendarInfo,
$this->l10n,
$this->config,
$this->logger,
$this->federatedCalendarMapper,
);
}
}
@@ -51,8 +51,7 @@ class FederatedCalendarImpl implements ICalendar, ICalendarIsShared, ICalendarIs
}
public function getPermissions(): int {
// TODO: implement read-write sharing
return Constants::PERMISSION_READ;
return $this->calendarInfo['{http://owncloud.org/ns}permissions'] ?? Constants::PERMISSION_READ;
}
public function isDeleted(): bool {
@@ -64,7 +63,8 @@ class FederatedCalendarImpl implements ICalendar, ICalendarIsShared, ICalendarIs
}
public function isWritable(): bool {
return false;
$permissions = $this->getPermissions();
return ($permissions & Constants::PERMISSION_UPDATE) !== 0;
}
public function isEnabled(): bool {
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use Sabre\CalDAV\ICalendarObject;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAVACL\IACL;
class FederatedCalendarObject implements ICalendarObject, IACL {
public function __construct(
protected FederatedCalendar $calendarObject,
protected $objectData,
) {
}
public function getName(): string {
return $this->objectData['uri'];
}
/**
* @param string $name Name of the file
*/
public function setName($name) {
throw new \Exception('Not implemented');
}
public function get(): string {
return $this->objectData['calendardata'];
}
/**
* @param resource|string $data contents of the file
*/
public function put($data): string {
$etag = $this->calendarObject->updateFile($this->objectData['uri'], $data);
$this->objectData['calendardata'] = $data;
$this->objectData['etag'] = $etag;
return $etag;
}
public function delete(): void {
$this->calendarObject->deleteFile($this->objectData['uri']);
}
public function getContentType(): ?string {
$mime = 'text/calendar; charset=utf-8';
if (isset($this->objectData['component']) && $this->objectData['component']) {
$mime .= '; component=' . $this->objectData['component'];
}
return $mime;
}
public function getETag(): string {
if (isset($this->objectData['etag'])) {
return $this->objectData['etag'];
} else {
return '"' . md5($this->get()) . '"';
}
}
public function getLastModified(): int {
return $this->objectData['lastmodified'];
}
public function getSize(): int {
if (isset($this->objectData['size'])) {
return $this->objectData['size'];
} else {
return strlen($this->get());
}
}
public function getOwner(): ?string {
return $this->calendarObject->getPrincipalURI();
}
public function getGroup(): ?string {
return null;
}
/**
* @return array<array-key, mixed>
*/
public function getACL(): array {
return $this->calendarObject->getACL();
}
public function setACL(array $acl): void {
throw new MethodNotAllowed('Changing ACLs on federated events is not allowed');
}
public function getSupportedPrivilegeSet(): ?array {
return null;
}
}
@@ -9,20 +9,52 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\Service\ASyncService;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Http;
use OCP\Federation\ICloudIdManager;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IDBConnection;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
class FederatedCalendarSyncService {
class FederatedCalendarSyncService extends ASyncService {
use TTransactional;
private const SYNC_TOKEN_PREFIX = 'http://sabre.io/ns/sync/';
public function __construct(
IClientService $clientService,
IConfig $config,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly LoggerInterface $logger,
private readonly CalDavSyncService $syncService,
private readonly CalDavBackend $backend,
private readonly IDBConnection $dbConnection,
private readonly ICloudIdManager $cloudIdManager,
) {
parent::__construct($clientService, $config);
}
/**
* Extract and encode credentials from a federated calendar entity.
*
* @return array{username: string, remoteUrl: string, token: string}
*/
private function getCredentials(FederatedCalendarEntity $calendar): array {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
// Need to encode the cloud id as it might contain a colon which is not allowed in basic
// auth according to RFC 7617
$calDavUser = base64_encode($calDavUser);
return [
'username' => $calDavUser,
'remoteUrl' => $calendar->getRemoteUrl(),
'token' => $calendar->getToken(),
];
}
/**
@@ -31,29 +63,77 @@ class FederatedCalendarSyncService {
* @throws ClientExceptionInterface If syncing the calendar fails.
*/
public function syncOne(FederatedCalendarEntity $calendar): int {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
$remoteUrl = $calendar->getRemoteUrl();
$credentials = $this->getCredentials($calendar);
$syncToken = $calendar->getSyncTokenForSabre();
// Need to encode the cloud id as it might contain a colon which is not allowed in basic
// auth according to RFC 7617
$calDavUser = base64_encode($calDavUser);
try {
$response = $this->requestSyncReport(
$credentials['remoteUrl'],
$credentials['username'],
$credentials['token'],
$syncToken,
);
} catch (ClientExceptionInterface $ex) {
if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) {
// Remote server revoked access to the calendar => remove it
$this->federatedCalendarMapper->delete($calendar);
$this->logger->warning("Authorization failed, remove federated calendar: {$credentials['remoteUrl']}", [
'app' => 'dav',
]);
return 0;
}
$this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]);
throw $ex;
}
$syncResponse = $this->syncService->syncRemoteCalendar(
$remoteUrl,
$calDavUser,
$calendar->getToken(),
$syncToken,
$calendar,
);
// Process changes from remote
$downloadedEvents = 0;
foreach ($response['response'] as $resource => $status) {
$objectUri = basename($resource);
if (isset($status[200])) {
// Object created or updated
$absoluteUrl = $this->prepareUri($credentials['remoteUrl'], $resource);
$calendarData = $this->download($absoluteUrl, $credentials['username'], $credentials['token']);
$this->atomic(function () use ($calendar, $objectUri, $calendarData): void {
$existingObject = $this->backend->getCalendarObject(
$calendar->getId(),
$objectUri,
CalDavBackend::CALENDAR_TYPE_FEDERATED
);
if (!$existingObject) {
$this->backend->createCalendarObject(
$calendar->getId(),
$objectUri,
$calendarData,
CalDavBackend::CALENDAR_TYPE_FEDERATED
);
} else {
$this->backend->updateCalendarObject(
$calendar->getId(),
$objectUri,
$calendarData,
CalDavBackend::CALENDAR_TYPE_FEDERATED
);
}
}, $this->dbConnection);
$downloadedEvents++;
} else {
// Object deleted
$this->backend->deleteCalendarObject(
$calendar->getId(),
$objectUri,
CalDavBackend::CALENDAR_TYPE_FEDERATED,
true
);
}
}
$newSyncToken = $syncResponse->getSyncToken();
$newSyncToken = $response['token'];
// Check sync token format and extract the actual sync token integer
$matches = [];
if (!preg_match('/^http:\/\/sabre\.io\/ns\/sync\/([0-9]+)$/', $newSyncToken, $matches)) {
$this->logger->error("Failed to sync federated calendar at $remoteUrl: New sync token has unexpected format: $newSyncToken", [
$this->logger->error("Failed to sync federated calendar at {$credentials['remoteUrl']}: New sync token has unexpected format: $newSyncToken", [
'calendar' => $calendar->toCalendarInfo(),
'newSyncToken' => $newSyncToken,
]);
@@ -67,10 +147,58 @@ class FederatedCalendarSyncService {
$newSyncToken,
);
} else {
$this->logger->debug("Sync Token for $remoteUrl unchanged from previous sync");
$this->logger->debug("Sync Token for {$credentials['remoteUrl']} unchanged from previous sync");
$this->federatedCalendarMapper->updateSyncTime($calendar->getId());
}
return $syncResponse->getDownloadedEvents();
return $downloadedEvents;
}
/**
* Create a calendar object on the remote server.
*
* @throws ClientExceptionInterface If the remote request fails.
*/
public function createCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string {
$credentials = $this->getCredentials($calendar);
$objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
return $this->requestPut(
$objectUrl,
$credentials['username'],
$credentials['token'],
$data,
'text/calendar; charset=utf-8'
);
}
/**
* Update a calendar object on the remote server.
*
* @throws ClientExceptionInterface If the remote request fails.
*/
public function updateCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string {
$credentials = $this->getCredentials($calendar);
$objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
return $this->requestPut(
$objectUrl,
$credentials['username'],
$credentials['token'],
$data,
'text/calendar; charset=utf-8'
);
}
/**
* Delete a calendar object on the remote server.
*
* @throws ClientExceptionInterface If the remote request fails.
*/
public function deleteCalendarObject(FederatedCalendarEntity $calendar, string $name): void {
$credentials = $this->getCredentials($calendar);
$objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
$this->requestDelete($objectUrl, $credentials['username'], $credentials['token']);
}
}
+9 -1
View File
@@ -12,6 +12,7 @@ use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\CalendarHome;
use OCA\DAV\CalDAV\CalendarObject;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
use OCA\DAV\CalDAV\Federation\FederatedCalendar;
use OCA\DAV\CalDAV\TipBroker;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
@@ -173,8 +174,15 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
return;
}
/** @var Calendar $calendarNode */
/** @var Calendar&ICalendar $calendarNode */
$calendarNode = $this->server->tree->getNodeForPath($calendarPath);
// abort if calendar is federated
if ($calendarNode instanceof FederatedCalendar) {
$this->logger->debug('Not processing scheduling for federated calendar at path: ' . $calendarPath);
return;
}
// extract addresses for owner
$addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
// determine if request is from a sharee
@@ -61,9 +61,31 @@ class ZipFolderPlugin extends ServerPlugin {
$this->server->on('afterMethod:GET', $this->afterDownload(...), 999);
}
/**
* @return iterable<NcNode>
*/
protected function createIterator(array $rootNodes): iterable {
foreach ($rootNodes as $rootNode) {
yield from $this->iterateNodes($rootNode);
}
}
/**
* Recursively iterate over all nodes in a folder.
* @return iterable<NcNode>
*/
protected function iterateNodes(NcNode $node): iterable {
yield $node;
if ($node instanceof NcFolder) {
foreach ($node->getDirectoryListing() as $childNode) {
yield from $this->iterateNodes($childNode);
}
}
}
/**
* Adding a node to the archive streamer.
* This will recursively add new nodes to the stream if the node is a directory.
*/
protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void {
// Remove the root path from the filename to make it relative to the requested folder
@@ -79,10 +101,6 @@ class ZipFolderPlugin extends ServerPlugin {
$streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime);
} elseif ($node instanceof NcFolder) {
$streamer->addEmptyDir($filename, $mtime);
$content = $node->getDirectoryListing();
foreach ($content as $subNode) {
$this->streamNode($streamer, $subNode, $rootPath);
}
}
}
@@ -137,7 +155,14 @@ class ZipFolderPlugin extends ServerPlugin {
}
$folder = $node->getNode();
$event = new BeforeZipCreatedEvent($folder, $files);
$rootNodes = empty($files) ? $folder->getDirectoryListing() : [];
foreach ($files as $path) {
$child = $node->getChild($path);
assert($child instanceof Node);
$rootNodes[] = $child->getNode();
}
$event = new BeforeZipCreatedEvent($folder, $files, $this->createIterator($rootNodes));
$this->eventDispatcher->dispatchTyped($event);
if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) {
$errorMessage = $event->getErrorMessage();
@@ -150,13 +175,6 @@ class ZipFolderPlugin extends ServerPlugin {
throw new Forbidden($errorMessage);
}
$content = empty($files) ? $folder->getDirectoryListing() : [];
foreach ($files as $path) {
$child = $node->getChild($path);
assert($child instanceof Node);
$content[] = $child->getNode();
}
$archiveName = $folder->getName();
if (count(explode('/', trim($folder->getPath(), '/'), 3)) === 2) {
// this is a download of the root folder
@@ -169,13 +187,13 @@ class ZipFolderPlugin extends ServerPlugin {
$rootPath = dirname($folder->getPath());
}
$streamer = new Streamer($tarRequest, -1, count($content), $this->timezoneFactory);
$streamer = new Streamer($tarRequest, -1, count($rootNodes), $this->timezoneFactory);
$streamer->sendHeaders($archiveName);
// For full folder downloads we also add the folder itself to the archive
if (empty($files)) {
$streamer->addEmptyDir($archiveName);
}
foreach ($content as $node) {
foreach ($event->getNodes() as $node) {
$this->streamNode($streamer, $node, $rootPath);
}
$streamer->finalize();
+66
View File
@@ -191,4 +191,70 @@ abstract class ASyncService {
rtrim($responseUri, '/'),
);
}
/**
* Push data to the remote server via HTTP PUT.
* Used for creating or updating CalDAV/CardDAV objects.
*
* @param string $url The absolute URL to PUT to
* @param string $username The username for authentication
* @param string $token The authentication token/password
* @param string $data The data to upload
* @param string $contentType The Content-Type header (e.g., 'text/calendar' or 'text/vcard')
*
* @return string The ETag returned by the server
*/
protected function requestPut(
string $url,
string $username,
string $token,
string $data,
string $contentType = 'text/calendar; charset=utf-8',
): string {
$client = $this->getClient();
$options = [
'auth' => [$username, $token],
'body' => $data,
'headers' => [
'Content-Type' => $contentType,
],
'verify' => !$this->config->getSystemValue(
'sharing.federation.allowSelfSignedCertificates',
false,
),
];
$response = $client->put($url, $options);
// Extract and return the ETag from the response
$etag = $response->getHeader('ETag');
return $etag;
}
/**
* Delete a resource from the remote server via HTTP DELETE.
* Used for deleting CalDAV/CardDAV objects.
*
* @param string $url The absolute URL to DELETE
* @param string $username The username for authentication
* @param string $token The authentication token/password
*/
protected function requestDelete(
string $url,
string $username,
string $token,
): void {
$client = $this->getClient();
$options = [
'auth' => [$username, $token],
'verify' => !$this->config->getSystemValue(
'sharing.federation.allowSelfSignedCertificates',
false,
),
];
$client->delete($url, $options);
}
}
@@ -92,11 +92,12 @@ class CalendarFederationProviderTest extends TestCase {
->willReturn(true);
$this->federatedCalendarMapper->expects(self::once())
->method('deleteByUri')
->method('findByUri')
->with(
'principals/users/sharee1',
'ae4b8ab904076fff2b955ea21b1a0d92',
);
)
->willReturn(null);
$this->federatedCalendarMapper->expects(self::once())
->method('insert')
@@ -123,6 +124,68 @@ class CalendarFederationProviderTest extends TestCase {
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithExistingCalendar(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'version' => 'v1',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('new-token');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$existingCalendar = new FederatedCalendarEntity();
$existingCalendar->setId(10);
$existingCalendar->setPrincipaluri('principals/users/sharee1');
$existingCalendar->setUri('ae4b8ab904076fff2b955ea21b1a0d92');
$existingCalendar->setRemoteUrl('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1');
$existingCalendar->setToken('old-token');
$existingCalendar->setPermissions(1);
$existingCalendar->setComponents('VEVENT');
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->with(
'principals/users/sharee1',
'ae4b8ab904076fff2b955ea21b1a0d92',
)
->willReturn($existingCalendar);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->federatedCalendarMapper->expects(self::once())
->method('update')
->willReturnCallback(function (FederatedCalendarEntity $calendar) {
$this->assertEquals('new-token', $calendar->getToken());
$this->assertEquals(1, $calendar->getPermissions());
$this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
return $calendar;
});
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithInvalidProtocolVersion(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
@@ -270,7 +333,7 @@ class CalendarFederationProviderTest extends TestCase {
$this->calendarFederationProvider->shareReceived($share);
}
public function testShareReceivedWithUnsupportedAccess(): void {
public function testShareReceivedWithReadWriteAccess(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
@@ -296,6 +359,65 @@ class CalendarFederationProviderTest extends TestCase {
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->with(
'principals/users/sharee1',
'ae4b8ab904076fff2b955ea21b1a0d92',
)
->willReturn(null);
$this->federatedCalendarMapper->expects(self::once())
->method('insert')
->willReturnCallback(function (FederatedCalendarEntity $calendar) {
$this->assertEquals('principals/users/sharee1', $calendar->getPrincipaluri());
$this->assertEquals('ae4b8ab904076fff2b955ea21b1a0d92', $calendar->getUri());
$this->assertEquals('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', $calendar->getRemoteUrl());
$this->assertEquals('Calendar 1', $calendar->getDisplayName());
$this->assertEquals('#ff0000', $calendar->getColor());
$this->assertEquals('token', $calendar->getToken());
$this->assertEquals('user1@nextcloud.remote', $calendar->getSharedBy());
$this->assertEquals('User 1', $calendar->getSharedByDisplayName());
$this->assertEquals(15, $calendar->getPermissions()); // READ | CREATE | UPDATE | DELETE
$this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
$calendar->setId(10);
return $calendar;
});
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithUnsupportedAccess(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'version' => 'v1',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 999, // Invalid access value
'components' => 'VEVENT,VTODO',
]);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('token');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
@@ -9,13 +9,17 @@ declare(strict_types=1);
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
use OCA\DAV\CalDAV\SyncServiceResult;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IConfig;
use OCP\IDBConnection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
@@ -26,21 +30,30 @@ class FederatedCalendarSyncServiceTest extends TestCase {
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private LoggerInterface&MockObject $logger;
private CalDavSyncService&MockObject $calDavSyncService;
private CalDavBackend&MockObject $backend;
private IDBConnection&MockObject $dbConnection;
private ICloudIdManager&MockObject $cloudIdManager;
private IClientService&MockObject $clientService;
private IConfig&MockObject $config;
protected function setUp(): void {
parent::setUp();
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->calDavSyncService = $this->createMock(CalDavSyncService::class);
$this->backend = $this->createMock(CalDavBackend::class);
$this->dbConnection = $this->createMock(IDBConnection::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->clientService = $this->createMock(IClientService::class);
$this->config = $this->createMock(IConfig::class);
$this->federatedCalendarSyncService = new FederatedCalendarSyncService(
$this->clientService,
$this->config,
$this->federatedCalendarMapper,
$this->logger,
$this->calDavSyncService,
$this->backend,
$this->dbConnection,
$this->cloudIdManager,
);
}
@@ -61,16 +74,24 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/101', 10));
// Mock HTTP client for sync report
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$response->method('getBody')
->willReturn('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>http://sabre.io/ns/sync/101</d:sync-token></d:multistatus>');
$client->expects(self::once())
->method('request')
->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
->willReturn($response);
$this->clientService->method('newClient')
->willReturn($client);
$this->config->method('getSystemValueInt')
->willReturn(30);
$this->config->method('getSystemValue')
->willReturn(false);
$this->federatedCalendarMapper->expects(self::once())
->method('updateSyncTokenAndTime')
@@ -78,7 +99,7 @@ class FederatedCalendarSyncServiceTest extends TestCase {
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTime');
$this->assertEquals(10, $this->federatedCalendarSyncService->syncOne($calendar));
$this->assertEquals(0, $this->federatedCalendarSyncService->syncOne($calendar));
}
public function testSyncOneUnchanged(): void {
@@ -97,16 +118,24 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/100', 0));
// Mock HTTP client for sync report
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$response->method('getBody')
->willReturn('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>http://sabre.io/ns/sync/100</d:sync-token></d:multistatus>');
$client->expects(self::once())
->method('request')
->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
->willReturn($response);
$this->clientService->method('newClient')
->willReturn($client);
$this->config->method('getSystemValueInt')
->willReturn(30);
$this->config->method('getSystemValue')
->willReturn(false);
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
@@ -143,16 +172,24 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult($syncToken, 10));
// Mock HTTP client for sync report with unexpected token format
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$response->method('getBody')
->willReturn('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>' . $syncToken . '</d:sync-token></d:multistatus>');
$client->expects(self::once())
->method('request')
->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
->willReturn($response);
$this->clientService->method('newClient')
->willReturn($client);
$this->config->method('getSystemValueInt')
->willReturn(30);
$this->config->method('getSystemValue')
->willReturn(false);
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendar;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarObject;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCP\Constants;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropPatch;
use Test\TestCase;
class FederatedCalendarTest extends TestCase {
private FederatedCalendar $federatedCalendar;
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private FederatedCalendarSyncService&MockObject $federatedCalendarService;
private CalDavBackend&MockObject $caldavBackend;
private FederatedCalendarEntity $federationInfo;
protected function setUp(): void {
parent::setUp();
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->federatedCalendarService = $this->createMock(FederatedCalendarSyncService::class);
$this->caldavBackend = $this->createMock(CalDavBackend::class);
$this->federationInfo = new FederatedCalendarEntity();
$this->federationInfo->setId(10);
$this->federationInfo->setPrincipaluri('principals/users/user1');
$this->federationInfo->setUri('calendar-uri');
$this->federationInfo->setDisplayName('Federated Calendar');
$this->federationInfo->setColor('#ff0000');
$this->federationInfo->setSharedBy('user2@nextcloud.remote');
$this->federationInfo->setSharedByDisplayName('User 2');
$this->federationInfo->setPermissions(Constants::PERMISSION_READ);
$this->federationInfo->setLastSync(1234567890);
$this->federatedCalendarMapper->method('findByUri')
->with('principals/users/user1', 'calendar-uri')
->willReturn($this->federationInfo);
$calendarInfo = [
'principaluri' => 'principals/users/user1',
'id' => 10,
'uri' => 'calendar-uri',
];
$this->federatedCalendar = new FederatedCalendar(
$this->federatedCalendarMapper,
$this->federatedCalendarService,
$this->caldavBackend,
$calendarInfo,
);
}
public function testGetResourceId(): void {
$this->assertEquals(10, $this->federatedCalendar->getResourceId());
}
public function testGetName(): void {
$this->assertEquals('calendar-uri', $this->federatedCalendar->getName());
}
public function testSetName(): void {
$this->expectException(MethodNotAllowed::class);
$this->expectExceptionMessage('Renaming federated calendars is not allowed');
$this->federatedCalendar->setName('new-name');
}
public function testGetPrincipalURI(): void {
$this->assertEquals('principals/users/user1', $this->federatedCalendar->getPrincipalURI());
}
public function testGetOwner(): void {
$expected = 'principals/remote-users/' . base64_encode('user2@nextcloud.remote');
$this->assertEquals($expected, $this->federatedCalendar->getOwner());
}
public function testGetGroup(): void {
$this->assertNull($this->federatedCalendar->getGroup());
}
public function testGetACLWithReadOnlyPermissions(): void {
$this->federationInfo->setPermissions(Constants::PERMISSION_READ);
$acl = $this->federatedCalendar->getACL();
$this->assertCount(3, $acl);
// Check basic read permissions
$this->assertEquals('{DAV:}read', $acl[0]['privilege']);
$this->assertTrue($acl[0]['protected']);
$this->assertEquals('{DAV:}read-acl', $acl[1]['privilege']);
$this->assertTrue($acl[1]['protected']);
$this->assertEquals('{DAV:}write-properties', $acl[2]['privilege']);
$this->assertTrue($acl[2]['protected']);
}
public function testGetACLWithCreatePermission(): void {
$this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_CREATE);
$acl = $this->federatedCalendar->getACL();
$this->assertCount(4, $acl);
// Check that create permission is added
$privileges = array_column($acl, 'privilege');
$this->assertContains('{DAV:}bind', $privileges);
}
public function testGetACLWithUpdatePermission(): void {
$this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE);
$acl = $this->federatedCalendar->getACL();
$this->assertCount(4, $acl);
// Check that update permission is added (write-content, not write-properties which is already in base ACL)
$privileges = array_column($acl, 'privilege');
$this->assertContains('{DAV:}write-content', $privileges);
}
public function testGetACLWithDeletePermission(): void {
$this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_DELETE);
$acl = $this->federatedCalendar->getACL();
$this->assertCount(4, $acl);
// Check that delete permission is added
$privileges = array_column($acl, 'privilege');
$this->assertContains('{DAV:}unbind', $privileges);
}
public function testGetACLWithAllPermissions(): void {
$this->federationInfo->setPermissions(
Constants::PERMISSION_READ
| Constants::PERMISSION_CREATE
| Constants::PERMISSION_UPDATE
| Constants::PERMISSION_DELETE
);
$acl = $this->federatedCalendar->getACL();
$this->assertCount(6, $acl);
$privileges = array_column($acl, 'privilege');
$this->assertContains('{DAV:}read', $privileges);
$this->assertContains('{DAV:}bind', $privileges);
$this->assertContains('{DAV:}write-content', $privileges);
$this->assertContains('{DAV:}write-properties', $privileges);
$this->assertContains('{DAV:}unbind', $privileges);
}
public function testSetACL(): void {
$this->expectException(MethodNotAllowed::class);
$this->expectExceptionMessage('Changing ACLs on federated calendars is not allowed');
$this->federatedCalendar->setACL([]);
}
public function testGetSupportedPrivilegeSet(): void {
$this->assertNull($this->federatedCalendar->getSupportedPrivilegeSet());
}
public function testGetProperties(): void {
$properties = $this->federatedCalendar->getProperties([
'{DAV:}displayname',
'{http://apple.com/ns/ical/}calendar-color',
]);
$this->assertEquals('Federated Calendar', $properties['{DAV:}displayname']);
$this->assertEquals('#ff0000', $properties['{http://apple.com/ns/ical/}calendar-color']);
}
public function testPropPatchWithDisplayName(): void {
$propPatch = $this->createMock(PropPatch::class);
$propPatch->method('getMutations')
->willReturn([
'{DAV:}displayname' => 'New Calendar Name',
]);
$this->federatedCalendarMapper->expects(self::once())
->method('update')
->willReturnCallback(function (FederatedCalendarEntity $entity) {
$this->assertEquals('New Calendar Name', $entity->getDisplayName());
return $entity;
});
$propPatch->expects(self::once())
->method('setResultCode')
->with('{DAV:}displayname', 200);
$this->federatedCalendar->propPatch($propPatch);
}
public function testPropPatchWithColor(): void {
$propPatch = $this->createMock(PropPatch::class);
$propPatch->method('getMutations')
->willReturn([
'{http://apple.com/ns/ical/}calendar-color' => '#00ff00',
]);
$this->federatedCalendarMapper->expects(self::once())
->method('update')
->willReturnCallback(function (FederatedCalendarEntity $entity) {
$this->assertEquals('#00ff00', $entity->getColor());
return $entity;
});
$propPatch->expects(self::once())
->method('setResultCode')
->with('{http://apple.com/ns/ical/}calendar-color', 200);
$this->federatedCalendar->propPatch($propPatch);
}
public function testPropPatchWithNoMutations(): void {
$propPatch = $this->createMock(PropPatch::class);
$propPatch->method('getMutations')
->willReturn([]);
$this->federatedCalendarMapper->expects(self::never())
->method('update');
$propPatch->expects(self::never())
->method('handle');
$this->federatedCalendar->propPatch($propPatch);
}
public function testGetChildACL(): void {
$this->assertEquals($this->federatedCalendar->getACL(), $this->federatedCalendar->getChildACL());
}
public function testGetLastModified(): void {
$this->assertEquals(1234567890, $this->federatedCalendar->getLastModified());
}
public function testDelete(): void {
$this->federatedCalendarMapper->expects(self::once())
->method('deleteById')
->with(10);
$this->federatedCalendar->delete();
}
public function testCreateDirectory(): void {
$this->expectException(MethodNotAllowed::class);
$this->expectExceptionMessage('Creating nested collection is not allowed');
$this->federatedCalendar->createDirectory('test');
}
public function testCalendarQuery(): void {
$filters = ['comp-filter' => ['name' => 'VEVENT']];
$expectedUris = ['event1.ics', 'event2.ics'];
$this->caldavBackend->expects(self::once())
->method('calendarQuery')
->with(10, $filters, 2) // 2 is CALENDAR_TYPE_FEDERATED
->willReturn($expectedUris);
$result = $this->federatedCalendar->calendarQuery($filters);
$this->assertEquals($expectedUris, $result);
}
public function testGetChild(): void {
$objectData = [
'id' => 1,
'uri' => 'event1.ics',
'calendardata' => 'BEGIN:VCALENDAR...',
];
$this->caldavBackend->expects(self::once())
->method('getCalendarObject')
->with(10, 'event1.ics', 2) // 2 is CALENDAR_TYPE_FEDERATED
->willReturn($objectData);
$child = $this->federatedCalendar->getChild('event1.ics');
$this->assertInstanceOf(FederatedCalendarObject::class, $child);
}
public function testGetChildNotFound(): void {
$this->caldavBackend->expects(self::once())
->method('getCalendarObject')
->with(10, 'nonexistent.ics', 2)
->willReturn(null);
$this->expectException(NotFound::class);
$this->federatedCalendar->getChild('nonexistent.ics');
}
public function testGetChildren(): void {
$objects = [
['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
];
$this->caldavBackend->expects(self::once())
->method('getCalendarObjects')
->with(10, 2) // 2 is CALENDAR_TYPE_FEDERATED
->willReturn($objects);
$children = $this->federatedCalendar->getChildren();
$this->assertCount(2, $children);
$this->assertInstanceOf(FederatedCalendarObject::class, $children[0]);
$this->assertInstanceOf(FederatedCalendarObject::class, $children[1]);
}
public function testGetMultipleChildren(): void {
$paths = ['event1.ics', 'event2.ics'];
$objects = [
['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
];
$this->caldavBackend->expects(self::once())
->method('getMultipleCalendarObjects')
->with(10, $paths, 2) // 2 is CALENDAR_TYPE_FEDERATED
->willReturn($objects);
$children = $this->federatedCalendar->getMultipleChildren($paths);
$this->assertCount(2, $children);
$this->assertInstanceOf(FederatedCalendarObject::class, $children[0]);
$this->assertInstanceOf(FederatedCalendarObject::class, $children[1]);
}
public function testChildExists(): void {
$this->caldavBackend->expects(self::once())
->method('getCalendarObject')
->with(10, 'event1.ics', 2)
->willReturn(['id' => 1, 'uri' => 'event1.ics']);
$result = $this->federatedCalendar->childExists('event1.ics');
$this->assertTrue($result);
}
public function testChildNotExists(): void {
$this->caldavBackend->expects(self::once())
->method('getCalendarObject')
->with(10, 'nonexistent.ics', 2)
->willReturn(null);
$result = $this->federatedCalendar->childExists('nonexistent.ics');
$this->assertFalse($result);
}
public function testCreateFile(): void {
$calendarData = 'BEGIN:VCALENDAR...END:VCALENDAR';
$remoteEtag = '"remote-etag-123"';
$localEtag = '"local-etag-456"';
$this->federatedCalendarService->expects(self::once())
->method('createCalendarObject')
->with($this->federationInfo, 'event1.ics', $calendarData)
->willReturn($remoteEtag);
$this->caldavBackend->expects(self::once())
->method('createCalendarObject')
->with(10, 'event1.ics', $calendarData, 2)
->willReturn($localEtag);
$result = $this->federatedCalendar->createFile('event1.ics', $calendarData);
$this->assertEquals($localEtag, $result);
}
public function testUpdateFile(): void {
$calendarData = 'BEGIN:VCALENDAR...UPDATED...END:VCALENDAR';
$remoteEtag = '"remote-etag-updated"';
$localEtag = '"local-etag-updated"';
$this->federatedCalendarService->expects(self::once())
->method('updateCalendarObject')
->with($this->federationInfo, 'event1.ics', $calendarData)
->willReturn($remoteEtag);
$this->caldavBackend->expects(self::once())
->method('updateCalendarObject')
->with(10, 'event1.ics', $calendarData, 2)
->willReturn($localEtag);
$result = $this->federatedCalendar->updateFile('event1.ics', $calendarData);
$this->assertEquals($localEtag, $result);
}
public function testDeleteFile(): void {
$this->federatedCalendarService->expects(self::once())
->method('deleteCalendarObject')
->with($this->federationInfo, 'event1.ics');
$this->caldavBackend->expects(self::once())
->method('deleteCalendarObject')
->with(10, 'event1.ics', 2);
$this->federatedCalendar->deleteFile('event1.ics');
}
}
+1 -1
View File
@@ -545,7 +545,7 @@ class Crypt {
$options,
$iv);
if ($plainContent) {
if ($plainContent !== false) {
return $plainContent;
} else {
throw new DecryptionFailedException('Encryption library: Decryption (symmetric) of content failed: ' . openssl_error_string());
+1 -1
View File
@@ -95,7 +95,7 @@ class Backends extends Base {
*/
private function formatConfiguration(array $parameters): array {
$configuration = array_filter($parameters, function (DefinitionParameter $parameter) {
return $parameter->isFlagSet(DefinitionParameter::FLAG_HIDDEN);
return !$parameter->isFlagSet(DefinitionParameter::FLAG_HIDDEN);
});
return array_map(function (DefinitionParameter $parameter) {
return $parameter->getTypeName();
@@ -28,7 +28,7 @@ trait DependencyTrait {
*
* @return MissingDependency[] Unsatisfied required dependencies
*/
public function checkRequiredDependencies() {
public function checkRequiredDependencies(): array {
return array_filter(
$this->checkDependencies(),
fn (MissingDependency $dependency) => !$dependency->isOptional()
@@ -270,8 +270,8 @@ class AmazonS3 extends Common {
$connection->deleteObjects([
'Bucket' => $this->bucket,
'Delete' => [
'Quiet' => true,
'Objects' => array_map(fn (array $object) => [
'ETag' => $object['ETag'],
'Key' => $object['Key'],
], $objects['Contents'])
]
@@ -178,7 +178,7 @@ class BackendService {
* @return Backend[]
*/
public function getAvailableBackends() {
return array_filter($this->getBackends(), fn (Backend $backend) => !$backend->checkRequiredDependencies());
return array_filter($this->getBackends(), fn (Backend $backend) => $backend->checkRequiredDependencies() === []);
}
/**
+1 -1
View File
@@ -233,7 +233,7 @@ OC.L10N.register(
"Create a new share link" : "Креирајте нов линк за споделување",
"Quick share options, the current selected is \"{selectedOption}\"" : "Опции за брзо споделување за , тековната избрана е \"{selectedOption}\"",
"View only" : "Само за гледање",
"Can edit" : "Може да се уредува",
"Can edit" : "Може да уредува",
"Custom permissions" : "Прилагодени дозволи",
"Resharing is not allowed" : "Повторно споделување не е дозволено",
"Name or email …" : "Име или е-пошта …",
+1 -1
View File
@@ -231,7 +231,7 @@
"Create a new share link" : "Креирајте нов линк за споделување",
"Quick share options, the current selected is \"{selectedOption}\"" : "Опции за брзо споделување за , тековната избрана е \"{selectedOption}\"",
"View only" : "Само за гледање",
"Can edit" : "Може да се уредува",
"Can edit" : "Може да уредува",
"Custom permissions" : "Прилагодени дозволи",
"Resharing is not allowed" : "Повторно споделување не е дозволено",
"Name or email …" : "Име или е-пошта …",
@@ -24,6 +24,7 @@ class BeforeDirectFileDownloadListener implements IEventListener {
public function __construct(
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ViewOnly $viewOnly,
) {
}
@@ -32,17 +33,17 @@ class BeforeDirectFileDownloadListener implements IEventListener {
return;
}
$pathsToCheck = [$event->getPath()];
// Check only for user/group shares. Don't restrict e.g. share links
$user = $this->userSession->getUser();
if ($user) {
$viewOnlyHandler = new ViewOnly(
$this->rootFolder->getUserFolder($user->getUID())
);
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
}
// Check only for user/group shares. Don't restrict e.g. share links
if (!$user) {
return;
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$node = $userFolder->get($event->getPath());
if (!$this->viewOnly->isNodeCanBeDownloaded($node)) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
}
}
}
@@ -13,7 +13,7 @@ use OCA\Files_Sharing\ViewOnly;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
use OCP\IUserSession;
/**
@@ -23,7 +23,7 @@ class BeforeZipCreatedListener implements IEventListener {
public function __construct(
private IUserSession $userSession,
private IRootFolder $rootFolder,
private ViewOnly $viewOnly,
) {
}
@@ -32,33 +32,19 @@ class BeforeZipCreatedListener implements IEventListener {
return;
}
/** @psalm-suppress DeprecatedMethod should be migrated to getFolder but for now it would just duplicate code */
$dir = $event->getDirectory();
$files = $event->getFiles();
if (empty($files)) {
$pathsToCheck = [$dir];
} else {
$pathsToCheck = [];
foreach ($files as $file) {
$pathsToCheck[] = $dir . '/' . $file;
}
}
// Check only for user/group shares. Don't restrict e.g. share links
$user = $this->userSession->getUser();
if ($user) {
$viewOnlyHandler = new ViewOnly(
$this->rootFolder->getUserFolder($user->getUID())
);
if (!$viewOnlyHandler->check($pathsToCheck)) {
$event->setErrorMessage('Access to this resource or one of its sub-items has been denied.');
$event->setSuccessful(false);
} else {
$event->setSuccessful(true);
}
} else {
$event->setSuccessful(true);
if (!$user) {
return;
}
// Check whether the user can download the requested folder
if (!$this->viewOnly->isNodeCanBeDownloaded($event->getFolder())) {
$event->setSuccessful(false);
$event->setErrorMessage('Access to this resource has been denied.');
return;
}
// Check recursively whether the user can download nested nodes
$event->addNodeFilter(fn (Node $node) => $this->viewOnly->isNodeCanBeDownloaded($node));
}
}
+2 -67
View File
@@ -8,80 +8,15 @@
namespace OCA\Files_Sharing;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
/**
* Handles restricting for download of files
*/
class ViewOnly {
public function __construct(
private Folder $userFolder,
) {
}
/**
* @param string[] $pathsToCheck paths to check, relative to the user folder
* @return bool
*/
public function check(array $pathsToCheck): bool {
// If any of elements cannot be downloaded, prevent whole download
foreach ($pathsToCheck as $file) {
try {
$info = $this->userFolder->get($file);
if ($info instanceof File) {
// access to filecache is expensive in the loop
if (!$this->checkFileInfo($info)) {
return false;
}
} elseif ($info instanceof Folder) {
// get directory content is rather cheap query
if (!$this->dirRecursiveCheck($info)) {
return false;
}
}
} catch (NotFoundException $e) {
continue;
}
}
return true;
}
/**
* @param Folder $dirInfo
* @return bool
* @throws NotFoundException
*/
private function dirRecursiveCheck(Folder $dirInfo): bool {
if (!$this->checkFileInfo($dirInfo)) {
return false;
}
// If any of elements cannot be downloaded, prevent whole download
$files = $dirInfo->getDirectoryListing();
foreach ($files as $file) {
if ($file instanceof File) {
if (!$this->checkFileInfo($file)) {
return false;
}
} elseif ($file instanceof Folder) {
return $this->dirRecursiveCheck($file);
}
}
return true;
}
/**
* @param Node $fileInfo
* @return bool
* @throws NotFoundException
*/
private function checkFileInfo(Node $fileInfo): bool {
public function isNodeCanBeDownloaded(Node $node): bool {
// Restrict view-only to nodes which are shared
$storage = $fileInfo->getStorage();
$storage = $node->getStorage();
if (!$storage->instanceOfStorage(SharedStorage::class)) {
return true;
}
+15 -5
View File
@@ -13,6 +13,7 @@ use OCA\Files_Sharing\AppInfo\Application;
use OCA\Files_Sharing\Listener\BeforeDirectFileDownloadListener;
use OCA\Files_Sharing\Listener\BeforeZipCreatedListener;
use OCA\Files_Sharing\SharedStorage;
use OCA\Files_Sharing\ViewOnly;
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\File;
@@ -91,7 +92,8 @@ class ApplicationTest extends TestCase {
$event = new BeforeDirectFileDownloadEvent($path);
$listener = new BeforeDirectFileDownloadListener(
$this->userSession,
$this->rootFolder
$this->rootFolder,
new ViewOnly(),
);
$listener->handle($event);
@@ -161,6 +163,13 @@ class ApplicationTest extends TestCase {
);
$folder->method('getDirectoryListing')->willReturn($directoryListing);
}
// If the folder contains any secure-shared files, make it appear as a secure-shared folder
// so that ViewOnly::isNodeCanBeDownloaded() will return false
$containsSecureSharedFiles = in_array('secureSharedStorage', $directoryListing);
if ($containsSecureSharedFiles && $folderStorage === 'nonSharedStorage') {
$folder->method('getStorage')->willReturn($secureSharedStorage);
}
$rootFolder = $this->createMock(Folder::class);
$rootFolder->method('getStorage')->willReturn($nonSharedStorage);
@@ -177,10 +186,11 @@ class ApplicationTest extends TestCase {
$this->rootFolder->method('getUserFolder')->with('test')->willReturn($userFolder);
// Simulate zip download of folder folder
$event = new BeforeZipCreatedEvent($dir, $files);
$event = new BeforeZipCreatedEvent($folder, $files, $directoryListing);
$listener = new BeforeZipCreatedListener(
$this->userSession,
$this->rootFolder
new ViewOnly(),
);
$listener->handle($event);
@@ -192,10 +202,10 @@ class ApplicationTest extends TestCase {
$this->userSession->method('isLoggedIn')->willReturn(false);
// Simulate zip download of folder folder
$event = new BeforeZipCreatedEvent('/test', ['test.txt']);
$event = new BeforeZipCreatedEvent('/test', ['test.txt'], []);
$listener = new BeforeZipCreatedListener(
$this->userSession,
$this->rootFolder
new ViewOnly(),
);
$listener->handle($event);
+21 -21
View File
@@ -6730,9 +6730,9 @@
}
},
"node_modules/asn1.js/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/assert": {
@@ -7084,9 +7084,9 @@
"license": "MIT"
},
"node_modules/bn.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
"integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
"integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
"license": "MIT"
},
"node_modules/body-parser": {
@@ -8114,9 +8114,9 @@
}
},
"node_modules/create-ecdh/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/create-hash": {
@@ -8690,9 +8690,9 @@
}
},
"node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/dijkstrajs": {
@@ -8889,9 +8889,9 @@
}
},
"node_modules/elliptic/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/emoji-mart-vue-fast": {
@@ -13028,9 +13028,9 @@
}
},
"node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/mime": {
@@ -14750,9 +14750,9 @@
}
},
"node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/punycode": {
+32
View File
@@ -21,6 +21,38 @@ $qb->selectColumnsDistinct('f');
$qb->selectAlias('g', 'h');
$qb->selectAlias($qb->func()->lower('i'), 'j');
$qb
->setParameter('k', 'l')
->setParameters([])
->setFirstResult(0)
->setMaxResults(0)
->delete()
->update()
->insert()
->from('m')
->join('n', 'o', 'p')
->innerJoin('q', 'r', 's')
->leftJoin('t', 'u', 'v')
->rightJoin('w', 'x', 'y')
->set('z', '1')
->where()
->andWhere()
->orWhere()
->groupBy()
->addGroupBy()
->setValue('2', '3')
->values([])
->having()
->andHaving()
->orHaving()
->orderBy('4')
->addOrderBy('5')
->resetQueryParts()
->resetQueryPart('6')
->hintShardKey('7', '8')
->runAcrossAllShards()
->forUpdate();
/** @psalm-check-type-exact $result = \OCP\DB\IResult<'a'|'b'|'c'|'d'|'e'|'f'|'h'|'j'> */
$result = $qb->executeQuery();
+1 -1
View File
@@ -82,7 +82,7 @@ class StatusCommand extends Command implements CompletionAwareInterface {
$numExecutedUnavailableMigrations = count($executedUnavailableMigrations);
$numNewMigrations = count(array_diff(array_keys($availableMigrations), $executedMigrations));
$pending = $ms->describeMigrationStep('lastest');
$pending = $ms->describeMigrationStep();
$infos = [
'App' => $ms->getApp(),
+29 -13
View File
@@ -43,6 +43,12 @@ class ResetPassword extends Base {
InputOption::VALUE_NONE,
'read password from environment variable NC_PASS/OC_PASS'
)
->addOption(
'no-password',
null,
InputOption::VALUE_NONE,
'Sets the password to blank'
)
;
}
@@ -76,22 +82,32 @@ class ResetPassword extends Base {
}
}
$question = new Question('Enter a new password: ');
$question->setHidden(true);
$password = $helper->ask($input, $output, $question);
if ($input->getOption('no-password')) {
$question = new ConfirmationQuestion('Are you sure you want to clear the password for ' . $username . '?');
if ($password === null) {
$output->writeln('<error>Password cannot be empty!</error>');
return 1;
}
if (!$helper->ask($input, $output, $question)) {
return 1;
}
$question = new Question('Confirm the new password: ');
$question->setHidden(true);
$confirm = $helper->ask($input, $output, $question);
$password = '';
} else {
$question = new Question('Enter a new password: ');
$question->setHidden(true);
$password = $helper->ask($input, $output, $question);
if ($password !== $confirm) {
$output->writeln('<error>Passwords did not match!</error>');
return 1;
if ($password === null) {
$output->writeln('<error>Password cannot be empty!</error>');
return 1;
}
$question = new Question('Confirm the new password: ');
$question->setHidden(true);
$confirm = $helper->ask($input, $output, $question);
if ($password !== $confirm) {
$output->writeln('<error>Passwords did not match!</error>');
return 1;
}
}
} else {
$output->writeln('<error>Interactive input or --password-from-env is needed for entering a new password!</error>');
+4 -3
View File
@@ -766,9 +766,10 @@ class OC {
self::checkConfig();
self::checkInstalled($systemConfig);
self::addSecurityHeaders();
self::performSameSiteCookieProtection($config);
if (!self::$CLI) {
self::addSecurityHeaders();
self::performSameSiteCookieProtection($config);
}
if (!defined('OC_CONSOLE')) {
$eventLogger->start('check_server', 'Run a few configuration checks');
+1 -1
View File
@@ -145,7 +145,7 @@ class Factory implements IFactory {
if ($lang === null) {
return null;
}
$lang = preg_replace('/[^a-zA-Z0-9.;,=-]/', '', $lang);
$lang = preg_replace('/[^a-zA-Z0-9.;,=_-]/', '', $lang);
return str_replace('..', '', $lang);
}
+10 -13
View File
@@ -269,20 +269,9 @@ class Log implements ILogger, IDataLogger {
}
}
if (!isset($logCondition['matches'])) {
$configLogLevel = $this->config->getValue('loglevel', ILogger::WARN);
if (is_numeric($configLogLevel)) {
$this->nestingLevel--;
return min((int)$configLogLevel, ILogger::FATAL);
}
$logConditionMatches = $logCondition['matches'] ?? [];
// Invalid configuration, warn the user and fall back to default level of WARN
error_log('Nextcloud configuration: "loglevel" is not a valid integer');
$this->nestingLevel--;
return ILogger::WARN;
}
foreach ($logCondition['matches'] as $option) {
foreach ($logConditionMatches as $option) {
if (
(!isset($option['shared_secret']) || $this->checkLogSecret($option['shared_secret']))
&& (!isset($option['users']) || in_array($userId, $option['users'], true))
@@ -300,6 +289,14 @@ class Log implements ILogger, IDataLogger {
}
}
$configLogLevel = $this->config->getValue('loglevel', ILogger::WARN);
if (is_numeric($configLogLevel)) {
$this->nestingLevel--;
return min((int)$configLogLevel, ILogger::FATAL);
}
// Invalid configuration, warn the user and fall back to default level of WARN
error_log('Nextcloud configuration: "loglevel" is not a valid integer');
$this->nestingLevel--;
return ILogger::WARN;
}
+7 -2
View File
@@ -199,14 +199,19 @@ class Repair implements IOutput {
* @return list<IRepairStep>
*/
public static function getExpensiveRepairSteps(): array {
return [
$expensiveSteps = [
Server::get(OldGroupMembershipShares::class),
Server::get(RemoveBrokenProperties::class),
Server::get(RepairMimeTypes::class),
Server::get(DeleteSchedulingObjects::class),
Server::get(RemoveObjectProperties::class),
Server::get(CleanupShareTarget::class),
];
if (class_exists(CleanupShareTarget::class)) {
$expensiveSteps[] = Server::get(CleanupShareTarget::class);
}
return $expensiveSteps;
}
/**
+31 -43
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
@@ -12,28 +14,27 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Share\IManager;
use OCP\Share\IShareHelper;
use Override;
class ShareHelper implements IShareHelper {
public function __construct(
private IManager $shareManager,
private readonly IManager $shareManager,
) {
}
/**
* @param Node $node
* @return array [ users => [Mapping $uid => $pathForUser], remotes => [Mapping $cloudId => $pathToMountRoot]]
*/
public function getPathsForAccessList(Node $node) {
#[Override]
public function getPathsForAccessList(Node $node): array {
$result = [
'users' => [],
'remotes' => [],
];
$accessList = $this->shareManager->getAccessList($node, true, true);
if (!empty($accessList['users'])) {
if (isset($accessList['users']) && $accessList['users'] !== []) {
$result['users'] = $this->getPathsForUsers($node, $accessList['users']);
}
if (!empty($accessList['remote'])) {
if (isset($accessList['remote']) && $accessList['remote'] !== []) {
$result['remotes'] = $this->getPathsForRemotes($node, $accessList['remote']);
}
@@ -60,20 +61,20 @@ class ShareHelper implements IShareHelper {
* 'test3' => '/cat',
* ],
*
* @param Node $node
* @param array[] $users
* @return array
* @param non-empty-array<string, array{node_id: int, node_path: string}> $users
* @return array<string, string>
*/
protected function getPathsForUsers(Node $node, array $users) {
/** @var array[] $byId */
protected function getPathsForUsers(Node $node, array $users): array {
/** @var array<int, array<string, string>> $byId */
$byId = [];
/** @var array[] $results */
/** @var array<string, string> $results */
$results = [];
foreach ($users as $uid => $info) {
if (!isset($byId[$info['node_id']])) {
$byId[$info['node_id']] = [];
}
$byId[$info['node_id']][$uid] = $info['node_path'];
}
@@ -82,15 +83,14 @@ class ShareHelper implements IShareHelper {
foreach ($byId[$node->getId()] as $uid => $path) {
$results[$uid] = $path;
}
unset($byId[$node->getId()]);
}
} catch (NotFoundException $e) {
return $results;
} catch (InvalidPathException $e) {
} catch (NotFoundException|InvalidPathException) {
return $results;
}
if (empty($byId)) {
if ($byId === []) {
return $results;
}
@@ -98,22 +98,18 @@ class ShareHelper implements IShareHelper {
$appendix = '/' . $node->getName();
while (!empty($byId)) {
try {
/** @var Node $item */
$item = $item->getParent();
if (!empty($byId[$item->getId()])) {
if ($byId[$item->getId()] !== []) {
foreach ($byId[$item->getId()] as $uid => $path) {
$results[$uid] = $path . $appendix;
}
unset($byId[$item->getId()]);
}
$appendix = '/' . $item->getName() . $appendix;
} catch (NotFoundException $e) {
return $results;
} catch (InvalidPathException $e) {
return $results;
} catch (NotPermittedException $e) {
} catch (NotFoundException|InvalidPathException|NotPermittedException) {
return $results;
}
}
@@ -141,27 +137,27 @@ class ShareHelper implements IShareHelper {
* 'test3' => ['token' => 't3', 'node_path' => '/SixTeen/TwentyThree/FortyTwo'],
* ],
*
* @param Node $node
* @param array[] $remotes
* @return array
* @param non-empty-array<string, array{node_id: int, token: string}> $remotes
* @return array<string, array{token: string, node_path: string}>
*/
protected function getPathsForRemotes(Node $node, array $remotes) {
/** @var array[] $byId */
protected function getPathsForRemotes(Node $node, array $remotes): array {
/** @var array<int, array<string, string>> $byId */
$byId = [];
/** @var array[] $results */
/** @var array<string, array{token: string, node_path: string}> $results */
$results = [];
foreach ($remotes as $cloudId => $info) {
if (!isset($byId[$info['node_id']])) {
$byId[$info['node_id']] = [];
}
$byId[$info['node_id']][$cloudId] = $info['token'];
}
$item = $node;
while (!empty($byId)) {
try {
if (!empty($byId[$item->getId()])) {
if ($byId[$item->getId()] !== []) {
$path = $this->getMountedPath($item);
foreach ($byId[$item->getId()] as $uid => $token) {
$results[$uid] = [
@@ -169,16 +165,12 @@ class ShareHelper implements IShareHelper {
'token' => $token,
];
}
unset($byId[$item->getId()]);
}
/** @var Node $item */
$item = $item->getParent();
} catch (NotFoundException $e) {
return $results;
} catch (InvalidPathException $e) {
return $results;
} catch (NotPermittedException $e) {
} catch (NotFoundException|InvalidPathException|NotPermittedException) {
return $results;
}
}
@@ -186,11 +178,7 @@ class ShareHelper implements IShareHelper {
return $results;
}
/**
* @param Node $node
* @return string
*/
protected function getMountedPath(Node $node) {
protected function getMountedPath(Node $node): string {
$path = $node->getPath();
$sections = explode('/', $path, 4);
return '/' . $sections[3];
@@ -37,7 +37,9 @@ interface ITypedQueryBuilder extends IQueryBuilder {
* @template NewS of string
* @param NewS ...$columns The columns to select. They are not allowed to contain table names or aliases, or asterisks. Use {@see self::selectAlias()} for that.
* @psalm-this-out self<S|NewS>
* @return $this
* @since 34.0.0
* @note Psalm has a bug that prevents inferring the correct type in chained calls: https://github.com/vimeo/psalm/issues/8803. Convert the chained calls to standalone calls or switch to PHPStan, which suffered the same bug in the past, but fixed it in 2.1.5: https://github.com/phpstan/phpstan/issues/8439
*/
public function selectColumns(string ...$columns): self;
@@ -52,7 +54,9 @@ interface ITypedQueryBuilder extends IQueryBuilder {
* @template NewS of string
* @param NewS ...$columns The columns to select distinct. They are not allowed to contain table names or aliases, or asterisks. Use {@see self::selectAlias()} for that.
* @psalm-this-out self<S|NewS>
* @return $this
* @since 34.0.0
* @note Psalm has a bug that prevents inferring the correct type in chained calls: https://github.com/vimeo/psalm/issues/8803. Convert the chained calls to standalone calls or switch to PHPStan, which suffered the same bug in the past, but fixed it in 2.1.5: https://github.com/phpstan/phpstan/issues/8439
*/
public function selectColumnsDistinct(string ...$columns): self;
@@ -69,8 +73,244 @@ interface ITypedQueryBuilder extends IQueryBuilder {
* @template NewS of string
* @param NewS $alias
* @psalm-this-out self<S|NewS>
* @psalm-suppress LessSpecificImplementedReturnType
* @return $this
* @note Psalm has a bug that prevents inferring the correct type in chained calls: https://github.com/vimeo/psalm/issues/8803. Convert the chained calls to standalone calls or switch to PHPStan, which suffered the same bug in the past, but fixed it in 2.1.5: https://github.com/phpstan/phpstan/issues/8439
*/
#[Override]
public function selectAlias($select, $alias): self;
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function setParameter($key, $value, $type = null);
/**
* @inheritDoc
* @return $this
*/
#[Override]
public function setParameters(array $params, array $types = []);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function setFirstResult($firstResult);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function setMaxResults($maxResults);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function delete($delete = null, $alias = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function update($update = null, $alias = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function insert($insert = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function from($from, $alias = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function join($fromAlias, $join, $alias, $condition = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function innerJoin($fromAlias, $join, $alias, $condition = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function leftJoin($fromAlias, $join, $alias, $condition = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function rightJoin($fromAlias, $join, $alias, $condition = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function set($key, $value);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function where(...$predicates);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function andWhere(...$where);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function orWhere(...$where);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function groupBy(...$groupBys);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function addGroupBy(...$groupBy);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function setValue($column, $value);
/**
* @inheritDoc
* @return $this
*/
#[Override]
public function values(array $values);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function having(...$having);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function andHaving(...$having);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function orHaving(...$having);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function orderBy($sort, $order = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function addOrderBy($sort, $order = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function resetQueryParts($queryPartNames = null);
/**
* @inheritDoc
* @return $this
* @psalm-suppress MissingParamType
*/
#[Override]
public function resetQueryPart($queryPartName);
/**
* @inheritDoc
* @return $this
*/
#[Override]
public function hintShardKey(string $column, mixed $value, bool $overwrite = false): self;
/**
* @inheritDoc
* @return $this
*/
#[Override]
public function runAcrossAllShards(): self;
/**
* @inheritDoc
* @return $this
*/
#[Override]
public function forUpdate(ConflictResolutionMode $conflictResolutionMode = ConflictResolutionMode::Ordinary): self;
}
@@ -11,6 +11,7 @@ namespace OCP\Files\Events;
use OCP\EventDispatcher\Event;
use OCP\Files\Folder;
use OCP\Files\Node;
/**
* This event is triggered before a archive is created when a user requested
@@ -25,16 +26,19 @@ class BeforeZipCreatedEvent extends Event {
private bool $successful = true;
private ?string $errorMessage = null;
private ?Folder $folder = null;
private array $nodeFilters = [];
/**
* @param string|Folder $directory Folder instance, or (deprecated) string path relative to user folder
* @param list<string> $files
* @param list<string> $files Selected files, empty for folder selection
* @param iterable<Node> $nodes Recursively collected nodes
* @since 25.0.0
* @since 31.0.0 support `OCP\Files\Folder` as `$directory` parameter - passing a string is deprecated now
*/
public function __construct(
string|Folder $directory,
private array $files,
private iterable $nodes,
) {
parent::__construct();
if ($directory instanceof Folder) {
@@ -70,6 +74,30 @@ class BeforeZipCreatedEvent extends Event {
return $this->files;
}
/**
* @return iterable<Node>
*/
public function getNodes(): iterable {
foreach ($this->nodes as $node) {
$pass = true;
foreach ($this->nodeFilters as $filter) {
$pass = $pass && $filter($node);
}
if ($pass) {
yield $node;
}
}
}
/**
* @param callable $filter
* @return void
*/
public function addNodeFilter(callable $filter): void {
$this->nodeFilters[] = $filter;
}
/**
* @since 25.0.0
*/
+2 -2
View File
@@ -258,9 +258,9 @@ interface IManager {
* @return ($currentAccess is true
* ? array{
* users?: array<string, array{node_id: int, node_path: string}>,
* remote?: array<string, array{node_id: int, node_path: string}>,
* remote?: array<string, array{node_id: int, token: string}>,
* public?: bool,
* mail?: array<string, array{node_id: int, node_path: string}>
* mail?: array<string, array{node_id: int, token: string}>
* }
* : array{users?: list<string>, remote?: bool, public?: bool, mail?: list<string>})
* @since 12.0.0
+7 -4
View File
@@ -1,23 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Share;
use OCP\AppFramework\Attribute\Consumable;
use OCP\Files\Node;
/**
* Interface IShareHelper
*
* @since 12
* @since 12.0.0
*/
#[Consumable(since: '12.0.0')]
interface IShareHelper {
/**
* @param Node $node
* @return array [ users => [Mapping $uid => $pathForUser], remotes => [Mapping $cloudId => $pathToMountRoot]]
* @return array{users: array<string, string>, remotes: array<string, array{token: string, node_path: string}>} [ users => [Mapping $uid => $pathForUser], remotes => [Mapping $cloudId => $pathToMountRoot]]
* @since 12
*/
public function getPathsForAccessList(Node $node);
public function getPathsForAccessList(Node $node): array;
}
+2
View File
@@ -28,6 +28,8 @@
<file name="build/psalm/ITypedQueryBuilderTest.php"/>
<file name="lib/private/DB/QueryBuilder/TypedQueryBuilder.php"/>
<file name="lib/public/DB/QueryBuilder/ITypedQueryBuilder.php"/>
<file name="lib/private/Share20/ShareHelper.php"/>
<file name="lib/public/Share/IShareHelper.php"/>
<ignoreFiles>
<directory name="apps/**/composer"/>
<directory name="apps/**/tests"/>
+1
View File
@@ -95,6 +95,7 @@ class FactoryTest extends TestCase {
return [
'null shortcut' => [null, null],
'default language' => ['de', 'de'],
'regional language' => ['de_DE', 'de_DE'],
'malicious language' => ['de/../fr', 'defr'],
'request language' => ['kab;q=0.8,ka;q=0.7,de;q=0.6', 'kab;q=0.8,ka;q=0.7,de;q=0.6'],
];
+28
View File
@@ -159,6 +159,34 @@ class LoggerTest extends TestCase implements IWriter {
$this->assertEquals($expectedLogs, $this->getLogs());
}
public function testMatchesConditionIncreaseLoglevel(): void {
$this->config->expects($this->any())
->method('getValue')
->willReturnMap([
['loglevel', ILogger::WARN, ILogger::INFO],
['log.condition', [], ['matches' => [
[
'message' => 'catched',
'loglevel' => 3,
]
]]],
]);
$logger = $this->logger;
$user = $this->createMock(IUser::class);
$user->method('getUID')
->willReturn('test-userid');
$userSession = $this->createMock(IUserSession::class);
$userSession->method('getUser')
->willReturn($user);
$this->overwriteService(IUserSession::class, $userSession);
$logger->info('catched message');
$logger->info('info level message');
$this->assertEquals(['1 info level message'], $this->getLogs());
}
public function testLoggingWithDataArray(): void {
$this->mockDefaultLogLevel();
/** @var IWriter&MockObject */