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
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud.
Have a good time and manage whatever you want.

]]></description>
<version>2.4.0</version>
<version>2.5.0-dev.0</version>
<licence>AGPL-3.0-or-later</licence>
<author>Nextcloud GmbH and Nextcloud contributors</author>
<namespace>Tables</namespace>
Expand Down
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@
['name' => 'ApiTables#transfer', 'url' => '/api/2/tables/{id}/transfer', 'verb' => 'PUT'],
['name' => 'ApiTables#previewSchemeChanges', 'url' => '/api/2/tables/{id}/scheme/preview-changes', 'verb' => 'POST'],
['name' => 'ApiTables#importScheme', 'url' => '/api/2/tables/{id}/scheme/import', 'verb' => 'POST'],
['name' => 'ApiTables#archiveTable', 'url' => '/api/2/tables/{id}/archive', 'verb' => 'POST'],
['name' => 'ApiTables#unarchiveTable', 'url' => '/api/2/tables/{id}/archive', 'verb' => 'DELETE'],

['name' => 'ApiColumns#index', 'url' => '/api/2/columns/{nodeType}/{nodeId}', 'verb' => 'GET'],
['name' => 'ApiColumns#show', 'url' => '/api/2/columns/{id}', 'verb' => 'GET'],
Expand All @@ -159,6 +161,8 @@
['name' => 'Context#previewSchemeChanges', 'url' => '/api/2/contexts/{contextId}/scheme/preview-changes', 'verb' => 'POST'],
['name' => 'Context#importScheme', 'url' => '/api/2/contexts/{contextId}/scheme/import', 'verb' => 'POST'],
['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'],
['name' => 'Context#archiveContext', 'url' => '/api/2/contexts/{contextId}/archive', 'verb' => 'POST'],
['name' => 'Context#unarchiveContext', 'url' => '/api/2/contexts/{contextId}/archive', 'verb' => 'DELETE'],
['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'],

['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'],
Expand Down
6 changes: 6 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use OC\OCM\OCMSignatoryManager;
use OCA\Analytics\Datasource\DatasourceEvent;
use OCA\Circles\Events\CircleDestroyedEvent;
use OCA\Circles\Events\CircleMemberRemovedEvent;
use OCA\Tables\Capabilities;
use OCA\Tables\Config\ConfigLexicon;
use OCA\Tables\Event\RowDeletedEvent;
Expand All @@ -20,6 +21,7 @@
use OCA\Tables\Federation\FederationProvider;
use OCA\Tables\Listener\AddMissingIndicesListener;
use OCA\Tables\Listener\AnalyticsDatasourceListener;
use OCA\Tables\Listener\ArchiveCleanupListener;
use OCA\Tables\Listener\LoadAdditionalEntriesListener;
use OCA\Tables\Listener\LoadAdditionalListener;
use OCA\Tables\Listener\ReceiverCleanupListener;
Expand Down Expand Up @@ -50,6 +52,7 @@
use OCP\Federation\ICloudFederationProvider;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
use OCP\Security\Signature\ISignatoryManager;
Expand All @@ -64,6 +67,7 @@ class Application extends App implements IBootstrap {

public const NODE_TYPE_TABLE = 0;
public const NODE_TYPE_VIEW = 1;
public const NODE_TYPE_CONTEXT = 2;

public const OWNER_TYPE_USER = 0;

Expand Down Expand Up @@ -108,6 +112,8 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserDeletedEvent::class, ReceiverCleanupListener::class);
$context->registerEventListener(GroupDeletedEvent::class, ReceiverCleanupListener::class);
$context->registerEventListener(CircleDestroyedEvent::class, ReceiverCleanupListener::class);
$context->registerEventListener(UserRemovedEvent::class, ArchiveCleanupListener::class);
$context->registerEventListener(CircleMemberRemovedEvent::class, ArchiveCleanupListener::class);
$context->registerEventListener(LocalOCMDiscoveryEvent::class, ResourceTypeRegisterListener::class);

