Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ to structure shared knowledge and cultivate trust.
<namespace>Collectives</namespace>
<types>
<filesystem/>
<dav/>
</types>

<documentation>
Expand Down Expand Up @@ -93,6 +94,12 @@ to structure shared knowledge and cultivate trust.
</navigation>
</navigations>

<sabre>
<plugins>
<plugin>OCA\Collectives\Dav\PropFindPlugin</plugin>
</plugins>
</sabre>

<trash>
<backend for="OCA\Collectives\Mount\CollectiveStorage">OCA\Collectives\Trash\PageTrashBackend</backend>
</trash>
Expand Down
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']],
['name' => 'collective#pageMode', 'url' => '/api/v{apiVersion}/collectives/{id}/pageMode', 'verb' => 'PUT',
'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']],
['name' => 'collective#customSettings', 'url' => '/api/v{apiVersion}/collectives/{id}/customSettings', 'verb' => 'PUT',
'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']],
['name' => 'collective#trash', 'url' => '/api/v{apiVersion}/collectives/{id}', 'verb' => 'DELETE',
'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']],

Expand Down
13 changes: 13 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@
use OCA\Collectives\Db\PageLinkMapper;
use OCA\Collectives\Db\PageMapper;
use OCA\Collectives\Fs\UserFolderHelper;
use OCA\Collectives\Listeners\BeforeDirectFileDownloadListener;
use OCA\Collectives\Listeners\BeforeTemplateRenderedListener;
use OCA\Collectives\Listeners\BeforeZipCreatedListener;
use OCA\Collectives\Listeners\CircleDestroyedListener;
use OCA\Collectives\Listeners\CircleEditingEventListener;
use OCA\Collectives\Listeners\CollectivesReferenceListener;
use OCA\Collectives\Listeners\NodeRenamedListener;
use OCA\Collectives\Listeners\NodeWrittenListener;
use OCA\Collectives\Listeners\SabrePluginAddListener;
use OCA\Collectives\Listeners\ShareDeletedListener;
use OCA\Collectives\Listeners\TextMentionListener;
use OCA\Collectives\Middleware\PublicOCSMiddleware;
Expand All @@ -41,6 +44,7 @@
use OCA\Collectives\Trash\PageTrashBackend;
use OCA\Collectives\Trash\PageTrashManager;
use OCA\Collectives\Versions\VersionsBackend;
use OCA\DAV\Events\SabrePluginAddEvent;
use OCA\Files_Versions\Versions\IVersionBackend;
use OCA\Text\Event\MentionEvent;
use OCP\App\IAppManager;
Expand All @@ -52,6 +56,8 @@
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Files\Config\IMountProviderCollection;
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
use OCP\Files\Events\BeforeZipCreatedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\Files\IMimeTypeLoader;
Expand Down Expand Up @@ -85,6 +91,13 @@ public function register(IRegistrationContext $context): void {

$context->registerNotifierService(Notifier::class);

// Negative priority so it runs after other listeners (e.g. files_sharing
// at priority 5) and has the final say on whether the download is allowed.
$context->registerEventListener(BeforeZipCreatedEvent::class, BeforeZipCreatedListener::class, -100);
$context->registerEventListener(BeforeDirectFileDownloadEvent::class, BeforeDirectFileDownloadListener::class, -100);

$context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class, 500);

$context->registerMiddleware(PublicOCSMiddleware::class);

$context->registerConfigLexicon(ConfigLexicon::class);
Expand Down
24 changes: 24 additions & 0 deletions lib/Controller/CollectiveController.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,30 @@ public function pageMode(int $id, int $mode): DataResponse {
return new DataResponse(['collective' => $collective]);
}

/**
* Set custom settings for an existing collective
*
* @param int $id ID of the collective
* @param string $key Key of the setting
* @param mixed $value Value of the setting
*
* @return DataResponse<Http::STATUS_OK, array{collective: CollectivesCollective}, array{}>
* @throws OCSNotFoundException Collective not found
* @throws OCSForbiddenException Not permitted
*
* 200: Collective updated
*/
#[NoAdminRequired]
public function customSettings(int $id, string $key, mixed $value): DataResponse {
$collective = $this->handleErrorResponse(fn (): Collective => $this->service->setCustomSettings(
$id,
$this->userId,
$key,
$value,
), $this->logger);
return new DataResponse(['collective' => $collective]);
}

