Skip to content

Commit d6ab44d

Browse files
committed
feat: add permissions for public link shares
Signed-off-by: Benjamin Frueh <benjamin.frueh@gmail.com>
1 parent 5339678 commit d6ab44d

31 files changed

Lines changed: 2038 additions & 144 deletions

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Have a good time and manage whatever you want.
5353
<repair-steps>
5454
<pre-migration>
5555
<step>OCA\Tables\Migration\FixContextsDefaults</step>
56+
<step>OCA\Tables\Migration\ResetPublicSharePermissions</step>
5657
</pre-migration>
5758
<post-migration>
5859
<step>OCA\Tables\Migration\NewDbStructureRepairStep</step>

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
['name' => 'share#show', 'url' => '/share/{id}', 'verb' => 'GET'],
9898
['name' => 'share#create', 'url' => '/share', 'verb' => 'POST'],
9999
['name' => 'share#updatePermission', 'url' => '/share/{id}/permission', 'verb' => 'PUT'],
100+
['name' => 'share#updatePermissions', 'url' => '/share/{id}/permissions', 'verb' => 'PUT'],
100101
['name' => 'share#updateDisplayMode', 'url' => '/share/{id}/display-mode', 'verb' => 'PUT'],
101102
['name' => 'share#destroy', 'url' => '/share/{id}', 'verb' => 'DELETE'],
102103

