Skip to content

Commit cc0308a

Browse files
feat: add shared resource section to profile
Signed-off-by: Kristian Zendato <kristian.zendato@nextcloud.com>
1 parent 4eadcf2 commit cc0308a

12 files changed

Lines changed: 796 additions & 1 deletion

apps/profile/.noopenapi

Whitespace-only changes.

apps/profile/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
return array(
99
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
1010
'OCA\\Profile\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
11+
'OCA\\Profile\\Controller\\ProfileApiController' => $baseDir . '/../lib/Controller/ProfileApiController.php',
1112
'OCA\\Profile\\Controller\\ProfilePageController' => $baseDir . '/../lib/Controller/ProfilePageController.php',
1213
'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => $baseDir . '/../lib/Listener/LoadAdditionalEntriesListener.php',
1314
'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => $baseDir . '/../lib/Listener/ProfilePickerReferenceListener.php',

apps/profile/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ComposerStaticInitProfile
2323
public static $classMap = array (
2424
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
2525
'OCA\\Profile\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
26+
'OCA\\Profile\\Controller\\ProfileApiController' => __DIR__ . '/..' . '/../lib/Controller/ProfileApiController.php',
2627
'OCA\\Profile\\Controller\\ProfilePageController' => __DIR__ . '/..' . '/../lib/Controller/ProfilePageController.php',
2728
'OCA\\Profile\\Listener\\LoadAdditionalEntriesListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalEntriesListener.php',
2829
'OCA\\Profile\\Listener\\ProfilePickerReferenceListener' => __DIR__ . '/..' . '/../lib/Listener/ProfilePickerReferenceListener.php',
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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\Profile\Controller;
11+
12+
use OCP\App\IAppManager;
13+
use OCP\AppFramework\Http;
14+
use OCP\AppFramework\Http\Attribute\ApiRoute;
15+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
17+
use OCP\AppFramework\Http\Attribute\OpenAPI;
18+
use OCP\AppFramework\Http\DataResponse;
19+
use OCP\AppFramework\OCS\OCSNotFoundException;
20+
use OCP\AppFramework\OCSController;
21+
use OCP\Calendar\ICalendarQuery;
22+
use OCP\Calendar\IManager;
23+
use OCP\Files\File;
24+
use OCP\IDateTimeFormatter;
25+
use OCP\IRequest;
26+
use OCP\IURLGenerator;
27+
use OCP\IUserManager;
28+
use OCP\IUserSession;
29+
use OCP\Share\IManager as IShareManager;
30+
use OCP\Share\IShare;
31+
32+
/**
33+
* @psalm-import-type ProfileSharedResource from \OCA\Profile\ResponseDefinitions
34+
*/
35+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
36+
class ProfileApiController extends OCSController {
37+
public function __construct(
38+
string $appName,
39+
IRequest $request,
40+
private IUserSession $userSession,
41+
private IUserManager $userManager,
42+
private IManager $calendarManager,
43+
private IShareManager $shareManager,
44+
private IDateTimeFormatter $formatter,
45+
private IURLGenerator $urlGenerator,
46+
private IAppManager $appManager,
47+
) {
48+
parent::__construct($appName, $request);
49+
}
50+
51+
/**
52+
* Get resources shared between the current user and the specified user.
53+
*
54+
* @param string $userId - The user ID of the user to get shared resources with.
55+
* @return DataResponse<Http::STATUS_OK, list<ProfileSharedResource>, array{}>
56+
* @throws OCSNotFoundException - The specified user does not exist.
57+
*
58+
* 200: The shared resources between the current user and the specified user.
59+
* 404: The specified user does not exist.
60+
*/
61+
#[NoCSRFRequired]
62+
#[NoAdminRequired]
63+
#[ApiRoute(verb: 'GET', url: '/api/v1/resources/{userId}')]
64+
public function getResources(string $userId): DataResponse {
65+
$user = $this->userManager->get($userId);
66+
if (!$user) {
67+
throw new OCSNotFoundException();
68+
}
69+
70+
$files = $this->getSharedNodes($userId);
71+
$events = $this->getSharedCalendarEvents($userId);
72+
73+
$entries = array_values(array_merge($files, $events));
74+
return new DataResponse($entries);
75+
}
76+
77+
/**
78+
* Get all upcoming events shared between a user and the current user.
79+
*
80+
* If the calendar app is disabled for the current user no events will be returned.
81+
*
82+
* @param string $userId - The user ID of the user to get shared events with.
83+
* @return list<ProfileSharedResource>
84+
*/
85+
private function getSharedCalendarEvents(string $userId) {
86+
if (!$this->appManager->isEnabledForUser('calendar', $this->userSession->getUser())) {
87+
return [];
88+
}
89+
90+
$mePrincipal = 'principals/users/' . $this->userSession->getUser()->getUID();
91+
92+
$query = $this->calendarManager->newQuery($mePrincipal);
93+
$query->setSearchPattern($userId);
94+
$query->addType('VEVENT');
95+
$query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_ATTENDEE);
96+
$query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_ORGANIZER);
97+
$now = new \DateTimeImmutable('now');
98+
$query->setTimerangeStart($now->modify('-1 hour'));
99+
$query->setLimit(9);
100+
101+
$events = $this->calendarManager->searchForPrincipal($query);
102+
$result = [];
103+
foreach ($events as $event) {
104+
if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') {
105+
continue;
106+
}
107+
108+
$end = $event['objects'][0]['DTEND'][0];
109+
if ($now->diff($end)->invert === 1) {
110+
// already ended, skip
111+
continue;
112+
}
113+
114+
$calendarUid = $event['objects'][0]['UID'][0];
115+
if (isset($event['RECURRENCE-ID'])) {
116+
$recurrenceId = $event['RECURRENCE-ID'][0];
117+
$href = $this->urlGenerator->linkToRouteAbsolute('calendar.object.indexuid.recurrenceId', ['uid' => $calendarUid, 'recurrenceId' => $recurrenceId]);
118+
} else {
119+
$href = $this->urlGenerator->linkToRouteAbsolute('calendar.object.indexuid', ['uid' => $calendarUid]);
120+
}
121+
122+
$start = \DateTime::createFromImmutable($event['objects'][0]['DTSTART'][0]);
123+
$result[] = [
124+
'label' => $event['objects'][0]['SUMMARY'][0],
125+
'text' => $this->formatter->formatTimeSpan($start, \DateTime::createFromImmutable($now)),
126+
'href' => $href,
127+
'img' => $this->urlGenerator->getAbsoluteURL($this->appManager->getAppIcon('calendar')),
128+
];
129+
}
130+
return $result;
131+
}
132+
133+
/**
134+
* @return ProfileSharedResource[]
135+
*/
136+
private function getSharedNodes(string $userId): array {
137+
$outgoingShares = [];
138+
$offset = 0;
139+
while (count($outgoingShares) < 5) {
140+
$shares = $this->shareManager->getSharesBy($userId, IShare::TYPE_USER, limit: 50, offset: $offset);
141+
$outgoingShares = array_merge($outgoingShares, array_filter($shares, fn ($share) => $share->getSharedWith() === $this->userSession->getUser()->getUID()));
142+
$offset += 50;
143+
if (count($shares) < 50) {
144+
break;
145+
}
146+
}
147+
148+
$incomingShares = [];
149+
$offset = 0;
150+
while (count($incomingShares) < 5) {
151+
$shares = $this->shareManager->getSharesBy($this->userSession->getUser()->getUID(), IShare::TYPE_USER, limit: 50, offset: $offset);
152+
$incomingShares = array_merge($incomingShares, array_filter($shares, fn ($share) => $share->getSharedWith() === $userId));
153+
$offset += 50;
154+
if (count($shares) < 50) {
155+
break;
156+
}
157+
}
158+
159+
$shares = array_slice(array_merge($outgoingShares, $incomingShares), 0, 5);
160+
usort($shares, fn ($a, $b) => $a->getNode()->getMTime() <=> $b->getNode()->getMTime());
161+
$files = [];
162+
foreach ($shares as $share) {
163+
$node = $share->getNode();
164+
// Preview endpoint only serves files; folders need the mime icon directly.
165+
if ($node instanceof File) {
166+
$img = $this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', [
167+
'fileId' => $node->getId(),
168+
'mimeFallback' => true,
169+
]);
170+
} else {
171+
$img = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'filetypes/folder.svg'));
172+
}
173+
$files[] = [
174+
'label' => $node->getName(),
175+
'text' => $this->formatter->formatTimeSpan($node->getMTime()),
176+
'href' => $this->urlGenerator->linkToRouteAbsolute('files.view.index', [
177+
'dir' => $node->getParent()->getPath(),
178+
'fileid' => $node->getId(),
179+
]),
180+
'img' => $img,
181+
];
182+
}
183+
return $files;
184+
}
185+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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\Profile;
11+
12+
/**
13+
* @psalm-type ProfileSharedResource array{
14+
* label: string,
15+
* text: string,
16+
* href: string,
17+
* img: string
18+
* }
19+
*/
20+
class ResponseDefinitions {
21+
}

0 commit comments

Comments
 (0)