$context->registerSearchProvider(SearchTablesProvider::class);
Expand Down
4 changes: 4 additions & 0 deletions lib/AuditLog/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ enum Operation: string {
case ColumnDeleted = 'tables.column.deleted';
case ImportCompleted = 'tables.import.completed';
case ContextCreated = 'tables.context.created';
case ContextArchived = 'tables.context.archived';
case ContextUnarchived = 'tables.context.unarchived';
case ContextUpdated = 'tables.context.updated';
case ContextDeleted = 'tables.context.deleted';
case ContextTransferred = 'tables.context.transferred';
Expand Down Expand Up @@ -54,6 +56,8 @@ public function description(): string {
self::ColumnDeleted => 'A column was deleted with its values in all rows',
self::ImportCompleted => 'Rows were imported into a table or view from a spreadsheet, possibly creating columns',
self::ContextCreated => 'An application was created',
self::ContextArchived => 'An application was archived for all users it is shared with',
self::ContextUnarchived => 'An application was unarchived',
self::ContextUpdated => 'An application was changed, including tables and views added to or removed from it and changes to their permissions in it, which exposes them to the people the application is shared with',
self::ContextDeleted => 'An application was deleted with its pages and shares',
self::ContextTransferred => 'The ownership of an application was transferred to another user',
Expand Down
59 changes: 59 additions & 0 deletions lib/BackgroundJob/CleanupArchiveOverridesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\BackgroundJob;

use OCA\Tables\AppInfo\Application;
use OCA\Tables\Service\ArchiveCleanupService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use Psr\Log\LoggerInterface;

/**
* Drops the archive overrides of users who lost access to a node.
*
* Deciding this per user costs a full permission resolution, so it runs out
* of band: the triggering request (a share deletion, a group or team removal)
* only records which users to check for which node.
*/
class CleanupArchiveOverridesJob extends QueuedJob {
public function __construct(
ITimeFactory $time,
private readonly ArchiveCleanupService $archiveCleanupService,
private readonly LoggerInterface $logger,
) {
parent::__construct($time);
}

/**
* @param mixed $argument array{nodeType: int, nodeId: int, userIds: list<string>}
*/
protected function run($argument): void {
if (!is_array($argument)) {
$this->logger->warning('Cannot clean up archive overrides: invalid argument');
return;
}

$nodeType = (int)($argument['nodeType'] ?? -1);
$nodeId = (int)($argument['nodeId'] ?? 0);
$userIds = $argument['userIds'] ?? [];

if ($nodeId <= 0 || !is_array($userIds) || $userIds === []) {
return;
}
if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_CONTEXT], true)) {
$this->logger->warning('Cannot clean up archive overrides: unsupported node type ' . $nodeType);
return;
}

foreach ($userIds as $userId) {
$this->archiveCleanupService->removeOverrideIfStale((string)$userId, $nodeType, $nodeId);
}
}
}
6 changes: 4 additions & 2 deletions lib/Command/RenameTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ protected function configure(): void {
->addOption(
'archived',
'a',
InputOption::VALUE_NONE,
'Archived'
InputOption::VALUE_NEGATABLE,
'Archive (--archived) or unarchive (--no-archived) the table; the archived state is kept unchanged when omitted'
)
;
}
Expand All @@ -70,6 +70,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$id = $input->getArgument('ID');
$title = $input->getArgument('title');
$emoji = $input->getOption('emoji');
// A negatable option is null when neither form was given, which leaves the
// archived state unchanged rather than unarchiving the table.
$archived = $input->getOption('archived');
$description = $input->getOption('description');

