diff --git a/apps/files_sharing/appinfo/routes.php b/apps/files_sharing/appinfo/routes.php index 91f8271c143bb..b741e9e98c91f 100644 --- a/apps/files_sharing/appinfo/routes.php +++ b/apps/files_sharing/appinfo/routes.php @@ -139,6 +139,14 @@ 'url' => '/api/v1/deletedshares/{id}', 'verb' => 'POST', ], + /* + * Expired Shares + */ + [ + 'name' => 'ExpiredShareAPI#index', + 'url' => '/api/v1/expiredshares', + 'verb' => 'GET', + ], /* * OCS Sharee API */ diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php index e3c94a7ac1a94..cbb73f8147134 100644 --- a/apps/files_sharing/composer/composer/autoload_classmap.php +++ b/apps/files_sharing/composer/composer/autoload_classmap.php @@ -29,6 +29,7 @@ 'OCA\\Files_Sharing\\Command\\ExiprationNotification' => $baseDir . '/../lib/Command/ExiprationNotification.php', 'OCA\\Files_Sharing\\Controller\\AcceptController' => $baseDir . '/../lib/Controller/AcceptController.php', 'OCA\\Files_Sharing\\Controller\\DeletedShareAPIController' => $baseDir . '/../lib/Controller/DeletedShareAPIController.php', + 'OCA\\Files_Sharing\\Controller\\ExpiredShareAPIController' => $baseDir . '/../lib/Controller/ExpiredShareAPIController.php', 'OCA\\Files_Sharing\\Controller\\ExternalSharesController' => $baseDir . '/../lib/Controller/ExternalSharesController.php', 'OCA\\Files_Sharing\\Controller\\PublicPreviewController' => $baseDir . '/../lib/Controller/PublicPreviewController.php', 'OCA\\Files_Sharing\\Controller\\RemoteController' => $baseDir . '/../lib/Controller/RemoteController.php', diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php index 597e65d96a261..5f1c698f2f7e9 100644 --- a/apps/files_sharing/composer/composer/autoload_static.php +++ b/apps/files_sharing/composer/composer/autoload_static.php @@ -44,6 +44,7 @@ class ComposerStaticInitFiles_Sharing 'OCA\\Files_Sharing\\Command\\ExiprationNotification' => __DIR__ . '/..' . '/../lib/Command/ExiprationNotification.php', 'OCA\\Files_Sharing\\Controller\\AcceptController' => __DIR__ . '/..' . '/../lib/Controller/AcceptController.php', 'OCA\\Files_Sharing\\Controller\\DeletedShareAPIController' => __DIR__ . '/..' . '/../lib/Controller/DeletedShareAPIController.php', + 'OCA\\Files_Sharing\\Controller\\ExpiredShareAPIController' => __DIR__ . '/..' . '/../lib/Controller/ExpiredShareAPIController.php', 'OCA\\Files_Sharing\\Controller\\ExternalSharesController' => __DIR__ . '/..' . '/../lib/Controller/ExternalSharesController.php', 'OCA\\Files_Sharing\\Controller\\PublicPreviewController' => __DIR__ . '/..' . '/../lib/Controller/PublicPreviewController.php', 'OCA\\Files_Sharing\\Controller\\RemoteController' => __DIR__ . '/..' . '/../lib/Controller/RemoteController.php', diff --git a/apps/files_sharing/lib/Controller/ExpiredShareAPIController.php b/apps/files_sharing/lib/Controller/ExpiredShareAPIController.php new file mode 100644 index 0000000000000..4bfdb1d9dff11 --- /dev/null +++ b/apps/files_sharing/lib/Controller/ExpiredShareAPIController.php @@ -0,0 +1,235 @@ +shareManager = $shareManager; + $this->userId = $UserId; + $this->userManager = $userManager; + $this->groupManager = $groupManager; + $this->rootFolder = $rootFolder; + $this->appManager = $appManager; + $this->serverContainer = $serverContainer; + } + + /** + * @suppress PhanUndeclaredClassMethod + * + * @return Files_SharingDeletedShare + */ + private function formatShare(IShare $share): array { + $result = [ + 'id' => $share->getFullId(), + 'share_type' => $share->getShareType(), + 'uid_owner' => $share->getSharedBy(), + 'displayname_owner' => $this->userManager->get($share->getSharedBy())->getDisplayName(), + 'permissions' => 0, + 'stime' => $share->getShareTime()->getTimestamp(), + 'parent' => null, + 'expiration' => null, + 'token' => null, + 'uid_file_owner' => $share->getShareOwner(), + 'displayname_file_owner' => $this->userManager->get($share->getShareOwner())->getDisplayName(), + 'path' => $share->getTarget(), + ]; + $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); + $node = $userFolder->getFirstNodeById($share->getNodeId()); + if (!$node) { + // fallback to guessing the path + $node = $userFolder->get($share->getTarget()); + if ($node === null || $share->getTarget() === '') { + throw new NotFoundException(); + } + } + + $result['path'] = $userFolder->getRelativePath($node->getPath()); + if ($node instanceof \OCP\Files\Folder) { + $result['item_type'] = 'folder'; + } else { + $result['item_type'] = 'file'; + } + $result['mimetype'] = $node->getMimetype(); + $result['storage_id'] = $node->getStorage()->getId(); + $result['storage'] = $node->getStorage()->getCache()->getNumericStorageId(); + $result['item_source'] = $node->getId(); + $result['file_source'] = $node->getId(); + $result['file_parent'] = $node->getParent()->getId(); + $result['file_target'] = $share->getTarget(); + $result['item_size'] = $node->getSize(); + $result['item_mtime'] = $node->getMTime(); + + $expiration = $share->getExpirationDate(); + if ($expiration !== null) { + $result['expiration'] = $expiration->format('Y-m-d 00:00:00'); + } + + if ($share->getShareType() === IShare::TYPE_GROUP) { + $group = $this->groupManager->get($share->getSharedWith()); + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = $group !== null ? $group->getDisplayName() : $share->getSharedWith(); + } elseif ($share->getShareType() === IShare::TYPE_ROOM) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getRoomShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_DECK) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getDeckShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_SCIENCEMESH) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getSciencemeshShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } + + return $result; + } + + /** + * Get a list of all expired shares + * + * @return DataResponse + * + * 200: Deleted shares returned + */ + #[NoAdminRequired] + public function index(): DataResponse { + $groupShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_GROUP, null, -1, 0); + $roomShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_ROOM, null, -1, 0); + $deckShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_DECK, null, -1, 0); + $sciencemeshShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_SCIENCEMESH, null, -1, 0); + $linkShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_LINK, null, -1, 0); + $userShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_USER, null, -1, 0); + $emailsShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_EMAIL, null, -1, 0); + $circlesShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_CIRCLE, null, -1, 0); + $remoteShares = $this->shareManager->getExpiredShares($this->userId, IShare::TYPE_REMOTE, null, -1, 0); + + $shares = array_merge($groupShares, $roomShares, $deckShares, $sciencemeshShares, $linkShares, $userShares, $emailsShares, $circlesShares, $remoteShares); + + $shares = array_map(function (IShare $share) { + return $this->formatShare($share); + }, $shares); + + return new DataResponse($shares); + } + + /** + * Returns the helper of DeletedShareAPIController for room shares. + * + * If the Talk application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return \OCA\Talk\Share\Helper\DeletedShareAPIController + * @throws QueryException + */ + private function getRoomShareHelper() { + if (!$this->appManager->isEnabledForUser('spreed')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\Talk\Share\Helper\DeletedShareAPIController'); + } + + /** + * Returns the helper of DeletedShareAPIHelper for deck shares. + * + * If the Deck application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return \OCA\Deck\Sharing\ShareAPIHelper + * @throws QueryException + */ + private function getDeckShareHelper() { + if (!$this->appManager->isEnabledForUser('deck')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\Deck\Sharing\ShareAPIHelper'); + } + + /** + * Returns the helper of DeletedShareAPIHelper for sciencemesh shares. + * + * If the sciencemesh application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return \OCA\Deck\Sharing\ShareAPIHelper + * @throws QueryException + */ + private function getSciencemeshShareHelper() { + if (!$this->appManager->isEnabledForUser('sciencemesh')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\ScienceMesh\Sharing\ShareAPIHelper'); + } +} diff --git a/apps/files_sharing/lib/ExpireSharesJob.php b/apps/files_sharing/lib/ExpireSharesJob.php index cd8490291d283..0a535766720e4 100644 --- a/apps/files_sharing/lib/ExpireSharesJob.php +++ b/apps/files_sharing/lib/ExpireSharesJob.php @@ -9,25 +9,24 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; use OCP\IDBConnection; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; +use Psr\Log\LoggerInterface; /** * Delete all shares that are expired */ class ExpireSharesJob extends TimedJob { - /** @var IManager */ - private $shareManager; - - /** @var IDBConnection */ - private $db; - - public function __construct(ITimeFactory $time, IManager $shareManager, IDBConnection $db) { - $this->shareManager = $shareManager; - $this->db = $db; + public function __construct( + ITimeFactory $time, + private IManager $shareManager, + private IDBConnection $db, + private IAppConfig $config, + private LoggerInterface $logger) { parent::__construct($time); @@ -43,13 +42,16 @@ public function __construct(ITimeFactory $time, IManager $shareManager, IDBConne * @param array $argument unused argument */ public function run($argument) { - //Current time + if ($this->config->getValueString('core', 'shareapi_delete_on_expire', 'yes') !== 'yes') { + $this->logger->info('Share deletion on expiration is disabled'); + return; + } + + // Current time $now = new \DateTime(); $now = $now->format('Y-m-d H:i:s'); - /* - * Expire file link shares only (for now) - */ + // Expire file link shares only (for now) $qb = $this->db->getQueryBuilder(); $qb->select('id', 'share_type') ->from('share') diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts index bd9448689a2be..2b61884a60446 100644 --- a/apps/files_sharing/src/files_actions/sharingStatusAction.ts +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts @@ -17,6 +17,7 @@ import { action as sidebarAction } from '../../../files/src/actions/sidebarActio import { generateAvatarSvg } from '../utils/AccountIcon' import './sharingStatusAction.scss' +import { expiredSharesViewId } from '../files_views/shares' const isExternal = (node: Node) => { return node.attributes.remote_id !== undefined @@ -24,10 +25,14 @@ const isExternal = (node: Node) => { export const action = new FileAction({ id: 'sharing-status', - displayName(nodes: Node[]) { + displayName(nodes: Node[], view: View) { const node = nodes[0] const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + if (view.id === expiredSharesViewId) { + return t('files_sharing', 'Expired') + } + if (shareTypes.length > 0 || (node.owner !== getCurrentUser()?.uid || isExternal(node))) { return t('files_sharing', 'Shared') diff --git a/apps/files_sharing/src/files_views/shares.ts b/apps/files_sharing/src/files_views/shares.ts index 7aec0dbeafb70..59a449dfc97b7 100644 --- a/apps/files_sharing/src/files_views/shares.ts +++ b/apps/files_sharing/src/files_views/shares.ts @@ -3,8 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { translate as t } from '@nextcloud/l10n' -import { View, getNavigation } from '@nextcloud/files' +import { Column, View, getNavigation } from '@nextcloud/files' import { ShareType } from '@nextcloud/sharing' +import moment from '@nextcloud/moment' + +import AccountArrowLeftSvg from '@mdi/svg/svg/account-arrow-left.svg?raw' import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw' import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw' import AccountPlusSvg from '@mdi/svg/svg/account-plus.svg?raw' @@ -22,6 +25,7 @@ export const sharingByLinksViewId = 'sharinglinks' export const deletedSharesViewId = 'deletedshares' export const pendingSharesViewId = 'pendingshares' export const fileRequestViewId = 'filerequest' +export const expiredSharesViewId = 'expiredshares' export default () => { const Navigation = getNavigation() @@ -89,7 +93,7 @@ export default () => { columns: [], - getContents: () => getContents(false, true, false, false, [ShareType.Link]), + getContents: () => getContents(false, true, false, false, false, [ShareType.Link]), })) Navigation.register(new View({ @@ -106,7 +110,7 @@ export default () => { columns: [], - getContents: () => getContents(false, true, false, false, [ShareType.Link, ShareType.Email]) + getContents: () => getContents(false, true, false, false, false, [ShareType.Link, ShareType.Email]) .then(({ folder, contents }) => { return { folder, @@ -132,6 +136,46 @@ export default () => { getContents: () => getContents(false, false, false, true), })) + Navigation.register(new View({ + id: expiredSharesViewId, + name: t('files_sharing', 'Expired shares'), + caption: t('files_sharing', 'List of shares that expired.'), + + emptyTitle: t('files_sharing', 'No expired shares'), + emptyCaption: t('files_sharing', 'Shares that have expired will show up here'), + + icon: AccountClockSvg, + order: 6, + parent: sharesViewId, + + columns: [ + new Column({ + id: 'expired', + title: t('files_sharing', 'Expired'), + render(node) { + const expirationTime = node.attributes?.expiration + const span = document.createElement('span') + if (expirationTime) { + span.title = moment.unix(expirationTime).format('LLL') + span.textContent = moment.unix(expirationTime).fromNow() + return span + } + + // Unknown expiration time + span.textContent = t('files_sharing', 'A long time ago') + return span + }, + sort(nodeA, nodeB) { + const deletionTimeA = nodeA.attributes?.expiration || nodeA?.mtime || 0 + const deletionTimeB = nodeB.attributes?.expiration || nodeB?.mtime || 0 + return deletionTimeB - deletionTimeA + }, + }), + ], + + getContents: () => getContents(false, false, false, false, true), + })) + Navigation.register(new View({ id: pendingSharesViewId, name: t('files_sharing', 'Pending shares'), @@ -140,8 +184,8 @@ export default () => { emptyTitle: t('files_sharing', 'No pending shares'), emptyCaption: t('files_sharing', 'Shares you have received but not approved will show up here'), - icon: AccountClockSvg, - order: 6, + icon: AccountArrowLeftSvg, + order: 7, parent: sharesViewId, columns: [], diff --git a/apps/files_sharing/src/services/SharingService.ts b/apps/files_sharing/src/services/SharingService.ts index 2f8144e216e1d..db18c9e5ee097 100644 --- a/apps/files_sharing/src/services/SharingService.ts +++ b/apps/files_sharing/src/services/SharingService.ts @@ -59,11 +59,16 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise (ocsEntry?.item_mtime || 0)) { mtime = new Date((ocsEntry.stime) * 1000) } + const expiration = ocsEntry?.expiration + ? new Date(ocsEntry?.expiration)?.getTime() / 1000 + : undefined + return new Node({ id: fileid, source, @@ -75,6 +80,7 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise> { }) } +const getExpiredShares = function(): AxiosPromise> { + const url = generateOcsUrl('apps/files_sharing/api/v1/expiredshares') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + /** * Check if a file request is enabled * @param attributes the share attributes json-encoded array @@ -180,7 +196,7 @@ const groupBy = function(nodes: (Folder | File)[], key: string) { }, {})) as (Folder | File)[][] } -export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes: number[] = []): Promise => { +export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, expiredShares = false, filterTypes: number[] = []): Promise => { const promises = [] as AxiosPromise>[] if (sharedWithYou) { @@ -195,6 +211,9 @@ export const getContents = async (sharedWithYou = true, sharedWithOthers = true, if (deletedshares) { promises.push(getDeletedShares()) } + if (expiredShares) { + promises.push(getExpiredShares()) + } const responses = await Promise.all(promises) const data = responses.map((response) => response.data.ocs.data).flat() diff --git a/apps/settings/lib/Settings/Admin/Sharing.php b/apps/settings/lib/Settings/Admin/Sharing.php index 34c91f3bce97b..7d77cba9ed972 100644 --- a/apps/settings/lib/Settings/Admin/Sharing.php +++ b/apps/settings/lib/Settings/Admin/Sharing.php @@ -71,6 +71,7 @@ public function getForm() { 'defaultRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_default_remote_expire_date'), 'remoteExpireAfterNDays' => $this->config->getAppValue('core', 'shareapi_remote_expire_after_n_days', '7'), 'enforceRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_enforce_remote_expire_date'), + 'deleteOnExpire' => $this->getHumanBooleanConfig('core', 'shareapi_delete_on_expire', true), ]; $this->initialState->provideInitialState('sharingAppEnabled', $this->appManager->isEnabledForUser('files_sharing')); diff --git a/apps/settings/src/components/AdminSettingsSharingForm.vue b/apps/settings/src/components/AdminSettingsSharingForm.vue index 93f30b2262c9f..c3aebee91680d 100644 --- a/apps/settings/src/components/AdminSettingsSharingForm.vue +++ b/apps/settings/src/components/AdminSettingsSharingForm.vue @@ -141,6 +141,12 @@ :placeholder="t('settings', 'Expire shares after x days')" :value.sync="settings.expireAfterNDays" /> + + + {{ t('settings', 'Delete shares on expiration') }} +