lib/Controller/Api1Controller.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ public function deleteShare(int $shareId): DataResponse {
672672
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
673673
public function updateSharePermissions(int $shareId, string $permissionType, bool $permissionValue): DataResponse {
674674
try {
675-
return new DataResponse($this->shareService->updatePermission($shareId, $permissionType, $permissionValue)->jsonSerialize());
675+
return new DataResponse($this->shareService->updatePermission($shareId, [$permissionType => $permissionValue])->jsonSerialize());
676676
} catch (PermissionError $e) {
677677
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
678678
$message = ['message' => $e->getMessage()];

lib/Controller/PublicRowOCSController.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
namespace OCA\Tables\Controller;
1010

1111
use OCA\Tables\AppInfo\Application;
12+
use OCA\Tables\Db\Row2Mapper;
1213
use OCA\Tables\Errors\BadRequestError;
1314
use OCA\Tables\Errors\InternalError;
1415
use OCA\Tables\Errors\NotFoundError;
1516
use OCA\Tables\Errors\PermissionError;
1617
use OCA\Tables\Helper\ConversionHelper;
1718
use OCA\Tables\Middleware\Attribute\AssertShareAccessIsAccessible;
19+
use OCA\Tables\Model\RowDataInput;
1820
use OCA\Tables\ResponseDefinitions;
1921
use OCA\Tables\Service\RowService;
2022
use OCA\Tables\Service\ShareService;
@@ -37,11 +39,13 @@ class PublicRowOCSController extends AOCSController {
3739
public function __construct(
3840
protected ShareService $shareService,
3941
protected RowService $rowService,
42+
protected Row2Mapper $row2Mapper,
4043
IRequest $request,
4144
LoggerInterface $logger,
4245
IL10N $l,
4346
) {
4447
parent::__construct($request, $logger, $l, '');
48+
$this->rowService->setPublicContext();
4549
}
4650

4751
/**
@@ -68,6 +72,10 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse
6872
$shareToken = new ShareToken($token);
6973
$share = $this->shareService->findByToken($shareToken);
7074

75+
if (!$share->getPermissionRead()) {
76+
return $this->handlePermissionError(new PermissionError('No read permission on this share'));
77+
}
78+
7179
$limit = $limit !== null ? max(0, min(500, $limit)) : null;
7280
$offset = $offset !== null ? max(0, $offset) : null;
7381

@@ -90,4 +98,165 @@ public function getRows(string $token, ?int $limit, ?int $offset): DataResponse
9098
return $this->handleBadRequestError($e);
9199
}
92100
}
101+
102+
/**
103+
* [api v2] Create a row in a link share
104+
*
105+
* @param string $token The share token
106+
* @param string|array<string, mixed> $data An array containing the column identifiers and their values
107+
* @return DataResponse<Http::STATUS_OK, TablesPublicRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
108+
*
109+
* 200: Row created
110+
* 400: Invalid request parameters
111+
* 403: No permissions
112+
* 404: Not found
113+
* 500: Internal error
114+
*/
115+
#[PublicPage]
116+
#[AssertShareAccessIsAccessible]
117+
#[ApiRoute(verb: 'POST', url: '/api/2/public/{token}/rows', requirements: ['token' => '[a-zA-Z0-9]{16}'])]
118+
#[OpenAPI]
119+
#[AnonRateLimit(limit: 20, period: 30)]
120+
public function createRow(string $token, mixed $data): DataResponse {
121+
try {
122+
$shareToken = new ShareToken($token);
123+
$share = $this->shareService->findByToken($shareToken);
124+
$this->row2Mapper->setUserId('public-' . $token);
125+
126+
if (!$share->getPermissionCreate()) {
127+
return $this->handlePermissionError(new PermissionError('No create permission on this share'));
128+
}
129+
130+
if (is_string($data)) {
131+
$data = json_decode($data, true);
132+
}
133+
if (!is_array($data)) {
134+
return $this->handleBadRequestError(new BadRequestError('Invalid data input'));
135+
}
136+
137+
$newRowData = new RowDataInput();
138+
foreach ($data as $key => $value) {
139+
$newRowData->add((int)$key, $value);
140+
}
141+
142+
$tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null;
143+
$viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null;
144+
145+
if ($viewId === null && $tableId === null) {
146+
throw new InternalError('Cannot create row without table or view provided');
147+
}
148+
149+
$row = $this->rowService->create($tableId, $viewId, $newRowData);
150+
return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]);
151+
} catch (PermissionError $e) {
152+
return $this->handlePermissionError($e);
153+
} catch (NotFoundError $e) {
154+
return $this->handleNotFoundError($e);
155+
} catch (BadRequestError $e) {
156+
return $this->handleBadRequestError($e);
157+
} catch (InternalError|\Exception $e) {
158+
return $this->handleError($e);
159+
}
160+
}
161+
162+
/**
163+
* [api v2] Update a row in a link share
164+
*
165+
* @param string $token The share token
166+
* @param int $rowId The row identifier
167+
* @param string|array<string, mixed> $data An array containing the column identifiers and their values
168+
* @return DataResponse<Http::STATUS_OK, TablesPublicRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
169+
*
170+
* 200: Row updated
171+
* 400: Invalid request parameters
172+
* 403: No permissions
173+
* 404: Not found
174+
* 500: Internal error
175+
*/
176+
#[PublicPage]
177+
#[AssertShareAccessIsAccessible]
178+
#[ApiRoute(verb: 'PUT', url: '/api/2/public/{token}/rows/{rowId}', requirements: ['token' => '[a-zA-Z0-9]{16}', 'rowId' => '\d+'])]
179+
#[OpenAPI]
180+
#[AnonRateLimit(limit: 20, period: 30)]
181+
public function updateRow(string $token, int $rowId, mixed $data): DataResponse {
182+
try {
183+
$shareToken = new ShareToken($token);
184+
$share = $this->shareService->findByToken($shareToken);
185+
$this->row2Mapper->setUserId('public-' . $token);
186+
187+
if (!$share->getPermissionUpdate()) {
188+
return $this->handlePermissionError(new PermissionError('No update permission on this share'));
189+
}
190+
191+
if (is_string($data)) {
192+
$data = json_decode($data, true);
193+
}
194+
if (!is_array($data)) {
195+
return $this->handleBadRequestError(new BadRequestError('Invalid data input'));
196+
}
197+
198+
$viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null;
199+
$tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null;
200+
201+
if ($viewId === null && $tableId === null) {
202+
throw new InternalError('Cannot update row without table or view provided');
203+
}
204+
205+
$row = $this->rowService->updateSet($rowId, $viewId, $data, '', $tableId);
206+
return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]);
207+
} catch (PermissionError $e) {
208+
return $this->handlePermissionError($e);
209+
} catch (NotFoundError $e) {
210+
return $this->handleNotFoundError($e);
211+
} catch (BadRequestError $e) {
212+
return $this->handleBadRequestError($e);
213+
} catch (InternalError|\Exception $e) {
214+
return $this->handleError($e);
215+
}
216+
}
217+
218+
/**
219+
* [api v2] Delete a row in a link share
220+
*
221+
* @param string $token The share token
222+
* @param int $rowId The row identifier
223+
* @return DataResponse<Http::STATUS_OK, TablesPublicRow, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
224+
*
225+
* 200: Row deleted
226+
* 403: No permissions
227+
* 404: Not found
228+
* 500: Internal error
229+
*/
230+
#[PublicPage]
231+
#[AssertShareAccessIsAccessible]
232+
#[ApiRoute(verb: 'DELETE', url: '/api/2/public/{token}/rows/{rowId}', requirements: ['token' => '[a-zA-Z0-9]{16}', 'rowId' => '\d+'])]
233+
#[OpenAPI]
234+
#[AnonRateLimit(limit: 20, period: 30)]
235+
public function deleteRow(string $token, int $rowId): DataResponse {
236+
try {
237+
$shareToken = new ShareToken($token);
238+
$share = $this->shareService->findByToken($shareToken);
239+
$this->row2Mapper->setUserId('public-' . $token);
240+
241+
if (!$share->getPermissionDelete()) {
242+
return $this->handlePermissionError(new PermissionError('No delete permission on this share'));
243+
}
244+
245+
$viewId = $share->getNodeType() === 'view' ? $share->getNodeId() : null;
246+
$tableId = $share->getNodeType() === 'table' ? $share->getNodeId() : null;
247+
248+
if ($viewId === null && $tableId === null) {
249+
throw new InternalError('Cannot delete row without table or view provided');
250+
}
251+
252+
$row = $this->rowService->delete($rowId, $viewId, '', $tableId);
253+
return new DataResponse($this->rowService->formatRowsForPublicShare([$row])[0]);
254+
} catch (PermissionError $e) {
255+
return $this->handlePermissionError($e);
256+
} catch (NotFoundError $e) {
257+
return $this->handleNotFoundError($e);
258+
} catch (InternalError|\Exception $e) {
259+
return $this->handleError($e);
260+
}
261+
}
93262
}