/**
* Trash an existing collective
*
Expand Down
172 changes: 172 additions & 0 deletions lib/Dav/PropFindPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Collectives\Dav;

use OCA\Collectives\Fs\UserFolderHelper;
use OCA\Collectives\Service\CollectiveHelper;
use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCA\DAV\Connector\Sabre\Node;
use OCP\IUserSession;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;

class PropFindPlugin extends ServerPlugin {
private ?array $allowDownloadPerCollectiveId = null;
private ?string $collectiveFolderInternalPath = null;
private Server $server;

public function __construct(
private readonly IUserSession $userSession,
private readonly UserFolderHelper $userFolderHelper,
private readonly CollectiveHelper $collectiveHelper,
) {
}

public function initialize(Server $server): void {
$this->server = $server;
$server->on('propFind', $this->propFind(...), 200);
$server->on('beforeCopy', $this->beforeCopy(...), 200);
$server->on('beforeMove', $this->beforeMove(...), 200);
}

public function beforeCopy(string $source, string $destination): void {
$node = $this->server->tree->getNodeForPath($source);

if (!$node instanceof Node) {
return;
}

if ($this->isDownloadDisabled($node)) {
throw new Forbidden('Copying this item is not allowed');
}
}

public function beforeMove(string $source, string $destination): void {
$node = $this->server->tree->getNodeForPath($source);

if (!$node instanceof Node) {
return;
}

if (!$this->isDownloadDisabled($node)) {
return;
}

// Moving within the same collective (e.g. renaming or reordering pages)
// is fine. Only block moving the item out of the collective, which would
// otherwise bypass the download restriction.
$sourceCollectiveId = $this->getCollectiveId($node);
if ($sourceCollectiveId !== null
&& $sourceCollectiveId === $this->getDestinationCollectiveId($destination)) {
return;
}

throw new Forbidden('Moving this item out of the collective is not allowed');
}

private function isDownloadDisabled(Node $node): bool {
if ($this->userSession->getUser() === null) {
return false;
}

// Check root collective folder
if ($node->getFileInfo()->getPath() === $this->getCollectiveFolderInternalPath()) {
return true;
}

$collectiveId = $this->getCollectiveId($node);
if ($collectiveId === null) {
return false;
}

$allowDownload = $this->getAllowDownloadPerCollectiveId()[$collectiveId] ?? false;

return !$allowDownload;
}

/**
* Returns the collective id a node belongs to, or null if it isn't part of
* a collective mount.
*/
private function getCollectiveId(Node $node): ?int {
$mountPoint = $node->getFileInfo()->getMountPoint();
if ($mountPoint->getMountType() !== 'collective') {
return null;
}

return $mountPoint->getStorage()
->getWrapperStorage()
->getFolderId();
}

/**
* Returns the collective id the move destination would live in, by resolving
* the destination's parent node. Returns null if it isn't a collective.
*/
private function getDestinationCollectiveId(string $destination): ?int {
$parent = dirname($destination);
try {
$parentNode = $this->server->tree->getNodeForPath($parent);
} catch (NotFound) {
return null;
}

if (!$parentNode instanceof Node) {
return null;
}

return $this->getCollectiveId($parentNode);
}

public function propFind(PropFind $propFind, INode $node) {
if (!$node instanceof Node) {
return;
}

$propFind->handle(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME,
function () use ($node, $propFind) {
if ($this->isDownloadDisabled($node)) {
return 'true';
}

return $propFind->get(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME);
});
}

private function getCollectiveFolderInternalPath(): string {
if ($this->collectiveFolderInternalPath !== null) {
return $this->collectiveFolderInternalPath;
}

$userId = $this->userSession->getUser()->getUID();

return $this->collectiveFolderInternalPath = '/' . $userId . '/files'
. $this->userFolderHelper->getUserFolderSetting($userId);
}

