Skip to content

Commit ed5622a

Browse files
committed
trigger remote sync on local file modification
Signed-off-by: Maxence Lange <maxence@artificial-owl.com>
1 parent 08afeb6 commit ed5622a

11 files changed

Lines changed: 315 additions & 15 deletions

File tree

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
['name' => 'Slave#createAppToken', 'url' => '/v1/createapptoken', 'verb' => 'GET'],
1212
['name' => 'Slave#discovery', 'url' => '/discovery', 'verb' => 'GET'],
1313
['name' => 'Slave#sharedFile', 'url' => '/sharedfile', 'verb' => 'GET'],
14+
['name' => 'Slave#refreshSharedFile', 'url' => '/refreshSharedFile', 'verb' => 'GET'],
1415
],
1516
'routes' => [
1617
[

lib/AppInfo/Application.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OCA\GlobalSiteSelector\GlobalSiteSelector;
1717
use OCA\GlobalSiteSelector\Listeners\AddContentSecurityPolicyListener;
1818
use OCA\GlobalSiteSelector\Listeners\DeletingUser;
19+
use OCA\GlobalSiteSelector\Listeners\SharedFileRefresh;
1920
use OCA\GlobalSiteSelector\Listeners\UserChanged;
2021
use OCA\GlobalSiteSelector\Listeners\UserCreated;
2122
use OCA\GlobalSiteSelector\Listeners\UserDeleted;
@@ -30,6 +31,9 @@
3031
use OCP\AppFramework\Bootstrap\IBootstrap;
3132
use OCP\AppFramework\Bootstrap\IRegistrationContext;
3233
use OCP\EventDispatcher\IEventDispatcher;
34+
use OCP\Files\Events\Node\NodeDeletedEvent;
35+
use OCP\Files\Events\Node\NodeRenamedEvent;
36+
use OCP\Files\Events\Node\NodeWrittenEvent;
3337
use OCP\IRequest;
3438
use OCP\IUser;
3539
use OCP\IUserManager;
@@ -86,6 +90,10 @@ public function register(IRegistrationContext $context): void {
8690
$context->registerEventListener(UserLoggedOutEvent::class, UserLoggedOut::class);
8791
$context->registerEventListener(UserChangedEvent::class, UserChanged::class);
8892

93+
$context->registerEventListener(NodeWrittenEvent::class, SharedFileRefresh::class);
94+
$context->registerEventListener(NodeRenamedEvent::class, SharedFileRefresh::class);
95+
$context->registerEventListener(NodeDeletedEvent::class, SharedFileRefresh::class);
96+
8997
$context->registerSetupCheck(LongJwtKeySetupCheck::class);
9098
$context->registerConfigLexicon(ConfigLexicon::class);
9199

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-only
8+
*/
9+
10+
namespace OCA\GlobalSiteSelector\BackgroundJobs;
11+
12+
use OCA\GlobalSiteSelector\ConfigLexicon;
13+
use OCA\GlobalSiteSelector\Service\GlobalShareService;
14+
use OCP\AppFramework\Utility\ITimeFactory;
15+
use OCP\BackgroundJob\QueuedJob;
16+
17+
/**
18+
* This is called to notify a remote instance that a local shared
19+
* document has been modified. This is a background job that might
20+
* be initiated if a file is shared to too many different instances.
21+
* Limit is set via {@see ConfigLexicon::INSTANCE_MAIN_THREAD}
22+
*/
23+
class NotifyRemoteFile extends QueuedJob {
24+
public function __construct(
25+
ITimeFactory $time,
26+
private readonly GlobalShareService $globalShareService,
27+
) {
28+
parent::__construct($time);
29+
}
30+
31+
#[\Override]
32+
protected function run($argument): void {
33+
$instances = $argument['instances'] ?? [];
34+
foreach ($instances as $instance => $shares) {
35+
$this->globalShareService->requestRemoteFileRefresh($instance, $shares);
36+
}
37+
}
38+
}

lib/ConfigLexicon.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class ConfigLexicon implements ILexicon {
1717
public const GS_TOKENS = 'globalScaleTokens';
1818
public const LOCAL_TOKEN = 'localToken';
1919
public const REDIRECT_WEBDAV = 'redirectWebDAV';
20+
public const INSTANCE_MAIN_THREAD = 'requested_instance_main_thread';
2021

2122
#[\Override]
2223
public function getStrictness(): Strictness {
@@ -32,6 +33,7 @@ public function getAppConfigs(): array {
3233
new Entry(key: self::GS_TOKENS, type: ValueType::ARRAY, defaultRaw: [], definition: 'list of token+host to navigate through GlobalScale', lazy: true),
3334
new Entry(key: self::LOCAL_TOKEN, type: ValueType::STRING, defaultRaw: '', definition: 'local token to id instance within GlobalScale', lazy: true),
3435
new Entry(key: self::REDIRECT_WEBDAV, type: ValueType::BOOL, defaultRaw: false, definition: 'redirect WebDAV request on Master to Slaves', lazy: false),
36+
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),
3537
];
3638
}
3739

lib/Controller/SlaveController.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OCA\GlobalSiteSelector\Exceptions\MasterUrlException;
1616
use OCA\GlobalSiteSelector\Exceptions\SharedFileException;
1717
use OCA\GlobalSiteSelector\GlobalSiteSelector;
18+
use OCA\GlobalSiteSelector\Model\FederatedShare;
1819
use OCA\GlobalSiteSelector\Model\LocalFile;
1920
use OCA\GlobalSiteSelector\Service\GlobalScaleService;
2021
use OCA\GlobalSiteSelector\Service\GlobalShareService;
@@ -126,6 +127,35 @@ public function sharedFile(string $jwt): DataResponse {
126127
}
127128

128129

130+
131+
/**
132+
* initiate refresh on local versions of a remote federated file.
133+
* request must contain encoded jwt.
134+
*/
135+
#[PublicPage]
136+
#[NoCSRFRequired]
137+
public function refreshSharedFile(string $jwt): DataResponse {
138+
$key = $this->gss->getJwtKey();
139+
$decoded = (array)JWT::decode($jwt, new Key($key, Application::JWT_ALGORITHM));
140+
// JWT store data as stdClass, not array
141+
$decoded = json_decode(json_encode($decoded), true);
142+
$this->logger->debug('decoded request', ['data' => $decoded]);
143+
$instance = $decoded['instance'] ?? '';
144+
145+
foreach ($decoded['shares'] as $entry) {
146+
$federatedShare = new FederatedShare();
147+
$federatedShare->import($entry);
148+
$this->globalShareService->refreshSharedTarget(
149+
$instance,
150+
$federatedShare->getId(),
151+
$federatedShare->getShareToken(),
152+
$federatedShare->getTarget()
153+
);
154+
}
155+
156+
return new DataResponse([]);
157+
}
158+
129159
#[PublicPage]
130160
#[NoCSRFRequired]
131161
#[UseSession]

lib/Db/FileRequest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,34 @@ public function getTeamStorages(FederatedShare $federatedShare, string $instance
185185
return $storage;
186186
}
187187

188+
/**
189+
* returns an array containing user and mountpoint from an external share; based on remote
190+
* instance that owns the file, the shareId on the remote instance and
191+
* the share token.
192+
*
193+
* If not known, user and mountpoint are null in the returned array.
194+
*/
195+
public function getMountPointFromShare(string $instance, int $remoteId, string $shareToken): array {
196+
$qb = $this->connection->getQueryBuilder();
197+
$qb->select('user', 'mountpoint')
198+
->from('share_external')
199+
->where(
200+
$qb->expr()->andX(
201+
$qb->expr()->like('remote', $qb->createNamedParameter('%://' . str_replace('%', '', $instance) . '/')),
202+
$qb->expr()->eq('remote_id', $qb->createNamedParameter($remoteId, IQueryBuilder::PARAM_INT)),
203+
$qb->expr()->eq('share_token', $qb->createNamedParameter($shareToken)),
204+
)
205+
);
206+
207+
$result = $qb->executeQuery();
208+
$row = $result->fetch();
209+
if ($row === false) {
210+
return [null, null];
211+
}
212+
213+
return [$row['user'], $row['mountpoint']];
214+
}
215+
188216
/**
189217
* returns the mount using the id of a node,
190218
* userid can then be extracted and used to retrieve the file's root folder

lib/Db/ShareRequest.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,30 @@ public function __construct(
2525
/**
2626
* returns list of existing federated shares providing access to a list
2727
* of files, in relation to the specified instance.
28+
* if instance is NULL then all instances are returned
2829
*
2930
* @param LocalFile[] $files
31+
* @param string|null $instance
3032
*
3133
* @return FederatedShare[]
3234
*/
33-
public function getFederatedSharesRelatedToRemoteInstance(array $files, string $instance): array {
35+
public function getFederatedSharesRelatedToRemoteInstance(array $files, ?string $instance = null): array {
3436
$indexedFiles = $ids = [];
3537
foreach ($files as $entry) {
3638
$indexedFiles[$entry->getId()] = $entry;
3739
$ids[] = $entry->getId();
3840
}
3941

4042
$qb = $this->connection->getQueryBuilder();
41-
$qb->select('s.id', 's.file_source', 's.share_type', 's.share_with', 's.permissions')
43+
$qb->select('s.id', 's.file_source', 's.share_type', 's.share_with', 's.permissions', 's.token')
4244
->from('share', 's')
4345
->where(
4446
$qb->expr()->andX(
4547
$qb->expr()->in('file_source', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)),
4648
$qb->expr()->orX(
4749
$qb->expr()->andX(
4850
$qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], IQueryBuilder::PARAM_INT_ARRAY)),
49-
$qb->expr()->like('share_with', $qb->createNamedParameter('%@' . $instance)),
51+
$qb->expr()->like('share_with', $qb->createNamedParameter('%@' . ($instance ?? '%'))),
5052
),
5153
$qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_CIRCLE], IQueryBuilder::PARAM_INT_ARRAY)),
5254
)
@@ -57,14 +59,15 @@ public function getFederatedSharesRelatedToRemoteInstance(array $files, string $
5759
$shares = [];
5860
while ($row = $result->fetch()) {
5961
$shareWith = $row['share_with'];
60-
if (str_ends_with(strtolower($shareWith), '@' . strtolower($instance))) {
62+
if ($instance !== null && str_ends_with(strtolower($shareWith), '@' . strtolower($instance))) {
6163
$shareWith = substr($shareWith, 0, -strlen('@' . $instance));
6264
}
6365

6466
$federatedShare = new FederatedShare();
6567
$federatedShare->setId($row['id'])
6668
->setFileId($row['file_source'])
6769
->setShareType($row['share_type'])
70+
->setShareToken($row['token'])
6871
->setShareWith($shareWith)
6972
->setPermissions($row['permissions'])
7073
->setTarget($indexedFiles[$row['file_source']]);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\GlobalSiteSelector\Exceptions;
11+
12+
use Exception;
13+
14+
class RemoteIsLocalException extends Exception {
15+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\GlobalSiteSelector\Listeners;
10+
11+
use OCA\GlobalSiteSelector\Service\GlobalShareService;
12+
use OCP\EventDispatcher\Event;
13+
use OCP\EventDispatcher\IEventListener;
14+
use OCP\Files\Events\Node\NodeDeletedEvent;
15+
use OCP\Files\Events\Node\NodeRenamedEvent;
16+
use OCP\Files\Events\Node\NodeWrittenEvent;
17+
use Psr\Log\LoggerInterface;
18+
use Throwable;
19+
20+
/**
21+
* @template-implements IEventListener<NodeRenamedEvent|NodeDeletedEvent|NodeWrittenEvent>
22+
*/
23+
class SharedFileRefresh implements IEventListener {
24+
public function __construct(
25+
private readonly GlobalShareService $globalShareService,
26+
private readonly LoggerInterface $logger,
27+
) {
28+
}
29+
30+
/**
31+
* @param Event $event
32+
*/
33+
#[\Override]
34+
public function handle(Event $event): void {
35+
switch (get_class($event)) {
36+
case NodeWrittenEvent::class:
37+
$fileId = $event->getNode()->getId();
38+
break;
39+
40+
case NodeRenamedEvent::class:
41+
$fileId = $event->getTarget()->getId();
42+
break;
43+
44+
case NodeDeletedEvent::class:
45+
$fileId = $event->getNode()->getParentId();
46+
break;
47+
48+
default:
49+
return;
50+
}
51+
52+
try {
53+
// file is modified locally, broadcasting the event to other instances
54+
$this->globalShareService->refreshFileAcrossGlobalScale($fileId);
55+
} catch (Throwable $e) {
56+
$this->logger->warning('issue while refreshing file across GS', ['exception' => $e]);
57+
}
58+
}
59+
}

lib/Model/FederatedShare.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class FederatedShare implements JsonSerializable {
1616
private int $fileId = 0;
1717
private int $shareType = 0;
1818
private string $shareWith = '';
19+
private string $shareToken = '';
1920
private int $permissions = 0;
2021
private bool $bounce = false;
2122
private string $remote = '';
@@ -62,6 +63,15 @@ public function getShareWith(): string {
6263
return $this->shareWith;
6364
}
6465

66+
public function setShareToken(string $shareToken): self {
67+
$this->shareToken = $shareToken;
68+
return $this;
69+
}
70+
71+
public function getShareToken(): string {
72+
return $this->shareToken;
73+
}
74+
6575
public function setPermissions(int $permissions): self {
6676
$this->permissions = $permissions;
6777
return $this;
@@ -120,6 +130,7 @@ public function import(array $data): self {
120130
->setFileId($data['fileId'] ?? 0)
121131
->setShareType($data['shareType'] ?? 0)
122132
->setShareWith($data['shareWith'] ?? '')
133+
->setShareToken($data['shareToken'] ?? '')
123134
->setPermissions($data['permissions'] ?? 0);
124135
}
125136

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

135146
/**
136-
* @return array{id: int, fileId: int, shareType: int, shareWith: string, permissions: int, target: array, remote: string, remoteId: int}
147+
* @return array{id: int, fileId: int, shareType: int, shareWith: string, shareToken: string, permissions: int, target: array, remote: string, remoteId: int}
137148
*/
138149
#[\Override]
139150
public function jsonSerialize(): array {
@@ -151,9 +162,9 @@ public function jsonSerialize(): array {
151162
'fileId' => $this->getFileId(),
152163
'shareType' => $this->getShareType(),
153164
'shareWith' => $this->getShareWith(),
165+
'shareToken' => $this->getShareToken(),
154166
'permissions' => $this->getPermissions(),
155167
'target' => $this->getTarget(),
156168
];
157-
158169
}
159170
}

0 commit comments

Comments
 (0)