lib/Controller/PublicSharePageController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ public function showShare(): TemplateResponse {
7171
$this->initialState->provideInitialState('shareToken', (string)$this->shareToken);
7272
$this->initialState->provideInitialState('nodeType', $this->share->getNodeType());
7373
$this->initialState->provideInitialState('nodeData', $nodeData);
74+
$this->initialState->provideInitialState('sharePermissions', [
75+
'read' => $this->share->getPermissionRead(),
76+
'create' => $this->share->getPermissionCreate(),
77+
'update' => $this->share->getPermissionUpdate(),
78+
'delete' => $this->share->getPermissionDelete(),
79+
]);
7480

7581
if (class_exists(LoadEditor::class)) {
7682
$this->eventDispatcher->dispatchTyped(new LoadEditor());

lib/Controller/ShareController.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,25 @@ public function create(
8585
#[NoAdminRequired]
8686
public function updatePermission(int $id, string $permission, bool $value): DataResponse {
8787
return $this->handleError(function () use ($id, $permission, $value) {
88-
return $this->service->updatePermission($id, $permission, $value);
88+
return $this->service->updatePermission($id, [$permission => $value]);
89+
});
90+
}
91+
92+
#[NoAdminRequired]
93+
public function updatePermissions(
94+
int $id,
95+
bool $permissionRead = false,
96+
bool $permissionCreate = false,
97+
bool $permissionUpdate = false,
98+
bool $permissionDelete = false,
99+
): DataResponse {
100+
return $this->handleError(function () use ($id, $permissionRead, $permissionCreate, $permissionUpdate, $permissionDelete) {
101+
return $this->service->updatePermission($id, [
102+
'read' => $permissionRead,
103+
'create' => $permissionCreate,
104+
'update' => $permissionUpdate && $permissionRead,
105+
'delete' => $permissionDelete && $permissionRead,
106+
]);
89107
});
90108
}
91109

lib/Db/Row2Mapper.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ public function getTableIdForRow(int $rowId): ?int {
122122
return $rowSleeve->getTableId();
123123
}
124124

125+
public function setUserId(string $userId): void {
126+
$this->userId = $userId;
127+
}
128+
125129
/**
126130
* @return int[]
127131
* @throws InternalError
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\Tables\Migration;
11+
12+
use OCA\Tables\AppInfo\Application;
13+
use OCA\Tables\Constants\ShareReceiverType;
14+
use OCP\DB\QueryBuilder\IQueryBuilder;
15+
use OCP\IConfig;
16+
use OCP\IDBConnection;
17+
use OCP\Migration\IOutput;
18+
use OCP\Migration\IRepairStep;
19+
20+
class ResetPublicSharePermissions implements IRepairStep {
21+
22+
public function __construct(
23+
protected IConfig $config,
24+
protected IDBConnection $dbc,
25+
) {
26+
}
27+
28+
public function getName(): string {
29+
return 'Reset public link share permissions to read-only for versions before 2.0.2';
30+
}
31+
32+
public function run(IOutput $output): void {
33+
$appVersion = $this->config->getAppValue(Application::APP_ID, 'installed_version', '0.0');
34+
if (\version_compare($appVersion, '2.0.2', '>=')) {
35+
$output->info('Not applicable, skipping.');
36+
return;
37+
}
38+
39+
$qb = $this->dbc->getQueryBuilder();
40+
$qb->update('tables_shares')
41+
->set('permission_read', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))
42+
->set('permission_create', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
43+
->set('permission_update', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
44+
->set('permission_delete', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
45+
->set('permission_manage', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
46+
->where($qb->expr()->eq('receiver_type', $qb->createNamedParameter(ShareReceiverType::LINK, IQueryBuilder::PARAM_STR)))
47+
->executeStatement();
48+
49+
$output->info('Reset public link share permissions to read-only.');
50+
}
51+
}

lib/Service/PermissionsService.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class PermissionsService {
4545

4646
protected bool $isCli = false;
4747

48+
private bool $isPublicContext = false;
49+
4850
private ContextMapper $contextMapper;
4951

5052
public function __construct(
@@ -549,6 +551,11 @@ public function getPermissionArrayForNodeFromContexts(int $nodeId, string $nodeT
549551
);
550552
}
551553

554+
public function setPublicContext(): void {
555+
$this->userId = '';
556+
$this->isPublicContext = true;
557+
}
558+
552559
private function hasPermission(int $existingPermissions, string $permissionName): bool {
553560
$constantName = 'PERMISSION_' . strtoupper($permissionName);
554561
try {
@@ -634,7 +641,7 @@ private function basisCheck(Table|View|Context $element, string $nodeType, ?stri
634641
}
635642

636643
if ($userId === '') {
637-
return true;
644+
return $this->isCli || $this->isPublicContext;
638645
}
639646

640647
if ($this->userIsElementOwner($element, $userId, $nodeType)) {

lib/Service/RowService.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ public function find(int $rowId): Row2 {
178178
* @throws InternalError
179179
*/
180180
public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): Row2 {
181-
if ($this->userId === null || $this->userId === '') {
181+
if ($this->userId === null) {
182182
$e = new \Exception('No user id in context, but needed.');
183183
$this->logger->error($e->getMessage(), ['exception' => $e]);
184184
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
@@ -677,7 +677,7 @@ public function updateSet(
677677
* @throws PermissionError
678678
* @noinspection DuplicatedCode
679679
*/
680-
public function delete(int $id, ?int $viewId, string $userId): Row2 {
680+
public function delete(int $id, ?int $viewId, string $userId, ?int $tableId = null): Row2 {
681681
try {
682682
$item = $this->getRowById($id);
683683
} catch (InternalError $e) {
@@ -712,6 +712,12 @@ public function delete(int $id, ?int $viewId, string $userId): Row2 {
712712
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
713713
}
714714
} else {
715+
if ($tableId !== null && $tableId !== $item->getTableId()) {
716+
$e = new \Exception('Row does not belong to table with id ' . $tableId);
717+
$this->logger->error($e->getMessage(), ['exception' => $e]);
718+
throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage());
719+
}
720+
715721
// security
716722
if (!$this->permissionsService->canReadRowsByElementId($item->getTableId(), 'table', $userId)) {
717723
$e = new \Exception('Row not found.');

0 commit comments

Comments
 (0)