Skip to content

Commit 270f200

Browse files
committed
feat(conversationfolder): use per share conversation folders
Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 14a1ee2 commit 270f200

27 files changed

Lines changed: 2897 additions & 30 deletions

lib/Capabilities.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ class Capabilities implements IPublicCapability {
169169
'attachments' => [
170170
'allowed',
171171
'folder',
172+
'conversation-subfolders',
172173
],
173174
'call' => [
174175
'predefined-backgrounds',
@@ -256,6 +257,7 @@ public function getCapabilities(): array {
256257
'attachments' => [
257258
'allowed' => $user instanceof IUser && $user->getBackendClassName() !== UserBackend::class,
258259
// 'folder' => string,
260+
'conversation-subfolders' => $this->talkConfig->isConversationSubfoldersEnabled(),
259261
],
260262
'call' => [
261263
'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: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,29 @@ 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+
$room = $this->manager->getRoomByToken($share->getSharedWith());
133+
// ensureOneToOneRoomIsFilled must always run so attendees are persisted.
134+
$this->participantService->ensureOneToOneRoomIsFilled($room);
135+
136+
// Attachment endpoint posts its own file_shared message by node ID;
137+
// the folder-level TYPE_ROOM share it creates here is only for access control.
138+
if ($route === 'ocs.spreed.chat.postattachmenttoroom') {
139+
return;
140+
}
141+
142+
$this->fixMimeTypeOfVoiceMessage($event, $room);
121143
}
122144
}
123145

@@ -346,6 +368,12 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
346368
return;
347369
}
348370

371+
// The attachment endpoint creates a folder-level TYPE_ROOM share purely for
372+
// access control — it must not be given a message-expiration date.
373+
if (strtolower($this->request->getParam('_route') ?? '') === 'ocs.spreed.chat.postattachmenttoroom') {
374+
return;
375+
}
376+
349377
$room = $this->manager->getRoomByToken($share->getSharedWith());
350378

351379
$messageExpiration = $room->getMessageExpiration();
@@ -358,19 +386,9 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void {
358386
$share->setExpirationDate($dateTime);
359387
}
360388

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

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-
374392
$metaData = $this->request->getParam('talkMetaData') ?? '';
375393
$metaData = json_decode($metaData, true);
376394
$metaData = is_array($metaData) ? $metaData : [];

lib/Config.php

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

0 commit comments

Comments
 (0)