Expand Down
9 changes: 6 additions & 3 deletions lib/Controller/Api1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ public function showScheme(int $tableId): JSONResponse|DataResponse {
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function getTable(int $tableId): DataResponse {
try {
return new DataResponse($this->tableService->find($tableId)->jsonSerialize());
$table = $this->userId === null
? $this->tableService->find($tableId)
: $this->tableService->getTableForUser($tableId, $this->userId);
return new DataResponse($table->jsonSerialize());
} catch (PermissionError $e) {
$this->logger->warning('A permission error occurred: ' . $e->getMessage(), ['exception' => $e]);
$message = ['message' => $e->getMessage()];
Expand All @@ -213,7 +216,7 @@ public function getTable(int $tableId): DataResponse {
* @param int $tableId Table ID
* @param string|null $title New table title
* @param string|null $emoji New table emoji
* @param bool $archived Whether the table is archived
* @param bool|null $archived Whether the table is archived; the state is kept unchanged when omitted
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Tables returned
Expand All @@ -226,7 +229,7 @@ public function getTable(int $tableId): DataResponse {
#[CORS]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')]
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
public function updateTable(int $tableId, ?string $title = null, ?string $emoji = null, ?bool $archived = false): DataResponse {
public function updateTable(int $tableId, ?string $title = null, ?string $emoji = null, ?bool $archived = null): DataResponse {
try {
return new DataResponse($this->tableService->update($tableId, $title, $emoji, null, $archived, $this->userId)->jsonSerialize());
} catch (InvalidArgumentException $e) {
Expand Down
61 changes: 60 additions & 1 deletion lib/Controller/ApiTablesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\IDBConnection;
use OCP\IL10N;
Expand Down Expand Up @@ -87,7 +88,7 @@ public function index(): DataResponse {
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
public function show(int $id): DataResponse {
try {
return new DataResponse($this->service->find($id)->jsonSerialize());
return new DataResponse($this->service->getTableForUser($id, $this->userId)->jsonSerialize());
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (InternalError $e) {
Expand Down Expand Up @@ -479,6 +480,64 @@ public function destroy(int $id): DataResponse {
}
}

/**
* [api v2] Archive a table for the requesting user
*
* Owners set the archived default for everyone; a user who made their own
* choice keeps it. Non-owners archive only for themselves.
*
* @param int $id Table ID
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Table returned with updated archived state
* 403: No permissions
* 404: Not found
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 20, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
public function archiveTable(int $id): DataResponse {
return $this->archiveResponse($id, true);
}

/**
* [api v2] Unarchive a table for the requesting user
*
* Owners set the unarchived default for everyone; a user who made their own
* choice keeps it. Non-owners remove only their personal archive override.
*
* @param int $id Table ID
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Table returned with updated archived state
* 403: No permissions
* 404: Not found
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 20, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
public function unarchiveTable(int $id): DataResponse {
return $this->archiveResponse($id, false);
}

/**
* @return DataResponse<Http::STATUS_OK, TablesTable, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*/
private function archiveResponse(int $id, bool $archived): DataResponse {
try {
$table = $archived
? $this->service->archiveTable($id, $this->userId)
: $this->service->unarchiveTable($id, $this->userId);
return new DataResponse($table->jsonSerialize());
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (InternalError $e) {
return $this->handleError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
}
}

/**
* [api v2] Transfer table
*
Expand Down
59 changes: 59 additions & 0 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\DB\Exception;
use OCP\IDBConnection;
Expand Down Expand Up @@ -255,6 +256,64 @@ public function transfer(int $contextId, string $newOwnerId, int $newOwnerType =
}
}

/**
* [api v2] Archive a context for the requesting user
*
* Owners set the archived default for everyone; a user who made their own
* choice keeps it. Non-owners archive only for themselves.
*
* @param int $contextId ID of the context
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Context returned with updated archived state
* 403: No permissions
* 404: Context not found or not available
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 20, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')]
public function archiveContext(int $contextId): DataResponse {
return $this->archiveResponse($contextId, true);
}

/**
* [api v2] Unarchive a context for the requesting user
*
* Owners set the unarchived default for everyone; a user who made their own
* choice keeps it. Non-owners remove only their personal archive override.
*
* @param int $contextId ID of the context
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Context returned with updated archived state
* 403: No permissions
* 404: Context not found or not available
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 20, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')]
public function unarchiveContext(int $contextId): DataResponse {
return $this->archiveResponse($contextId, false);
}

/**
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*/
private function archiveResponse(int $contextId, bool $archived): DataResponse {
try {
$context = $archived
? $this->contextService->archiveContext($contextId, $this->userId)
: $this->contextService->unarchiveContext($contextId, $this->userId);
return new DataResponse($context->jsonSerialize());
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (InternalError|Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Update the order on a page of a context
*
Expand Down
2 changes: 1 addition & 1 deletion lib/Controller/TableController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function index(): DataResponse {
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_READ, type: Application::NODE_TYPE_TABLE, idParam: 'id')]
public function show(int $id): DataResponse {
return $this->handleError(fn () => $this->service->find($id));
return $this->handleError(fn () => $this->service->getTableForUser($id, $this->userId));
}

#[NoAdminRequired]
Expand Down
7 changes: 6 additions & 1 deletion lib/Db/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
* @method setOwnerId(string $value): void
* @method getOwnerType(): int
* @method setOwnerType(int $value): void
* @method isArchived(): bool
* @method setArchived(bool $value): void
*
* @method getSharing(): array
* @method setSharing(array $value): void
Expand All @@ -35,6 +37,7 @@ class Context extends EntitySuper implements JsonSerializable {
protected ?string $description = null;
protected ?string $ownerId = null;
protected ?int $ownerType = null;
protected bool $archived = false;

// virtual properties
protected ?array $sharing = null;
Expand All @@ -46,6 +49,7 @@ class Context extends EntitySuper implements JsonSerializable {
public function __construct() {
$this->addType('id', 'integer');
$this->addType('owner_type', 'integer');
$this->addType('archived', 'boolean');
}

public function jsonSerialize(): array {
Expand All @@ -56,7 +60,8 @@ public function jsonSerialize(): array {
'iconName' => $this->getIcon(),
'description' => $this->getDescription(),
'owner' => $this->getOwnerId(),
'ownerType' => $this->getOwnerType()
'ownerType' => $this->getOwnerType(),
'archived' => $this->isArchived(),
];

// extended data
Expand Down
Loading
Loading