Skip to content

Commit 3e56ca3

Browse files
authored
Merge pull request #17346 from nextcloud/feat/4340/lazy-conversation-subfolders
feat(conversationfolder): use per share conversation folders
2 parents 136388c + fab2466 commit 3e56ca3

38 files changed

Lines changed: 4399 additions & 44 deletions

‎docs/capabilities.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,4 @@
225225
* `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`)
226226
* `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`)
227227
* `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations
228+
* `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/<ConversationName>-<token>/<DisplayName>-<uid>/` before calling the attachment endpoint

‎lib/Capabilities.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ class Capabilities implements IPublicCapability {
170170
'attachments' => [
171171
'allowed',
172172
'folder',
173+
'conversation-subfolders',
173174
],
174175
'call' => [
175176
'predefined-backgrounds',
@@ -259,6 +260,7 @@ public function getCapabilities(): array {
259260
'attachments' => [
260261
'allowed' => $user instanceof IUser && $user->getBackendClassName() !== UserBackend::class,
261262
// 'folder' => string,
263+
'conversation-subfolders' => $this->talkConfig->isConversationSubfoldersEnabled(),
262264
],
263265
'call' => [
264266
'enabled' => ((int)$this->serverConfig->getAppValue('spreed', 'start_calls', (string)Room::START_CALL_EVERYONE)) !== Room::START_CALL_NOONE,

‎lib/Chat/Parser/SystemMessage.php‎

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,13 @@ protected function parseMessage(Message $chatMessage, $allowInaccurate): void {
519519
}
520520
} elseif ($message === 'file_shared') {
521521
try {
522-
$parsedParameters['file'] = $this->getFileFromShare($room, $participant, $parameters['share'], $allowInaccurate);
522+
if (isset($parameters['share'])) {
523+
$parsedParameters['file'] = $this->getFileFromShare($room, $participant, $parameters['share'], $allowInaccurate);
524+
} elseif (isset($parameters['fileId'])) {
525+
$parsedParameters['file'] = $this->getFileFromNodeId($room, $participant, (int)$parameters['fileId'], $allowInaccurate);
526+
} else {
527+
throw new \InvalidArgumentException('No share or fileId in file_shared message');
528+
}
523529
$parsedMessage = '{file}';
524530
$metaData = $parameters['metaData'] ?? [];
525531
if (isset($metaData['messageType'])) {
@@ -797,6 +803,94 @@ protected function parseDeletedMessage(Message $chatMessage): void {
797803
$chatMessage->setMessage($parsedMessage, $parsedParameters, $message);
798804
}
799805

806+
/**
807+
* Build the same file-metadata array as getFileFromShare() but starting
808+
* from a node ID rather than a share ID.
809+
*
810+
* Files posted via the conversation-folder mechanism are accessible to room
811+
* members through the folder-level TYPE_ROOM share, so
812+
* userFolder->getFirstNodeById() will find them via the share mount.
813+
*
814+
* @throws NotFoundException
815+
* @throws ShareNotFound
816+
*/
817+
protected function getFileFromNodeId(Room $room, ?Participant $participant, int $nodeId, bool $allowInaccurate = false): array {
818+
if ($participant && $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
819+
if ($allowInaccurate) {
820+
// Lightweight lookup: search the filecache directly without setting up the user
821+
// filesystem, mirroring getFileFromShare()'s use of getNodeCacheEntry().
822+
// Path is intentionally inaccurate (filename only) — callers that need the full
823+
// relative path must pass $allowInaccurate = false.
824+
$node = $this->rootFolder->getFirstNodeById($nodeId);
825+
if (!$node instanceof Node) {
826+
throw new NotFoundException('File node ' . $nodeId . ' not found');
827+
}
828+
829+
$name = $node->getName();
830+
$size = $node->getSize();
831+
$path = $name;
832+
} else {
833+
$uid = $participant->getAttendee()->getActorId();
834+
$userFolder = $this->rootFolder->getUserFolder($uid);
835+
836+
$node = $userFolder->getFirstNodeById($nodeId);
837+
if (!$node instanceof Node) {
838+
throw new NotFoundException('File node ' . $nodeId . ' not found for user ' . $uid);
839+
}
840+
841+
$fullPath = $node->getPath();
842+
$pathSegments = explode('/', $fullPath, 4);
843+
$name = $node->getName();
844+
$size = $node->getSize();
845+
$path = $pathSegments[3] ?? $name;
846+
}
847+
848+
$url = $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', [
849+
'fileid' => $node->getId(),
850+
]);
851+
} elseif ($participant && $room->getType() !== Room::TYPE_PUBLIC && $participant->getAttendee()->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
852+
throw new ShareNotFound();
853+
} else {
854+
// Guest / public room path: load via the owner's root folder.
855+
// Without a per-file share we cannot look up a share token, so
856+
// guests will see the file as unavailable.
857+
throw new ShareNotFound();
858+
}
859+
860+
$fileId = $node->getId();
861+
$isPreviewAvailable = $size > 0 && $this->previewManager->isMimeSupported($node->getMimeType());
862+
863+
$data = [
864+
'type' => 'file',
865+
'id' => (string)$fileId,
866+
'name' => $name,
867+
'size' => (string)$size,
868+
'path' => $path,
869+
'link' => $url,
870+
'etag' => $node->getEtag(),
871+
'permissions' => (string)$node->getPermissions(),
872+
'mimetype' => $node->getMimeType(),
873+
'preview-available' => $isPreviewAvailable ? 'yes' : 'no',
874+
'hide-download' => 'no',
875+
];
876+
877+
if ($isPreviewAvailable && str_starts_with($node->getMimeType(), 'image/')) {
878+
try {
879+
$sizeMetadata = $this->metadataCache->getImageMetadataForFileId($fileId);
880+
if (isset($sizeMetadata['width'], $sizeMetadata['height'])) {
881+
$data['width'] = (string)$sizeMetadata['width'];
882+
$data['height'] = (string)$sizeMetadata['height'];
883+
}
884+
if (isset($sizeMetadata['blurhash'])) {
885+
$data['blurhash'] = $sizeMetadata['blurhash'];
886+
}
887+
} catch (\OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException) {
888+
}
889+
}
890+
891+
return $data;
892+
}
893+
800894
/**
801895
* @throws InvalidPathException
802896
* @throws NotFoundException
@@ -826,7 +920,9 @@ protected function getFileFromShare(Room $room, ?Participant $participant, strin
826920
// FIXME This should be much more sensible, e.g.
827921
// 1. Only be executed on "Waiting for new messages"
828922
// 2. Once per request
923+
/** @psalm-suppress UndefinedClass */
829924
\OC_Util::tearDownFS();
925+
/** @psalm-suppress UndefinedClass */
830926
\OC_Util::setupFS($participant->getAttendee()->getActorId());
831927
$userNodes = $userFolder->getById($share->getNodeId());
832928

‎lib/Chat/SystemMessage/Listener.php‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,36 @@ public function handle(Event $event): void {
117117
} elseif ($event instanceof BeforeShareCreatedEvent) {
118118
$this->setShareExpiration($event);
119119
} elseif ($event instanceof BeforeDuplicateShareSentEvent || $event instanceof ShareCreatedEvent) {
120-
$this->fixMimeTypeOfVoiceMessage($event);
120+
$share = $event->getShare();
121+
if ($share->getShareType() !== IShare::TYPE_ROOM) {
122+
return;
123+
}
124+
125+
$route = strtolower($this->request->getParam('_route') ?? '');
126+
127+
// Recording endpoint posts its own system message; skip the generic one.
128+
if ($route === 'ocs.spreed.recording.sharetochat') {
129+
return;
130+
}
131+
132+
// Probe only creates the folder share for access control and never
133+
// posts a message, so ensureOneToOneRoomIsFilled is not needed here —
134+
// it will run when the actual chat message is posted.
135+
if ($route === 'ocs.spreed.chat.probeattachmentfolder') {
136+
return;
137+
}
138+
139+
$room = $this->manager->getRoomByToken($share->getSharedWith());
140+
// ensureOneToOneRoomIsFilled must run so attendees are persisted.
141+
$this->participantService->ensureOneToOneRoomIsFilled($room);
142+
143+
// Attachment endpoint posts its own file_shared message by node ID;
144+
// the folder-level TYPE_ROOM share it creates here is only for access control.
145+
if ($route === 'ocs.spreed.chat.postattachmenttoroom') {
146+
return;
147+
}
148+
149+
$this->fixMimeTypeOfVoiceMessage($event, $room);
121150
}
122151
}
123152

@@ -346,6 +375,14 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
346375
return;
347376
}
348377

378+
// The attachment and probe endpoints create a folder-level TYPE_ROOM share
379+
// purely for access control — it must not be given a message-expiration date.
380+
$route = strtolower($this->request->getParam('_route') ?? '');
381+
if ($route === 'ocs.spreed.chat.postattachmenttoroom'
382+
|| $route === 'ocs.spreed.chat.probeattachmentfolder') {
383+
return;
384+
}
385+
349386
$room = $this->manager->getRoomByToken($share->getSharedWith());
350387

351388
$messageExpiration = $room->getMessageExpiration();
@@ -358,19 +395,9 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
358395
$share->setExpirationDate($dateTime);
359396
}
360397

361-
protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event): void {
398+
protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event, Room $room): void {
362399
$share = $event->getShare();
363400

364-
if ($share->getShareType() !== IShare::TYPE_ROOM) {
365-
return;
366-
}
367-
368-
if (strtolower($this->request->getParam('_route')) === 'ocs.spreed.recording.sharetochat') {
369-
return;
370-
}
371-
$room = $this->manager->getRoomByToken($share->getSharedWith());
372-
$this->participantService->ensureOneToOneRoomIsFilled($room);
373-
374401
$metaData = $this->request->getParam('talkMetaData') ?? '';
375402
$metaData = json_decode($metaData, true);
376403
$metaData = is_array($metaData) ? $metaData : [];

‎lib/Config.php‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ public function isBreakoutRoomsEnabled(): bool {
138138
return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes';
139139
}
140140

141+
public function isConversationSubfoldersEnabled(): bool {
142+
return $this->appConfig->getAppValueBool('conversation_subfolders', true);
143+
}
144+
141145
public function getDialInInfo(): string {
142146
return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
143147
}
@@ -308,6 +312,70 @@ public function getAttachmentFolder(string $userId): string {
308312
return $this->config->getUserValue($userId, 'spreed', UserPreference::ATTACHMENT_FOLDER, $defaultAttachmentFolder);
309313
}
310314

315+
/**
316+
* Returns the per-conversation folder name: "<sanitizedDisplayName>-<token>"
317+
* The display name is sanitized and trimmed to 64 characters.
318+
*
319+
* @throws \LogicException if the conversation-subfolders feature is disabled
320+
*/
321+
public function getConversationFolderName(Room $room, string $userId): string {
322+
if (!$this->isConversationSubfoldersEnabled()) {
323+
throw new \LogicException('getConversationFolderName called while conversation subfolders are disabled');
324+
}
325+
return $this->buildConversationFolderName($room->getDisplayName($userId), $room->getToken());
326+
}
327+
328+
public function buildConversationFolderName(string $displayName, string $token): string {
329+
return $this->sanitizeDisplayName($displayName, 64) . '-' . $token;
330+
}
331+
332+
/**
333+
* Returns the per-user subfolder name within a conversation folder.
334+
*
335+
* Format: "<displayPrefix>-<uid>" where the prefix length is capped so that
336+
* the total segment length stays under 64 characters on all filesystems.
337+
* If the uid is 63+ characters, the prefix is omitted.
338+
*/
339+
public function getConversationSubfolderName(string $userId): string {
340+
if (!$this->isConversationSubfoldersEnabled()) {
341+
throw new \LogicException('getConversationSubfolderName called while conversation subfolders are disabled');
342+
}
343+
$displayName = $this->userManager->getDisplayName($userId) ?? '';
344+
return $this->buildConversationSubfolderName($userId, $displayName);
345+
}
346+
347+
/**
348+
* Builds the per-user subfolder name from a pre-fetched display name.
349+
* Use this when the caller already holds the IUser object (e.g. the current
350+
* user from IUserSession) to avoid an extra IUserManager::get() lookup.
351+
*/
352+
public function buildConversationSubfolderName(string $userId, string $displayName): string {
353+
$prefixLen = min(16, max(0, 63 - strlen($userId)));
354+
if ($prefixLen > 0) {
355+
$prefix = $this->sanitizeDisplayName($displayName, $prefixLen);
356+
if ($prefix !== '') {
357+
return $prefix . '-' . $userId;
358+
}
359+
}
360+
return $userId;
361+
}
362+
363+
/**
364+
* Sanitizes a display name for use as (part of) a filesystem folder name.
365+
*
366+
* Replaces forward slash, backslash, and ASCII control characters (U+0000–U+001F)
367+
* with a space, trims whitespace, then truncates to $maxChars Unicode code points.
368+
*/
369+
private function sanitizeDisplayName(string $name, int $maxChars): string {
370+
// phpcs:ignore -- control characters are intentional
371+
$name = preg_replace('/[\x00-\x1f\/\\\\]+/', ' ', $name);
372+
$name = trim((string)$name);
373+
if (mb_strlen($name) > $maxChars) {
374+
$name = trim(mb_substr($name, 0, $maxChars));
375+
}
376+
return $name;
377+
}
378+
311379
/**
312380
* @return string[]
313381
*/

0 commit comments

Comments
 (0)