private function getAllowDownloadPerCollectiveId(): array {
if ($this->allowDownloadPerCollectiveId !== null) {
return $this->allowDownloadPerCollectiveId;
}

$userId = $this->userSession->getUser()->getUID();
$collectives = $this->collectiveHelper->getCollectivesForUser($userId, true, false);
$this->allowDownloadPerCollectiveId = [];
foreach ($collectives as $collective) {
$this->allowDownloadPerCollectiveId[$collective->getId()] = $collective->canDownload();
}

return $this->allowDownloadPerCollectiveId;
}
}
28 changes: 28 additions & 0 deletions lib/Db/Collective.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@
* @method int|null getTrashTimestamp()
* @method void setTrashTimestamp(?int $trashTimestamp)
* @method int getPageMode()
* @method string|null getCustomSettings()
* @method void setCustomSettings(?string $customSettings)
*/
class Collective extends Entity implements JsonSerializable {
public const CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL = 'downloadPermissionLevel';
public const CUSTOM_SETTINGS = [
self::CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL,
];

/** @var int */
public const defaultPermissions
= Constants::PERMISSION_ALL * 100 // Moderator
Expand Down Expand Up @@ -67,6 +74,8 @@ class Collective extends Entity implements JsonSerializable {
protected ?string $emoji = null;
protected ?int $trashTimestamp = null;
protected int $pageMode = self::defaultPageMode;
protected ?string $customSettings = null;

/** transient attributes, not persisted in database */
protected string $name = '';
protected int $level = Member::LEVEL_MEMBER;
Expand Down Expand Up @@ -136,6 +145,16 @@ public function setPageMode(int $mode): void {
$this->markFieldUpdated('pageMode');
}

public function getCustomSettingsArray(): array {
return json_decode($this->customSettings ?: '{}', true) ?: [];
}

public function setCustomSetting(string $key, mixed $value): void {
$customSettings = $this->getCustomSettingsArray();
$customSettings[$key] = $value;
$this->setCustomSettings(json_encode($customSettings));
}

public function isTrashed(): bool {
return (bool)$this->getTrashTimestamp();
}
Expand Down Expand Up @@ -292,6 +311,14 @@ public function canShare(): bool {
return $this->level >= $this->getSharePermissionLevel();
}

public function getDownloadPermissionLevel(): int {
return (int)($this->getCustomSettingsArray()[self::CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL] ?? Member::LEVEL_ADMIN);
}

public function canDownload(): bool {
return $this->level >= $this->getDownloadPermissionLevel();
}

public function getUrlPath(): string {
return $this->slug ? $this->slug . '-' . $this->id : $this->name;
}
Expand Down Expand Up @@ -320,6 +347,7 @@ public function jsonSerialize(): array {
'userFavoritePages' => $this->userFavoritePages,
'userNotify' => $this->userNotify,
'canLeave' => $this->getCanLeave(),
'customSettings' => $this->getCustomSettingsArray(),
];
}
}
49 changes: 49 additions & 0 deletions lib/Listeners/BeforeDirectFileDownloadListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Collectives\Listeners;

use OCA\Collectives\Service\DownloadCheckService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\BeforeDirectFileDownloadEvent;
use OCP\IUserSession;

/**
* Block direct download of a single file that lives inside a collective which
* has downloads disabled.
*
* The WebDAV PropFindPlugin only hides the download button in the UI and cannot
* stop server-side downloads. This listener enforces the restriction.
*
* @template-implements IEventListener<BeforeDirectFileDownloadEvent|Event>
*/
class BeforeDirectFileDownloadListener implements IEventListener {
public function __construct(
private readonly IUserSession $userSession,
private readonly DownloadCheckService $downloadCheckService,
) {
}

public function handle(Event $event): void {
if (!($event instanceof BeforeDirectFileDownloadEvent)) {
return;
}

$user = $this->userSession->getUser();
if ($user === null) {
return;
}

if ($this->downloadCheckService->isDownloadBlocked($user->getUID(), [$event->getPath()])) {
$event->setSuccessful(false);
$event->setErrorMessage('Downloading this collective is not allowed.');
}
}
}
Loading
Loading