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
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
['name' => 'Slave#createAppToken', 'url' => '/v1/createapptoken', 'verb' => 'GET'],
['name' => 'Slave#discovery', 'url' => '/discovery', 'verb' => 'GET'],
['name' => 'Slave#sharedFile', 'url' => '/sharedfile', 'verb' => 'GET'],
['name' => 'Slave#refreshSharedFile', 'url' => '/refreshSharedFile', 'verb' => 'GET'],
],
'routes' => [
[
Expand Down
8 changes: 8 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCA\GlobalSiteSelector\GlobalSiteSelector;
use OCA\GlobalSiteSelector\Listeners\AddContentSecurityPolicyListener;
use OCA\GlobalSiteSelector\Listeners\DeletingUser;
use OCA\GlobalSiteSelector\Listeners\SharedFileRefresh;
use OCA\GlobalSiteSelector\Listeners\UserChanged;
use OCA\GlobalSiteSelector\Listeners\UserCreated;
use OCA\GlobalSiteSelector\Listeners\UserDeleted;
Expand All @@ -30,6 +31,9 @@
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
Expand Down Expand Up @@ -86,6 +90,10 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserLoggedOutEvent::class, UserLoggedOut::class);
$context->registerEventListener(UserChangedEvent::class, UserChanged::class);

$context->registerEventListener(NodeWrittenEvent::class, SharedFileRefresh::class);
$context->registerEventListener(NodeRenamedEvent::class, SharedFileRefresh::class);
$context->registerEventListener(NodeDeletedEvent::class, SharedFileRefresh::class);

$context->registerSetupCheck(LongJwtKeySetupCheck::class);
$context->registerConfigLexicon(ConfigLexicon::class);

Expand Down
38 changes: 38 additions & 0 deletions lib/BackgroundJobs/NotifyRemoteFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

namespace OCA\GlobalSiteSelector\BackgroundJobs;

use OCA\GlobalSiteSelector\ConfigLexicon;
use OCA\GlobalSiteSelector\Service\GlobalShareService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;

/**
* This is called to notify a remote instance that a local shared
* document has been modified. This is a background job that might
* be initiated if a file is shared to too many different instances.
* Limit is set via {@see ConfigLexicon::INSTANCE_MAIN_THREAD}
*/
class NotifyRemoteFile extends QueuedJob {
public function __construct(
ITimeFactory $time,
private readonly GlobalShareService $globalShareService,
) {
parent::__construct($time);
}

#[\Override]
protected function run($argument): void {
$instances = $argument['instances'] ?? [];
foreach ($instances as $instance => $shares) {
$this->globalShareService->requestRemoteFileRefresh($instance, $shares);
}
}
}
2 changes: 2 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class ConfigLexicon implements ILexicon {
public const GS_TOKENS = 'globalScaleTokens';
public const LOCAL_TOKEN = 'localToken';
public const REDIRECT_WEBDAV = 'redirectWebDAV';
public const INSTANCE_MAIN_THREAD = 'requested_instance_main_thread';
Comment thread
ArtificialOwl marked this conversation as resolved.

#[\Override]
public function getStrictness(): Strictness {
Expand All @@ -32,6 +33,7 @@ public function getAppConfigs(): array {
new Entry(key: self::GS_TOKENS, type: ValueType::ARRAY, defaultRaw: [], definition: 'list of token+host to navigate through GlobalScale', lazy: true),
new Entry(key: self::LOCAL_TOKEN, type: ValueType::STRING, defaultRaw: '', definition: 'local token to id instance within GlobalScale', lazy: true),
new Entry(key: self::REDIRECT_WEBDAV, type: ValueType::BOOL, defaultRaw: false, definition: 'redirect WebDAV request on Master to Slaves', lazy: false),
new Entry(key: self::INSTANCE_MAIN_THREAD, type: ValueType::INT, defaultRaw: 2, definition: 'when running event requests, maximum number of instances to reach before switching to background job', lazy: false),
];
}

Expand Down
30 changes: 30 additions & 0 deletions lib/Controller/SlaveController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use OCA\GlobalSiteSelector\Exceptions\MasterUrlException;
use OCA\GlobalSiteSelector\Exceptions\SharedFileException;
use OCA\GlobalSiteSelector\GlobalSiteSelector;
use OCA\GlobalSiteSelector\Model\FederatedShare;
use OCA\GlobalSiteSelector\Model\LocalFile;
use OCA\GlobalSiteSelector\Service\GlobalScaleService;
use OCA\GlobalSiteSelector\Service\GlobalShareService;
Expand Down Expand Up @@ -126,6 +127,35 @@ public function sharedFile(string $jwt): DataResponse {
}



/**
* initiate refresh on local versions of a remote federated file.
* request must contain encoded jwt.
*/
#[PublicPage]
#[NoCSRFRequired]
public function refreshSharedFile(string $jwt): DataResponse {
$key = $this->gss->getJwtKey();
$decoded = (array)JWT::decode($jwt, new Key($key, Application::JWT_ALGORITHM));
// JWT store data as stdClass, not array
$decoded = json_decode(json_encode($decoded), true);
$this->logger->debug('decoded request', ['data' => $decoded]);
$instance = $decoded['instance'] ?? '';

foreach ($decoded['shares'] as $entry) {
$federatedShare = new FederatedShare();
$federatedShare->import($entry);
$this->globalShareService->refreshSharedTarget(
$instance,
$federatedShare->getId(),
$federatedShare->getShareToken(),
$federatedShare->getTarget()
);
}

return new DataResponse([]);
}

#[PublicPage]
#[NoCSRFRequired]
#[UseSession]
Expand Down
28 changes: 28 additions & 0 deletions lib/Db/FileRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,34 @@ public function getTeamStorages(FederatedShare $federatedShare, string $instance
return $storage;
}

/**
* returns an array containing user and mountpoint from an external share; based on remote
* instance that owns the file, the shareId on the remote instance and
* the share token.
*
* If not known, user and mountpoint are null in the returned array.
*/
public function getMountPointFromShare(string $instance, int $remoteId, string $shareToken): array {
$qb = $this->connection->getQueryBuilder();
$qb->select('user', 'mountpoint')
->from('share_external')
->where(
$qb->expr()->andX(
$qb->expr()->like('remote', $qb->createNamedParameter('%://' . str_replace('%', '', $instance) . '/')),
$qb->expr()->eq('remote_id', $qb->createNamedParameter($remoteId, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('share_token', $qb->createNamedParameter($shareToken)),
)
);

$result = $qb->executeQuery();
$row = $result->fetch();
if ($row === false) {
return [null, null];
}

return [$row['user'], $row['mountpoint']];
}

/**
* returns the mount using the id of a node,
* userid can then be extracted and used to retrieve the file's root folder
Expand Down
11 changes: 7 additions & 4 deletions lib/Db/ShareRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,30 @@ public function __construct(
/**
* returns list of existing federated shares providing access to a list
* of files, in relation to the specified instance.
* if instance is NULL then all instances are returned
*
* @param LocalFile[] $files
* @param string|null $instance
*
* @return FederatedShare[]
*/
public function getFederatedSharesRelatedToRemoteInstance(array $files, string $instance): array {
public function getFederatedSharesRelatedToRemoteInstance(array $files, ?string $instance = null): array {
$indexedFiles = $ids = [];
foreach ($files as $entry) {
$indexedFiles[$entry->getId()] = $entry;
$ids[] = $entry->getId();
}

$qb = $this->connection->getQueryBuilder();
$qb->select('s.id', 's.file_source', 's.share_type', 's.share_with', 's.permissions')
$qb->select('s.id', 's.file_source', 's.share_type', 's.share_with', 's.permissions', 's.token')
->from('share', 's')
->where(
$qb->expr()->andX(
$qb->expr()->in('file_source', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)),
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], IQueryBuilder::PARAM_INT_ARRAY)),
$qb->expr()->like('share_with', $qb->createNamedParameter('%@' . $instance)),
$qb->expr()->like('share_with', $qb->createNamedParameter('%@' . ($instance ?? '%'))),
),
$qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_CIRCLE], IQueryBuilder::PARAM_INT_ARRAY)),
)
Expand All @@ -57,14 +59,15 @@ public function getFederatedSharesRelatedToRemoteInstance(array $files, string $
$shares = [];
while ($row = $result->fetch()) {
$shareWith = $row['share_with'];
if (str_ends_with(strtolower($shareWith), '@' . strtolower($instance))) {
if ($instance !== null && str_ends_with(strtolower($shareWith), '@' . strtolower($instance))) {
$shareWith = substr($shareWith, 0, -strlen('@' . $instance));
}

$federatedShare = new FederatedShare();
$federatedShare->setId($row['id'])
->setFileId($row['file_source'])
->setShareType($row['share_type'])
->setShareToken($row['token'])
->setShareWith($shareWith)
->setPermissions($row['permissions'])
->setTarget($indexedFiles[$row['file_source']]);
Expand Down
15 changes: 15 additions & 0 deletions lib/Exceptions/RemoteIsLocalException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

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

namespace OCA\GlobalSiteSelector\Exceptions;

use Exception;

class RemoteIsLocalException extends Exception {
}
59 changes: 59 additions & 0 deletions lib/Listeners/SharedFileRefresh.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\GlobalSiteSelector\Listeners;

use OCA\GlobalSiteSelector\Service\GlobalShareService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* @template-implements IEventListener<NodeRenamedEvent|NodeDeletedEvent|NodeWrittenEvent>
*/
class SharedFileRefresh implements IEventListener {
public function __construct(
private readonly GlobalShareService $globalShareService,
private readonly LoggerInterface $logger,
) {
}

/**
* @param Event $event
*/
#[\Override]
public function handle(Event $event): void {
switch (get_class($event)) {
case NodeWrittenEvent::class:
$fileId = $event->getNode()->getId();
break;

case NodeRenamedEvent::class:
$fileId = $event->getTarget()->getId();
break;

case NodeDeletedEvent::class:
$fileId = $event->getNode()->getParentId();
break;

default:
return;
}

try {
// file is modified locally, broadcasting the event to other instances
$this->globalShareService->refreshFileAcrossGlobalScale($fileId);
} catch (Throwable $e) {
$this->logger->warning('issue while refreshing file across GS', ['exception' => $e]);
}
}
}
15 changes: 13 additions & 2 deletions lib/Model/FederatedShare.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class FederatedShare implements JsonSerializable {
private int $fileId = 0;
private int $shareType = 0;
private string $shareWith = '';
private string $shareToken = '';
private int $permissions = 0;
private bool $bounce = false;
private string $remote = '';
Expand Down Expand Up @@ -62,6 +63,15 @@ public function getShareWith(): string {
return $this->shareWith;
}

public function setShareToken(string $shareToken): self {
$this->shareToken = $shareToken;
return $this;
}

public function getShareToken(): string {
return $this->shareToken;
}

public function setPermissions(int $permissions): self {
$this->permissions = $permissions;
return $this;
Expand Down Expand Up @@ -120,6 +130,7 @@ public function import(array $data): self {
->setFileId($data['fileId'] ?? 0)
->setShareType($data['shareType'] ?? 0)
->setShareWith($data['shareWith'] ?? '')
->setShareToken($data['shareToken'] ?? '')
->setPermissions($data['permissions'] ?? 0);
}

Expand All @@ -133,7 +144,7 @@ public function import(array $data): self {
}

/**
* @return array{id: int, fileId: int, shareType: int, shareWith: string, permissions: int, target: array, remote: string, remoteId: int}
* @return array{id: int, fileId: int, shareType: int, shareWith: string, shareToken: string, permissions: int, target: array, remote: string, remoteId: int}
*/
#[\Override]
public function jsonSerialize(): array {
Expand All @@ -151,9 +162,9 @@ public function jsonSerialize(): array {
'fileId' => $this->getFileId(),
'shareType' => $this->getShareType(),
'shareWith' => $this->getShareWith(),
'shareToken' => $this->getShareToken(),
'permissions' => $this->getPermissions(),
'target' => $this->getTarget(),
];

}
}
Loading
Loading