Skip to content
Open
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
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 @@ -56,7 +56,7 @@ protected function configure(): void {
'archived',
'a',
InputOption::VALUE_NONE,
'Archived'
'Archive the table; the archived state is kept unchanged when omitted'
)
;
}
Expand All @@ -70,7 +70,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$id = $input->getArgument('ID');
$title = $input->getArgument('title');
$emoji = $input->getOption('emoji');
$archived = $input->getOption('archived');
// VALUE_NONE yields false when absent; map that to null so an omitted
// flag leaves the archived state unchanged instead of unarchiving
$archived = $input->getOption('archived') ? true : null;
$description = $input->getOption('description');

try {
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 @@ -27,6 +27,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 @@ -85,7 +86,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 @@ -469,6 +470,64 @@ public function destroy(int $id): DataResponse {
}
}

/**
* [api v2] Archive a table for the requesting user
*
* Owners archive the table for all users (clears per-user overrides).
* 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 unarchive the table for all users (clears per-user overrides).
* 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 archive the context for all users (clears per-user overrides).
* 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 unarchive the context for all users (clears per-user overrides).
* 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
1 change: 1 addition & 0 deletions lib/Db/ContextMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ protected function formatResultRows(array $rows, ?string $userId) {
'description' => $rows[0]['description'],
'owner_id' => $rows[0]['owner_id'],
'owner_type' => $rows[0]['owner_type'],
'archived' => (bool)($rows[0]['archived'] ?? false),
];

$formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) {
Expand Down
Loading
Loading