From bb1a36d830255f046e96cbbeac324b4595e8aeac Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 30 Mar 2026 12:28:25 +0200 Subject: [PATCH 1/2] feat(conversationfolder): use per share conversation folders Introduces a per-share conversation subfolder scheme for Talk attachments. Instead of a single flat attachment folder, files are organised as: Talk/-/-/ A new POST /api/v1/chat/{token}/attachment endpoint accepts a file path already inside the caller's conversation subfolder, creates a folder-level TYPE_ROOM share (via ConversationFolderService) to give all room members access, and posts the file_shared chat message. The attachment folder root is configurable per user; the feature is toggled via the `conversation_subfolders` app config key (default on) and advertised through the existing capabilities mechanism. AI-assisted-by: Claude Sonnet 4.6 Signed-off-by: Anna Larch --- docs/capabilities.md | 1 + lib/Capabilities.php | 2 + lib/Chat/Parser/SystemMessage.php | 98 ++- lib/Chat/SystemMessage/Listener.php | 51 +- lib/Config.php | 68 ++ lib/Controller/ChatController.php | 212 ++++++ lib/Controller/RoomController.php | 3 + lib/Federation/Proxy/TalkV1/ProxyRequest.php | 3 + lib/ResponseDefinitions.php | 2 + lib/Service/ConversationFolderService.php | 287 ++++++++ lib/Share/Listener.php | 82 ++- openapi-administration.json | 7 +- openapi-backend-recording.json | 7 +- openapi-backend-signaling.json | 7 +- openapi-backend-sipbridge.json | 7 +- openapi-bots.json | 7 +- openapi-federation.json | 7 +- openapi-full.json | 657 +++++++++++++++++- openapi.json | 657 +++++++++++++++++- src/__mocks__/capabilities.ts | 1 + src/types/openapi/openapi-administration.ts | 2 + .../openapi/openapi-backend-recording.ts | 2 + .../openapi/openapi-backend-signaling.ts | 2 + .../openapi/openapi-backend-sipbridge.ts | 2 + src/types/openapi/openapi-bots.ts | 2 + src/types/openapi/openapi-federation.ts | 2 + src/types/openapi/openapi-full.ts | 316 +++++++++ src/types/openapi/openapi.ts | 316 +++++++++ 28 files changed, 2780 insertions(+), 30 deletions(-) create mode 100644 lib/Service/ConversationFolderService.php diff --git a/docs/capabilities.md b/docs/capabilities.md index b217e6adb7b..58f2ea9d908 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -225,3 +225,4 @@ * `config => conversations => sort-order` (local) - User selected sort order for conversations (`activity` or `alphabetical`) * `config => conversations => group-mode` (local) - User selected grouping mode for conversations (`none`, `group-first` or `private-first`) * `private-reply` - Whether clients can link the original message to a private reply in one-to-one conversations +* `config => attachments => conversation-subfolders` (local) - Whether per-conversation subfolders are used for Talk attachments; when `true` files must be uploaded to `Talk/-/-/` before calling the attachment endpoint diff --git a/lib/Capabilities.php b/lib/Capabilities.php index aed4ead872a..db47841106c 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -170,6 +170,7 @@ class Capabilities implements IPublicCapability { 'attachments' => [ 'allowed', 'folder', + 'conversation-subfolders', ], 'call' => [ 'predefined-backgrounds', @@ -259,6 +260,7 @@ public function getCapabilities(): array { 'attachments' => [ 'allowed' => $user instanceof IUser && $user->getBackendClassName() !== UserBackend::class, // 'folder' => string, + 'conversation-subfolders' => $this->talkConfig->isConversationSubfoldersEnabled(), ], 'call' => [ 'enabled' => ((int)$this->serverConfig->getAppValue('spreed', 'start_calls', (string)Room::START_CALL_EVERYONE)) !== Room::START_CALL_NOONE, diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index ddb267299f6..4948d191eba 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -519,7 +519,13 @@ protected function parseMessage(Message $chatMessage, $allowInaccurate): void { } } elseif ($message === 'file_shared') { try { - $parsedParameters['file'] = $this->getFileFromShare($room, $participant, $parameters['share'], $allowInaccurate); + if (isset($parameters['share'])) { + $parsedParameters['file'] = $this->getFileFromShare($room, $participant, $parameters['share'], $allowInaccurate); + } elseif (isset($parameters['fileId'])) { + $parsedParameters['file'] = $this->getFileFromNodeId($room, $participant, (int)$parameters['fileId'], $allowInaccurate); + } else { + throw new \InvalidArgumentException('No share or fileId in file_shared message'); + } $parsedMessage = '{file}'; $metaData = $parameters['metaData'] ?? []; if (isset($metaData['messageType'])) { @@ -797,6 +803,94 @@ protected function parseDeletedMessage(Message $chatMessage): void { $chatMessage->setMessage($parsedMessage, $parsedParameters, $message); } + /** + * Build the same file-metadata array as getFileFromShare() but starting + * from a node ID rather than a share ID. + * + * Files posted via the conversation-folder mechanism are accessible to room + * members through the folder-level TYPE_ROOM share, so + * userFolder->getFirstNodeById() will find them via the share mount. + * + * @throws NotFoundException + * @throws ShareNotFound + */ + protected function getFileFromNodeId(Room $room, ?Participant $participant, int $nodeId, bool $allowInaccurate = false): array { + if ($participant && $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) { + if ($allowInaccurate) { + // Lightweight lookup: search the filecache directly without setting up the user + // filesystem, mirroring getFileFromShare()'s use of getNodeCacheEntry(). + // Path is intentionally inaccurate (filename only) — callers that need the full + // relative path must pass $allowInaccurate = false. + $node = $this->rootFolder->getFirstNodeById($nodeId); + if (!$node instanceof Node) { + throw new NotFoundException('File node ' . $nodeId . ' not found'); + } + + $name = $node->getName(); + $size = $node->getSize(); + $path = $name; + } else { + $uid = $participant->getAttendee()->getActorId(); + $userFolder = $this->rootFolder->getUserFolder($uid); + + $node = $userFolder->getFirstNodeById($nodeId); + if (!$node instanceof Node) { + throw new NotFoundException('File node ' . $nodeId . ' not found for user ' . $uid); + } + + $fullPath = $node->getPath(); + $pathSegments = explode('/', $fullPath, 4); + $name = $node->getName(); + $size = $node->getSize(); + $path = $pathSegments[3] ?? $name; + } + + $url = $this->url->linkToRouteAbsolute('files.viewcontroller.showFile', [ + 'fileid' => $node->getId(), + ]); + } elseif ($participant && $room->getType() !== Room::TYPE_PUBLIC && $participant->getAttendee()->getActorType() === Attendee::ACTOR_FEDERATED_USERS) { + throw new ShareNotFound(); + } else { + // Guest / public room path: load via the owner's root folder. + // Without a per-file share we cannot look up a share token, so + // guests will see the file as unavailable. + throw new ShareNotFound(); + } + + $fileId = $node->getId(); + $isPreviewAvailable = $size > 0 && $this->previewManager->isMimeSupported($node->getMimeType()); + + $data = [ + 'type' => 'file', + 'id' => (string)$fileId, + 'name' => $name, + 'size' => (string)$size, + 'path' => $path, + 'link' => $url, + 'etag' => $node->getEtag(), + 'permissions' => (string)$node->getPermissions(), + 'mimetype' => $node->getMimeType(), + 'preview-available' => $isPreviewAvailable ? 'yes' : 'no', + 'hide-download' => 'no', + ]; + + if ($isPreviewAvailable && str_starts_with($node->getMimeType(), 'image/')) { + try { + $sizeMetadata = $this->metadataCache->getImageMetadataForFileId($fileId); + if (isset($sizeMetadata['width'], $sizeMetadata['height'])) { + $data['width'] = (string)$sizeMetadata['width']; + $data['height'] = (string)$sizeMetadata['height']; + } + if (isset($sizeMetadata['blurhash'])) { + $data['blurhash'] = $sizeMetadata['blurhash']; + } + } catch (\OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException) { + } + } + + return $data; + } + /** * @throws InvalidPathException * @throws NotFoundException @@ -826,7 +920,9 @@ protected function getFileFromShare(Room $room, ?Participant $participant, strin // FIXME This should be much more sensible, e.g. // 1. Only be executed on "Waiting for new messages" // 2. Once per request + /** @psalm-suppress UndefinedClass */ \OC_Util::tearDownFS(); + /** @psalm-suppress UndefinedClass */ \OC_Util::setupFS($participant->getAttendee()->getActorId()); $userNodes = $userFolder->getById($share->getNodeId()); diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php index 0bbc0bbc9d3..47818cded16 100644 --- a/lib/Chat/SystemMessage/Listener.php +++ b/lib/Chat/SystemMessage/Listener.php @@ -117,7 +117,36 @@ public function handle(Event $event): void { } elseif ($event instanceof BeforeShareCreatedEvent) { $this->setShareExpiration($event); } elseif ($event instanceof BeforeDuplicateShareSentEvent || $event instanceof ShareCreatedEvent) { - $this->fixMimeTypeOfVoiceMessage($event); + $share = $event->getShare(); + if ($share->getShareType() !== IShare::TYPE_ROOM) { + return; + } + + $route = strtolower($this->request->getParam('_route') ?? ''); + + // Recording endpoint posts its own system message; skip the generic one. + if ($route === 'ocs.spreed.recording.sharetochat') { + return; + } + + // Probe only creates the folder share for access control and never + // posts a message, so ensureOneToOneRoomIsFilled is not needed here — + // it will run when the actual chat message is posted. + if ($route === 'ocs.spreed.chat.probeattachmentfolder') { + return; + } + + $room = $this->manager->getRoomByToken($share->getSharedWith()); + // ensureOneToOneRoomIsFilled must run so attendees are persisted. + $this->participantService->ensureOneToOneRoomIsFilled($room); + + // Attachment endpoint posts its own file_shared message by node ID; + // the folder-level TYPE_ROOM share it creates here is only for access control. + if ($route === 'ocs.spreed.chat.postattachmenttoroom') { + return; + } + + $this->fixMimeTypeOfVoiceMessage($event, $room); } } @@ -346,6 +375,14 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void { return; } + // The attachment and probe endpoints create a folder-level TYPE_ROOM share + // purely for access control — it must not be given a message-expiration date. + $route = strtolower($this->request->getParam('_route') ?? ''); + if ($route === 'ocs.spreed.chat.postattachmenttoroom' + || $route === 'ocs.spreed.chat.probeattachmentfolder') { + return; + } + $room = $this->manager->getRoomByToken($share->getSharedWith()); $messageExpiration = $room->getMessageExpiration(); @@ -358,19 +395,9 @@ protected function setShareExpiration(BeforeShareCreatedEvent $event): void { $share->setExpirationDate($dateTime); } - protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event): void { + protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateShareSentEvent $event, Room $room): void { $share = $event->getShare(); - if ($share->getShareType() !== IShare::TYPE_ROOM) { - return; - } - - if (strtolower($this->request->getParam('_route')) === 'ocs.spreed.recording.sharetochat') { - return; - } - $room = $this->manager->getRoomByToken($share->getSharedWith()); - $this->participantService->ensureOneToOneRoomIsFilled($room); - $metaData = $this->request->getParam('talkMetaData') ?? ''; $metaData = json_decode($metaData, true); $metaData = is_array($metaData) ? $metaData : []; diff --git a/lib/Config.php b/lib/Config.php index 6ca99355f7d..7b7086b4d26 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -138,6 +138,10 @@ public function isBreakoutRoomsEnabled(): bool { return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes'; } + public function isConversationSubfoldersEnabled(): bool { + return $this->appConfig->getAppValueBool('conversation_subfolders', true); + } + public function getDialInInfo(): string { return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info'); } @@ -308,6 +312,70 @@ public function getAttachmentFolder(string $userId): string { return $this->config->getUserValue($userId, 'spreed', UserPreference::ATTACHMENT_FOLDER, $defaultAttachmentFolder); } + /** + * Returns the per-conversation folder name: "-" + * The display name is sanitized and trimmed to 64 characters. + * + * @throws \LogicException if the conversation-subfolders feature is disabled + */ + public function getConversationFolderName(Room $room, string $userId): string { + if (!$this->isConversationSubfoldersEnabled()) { + throw new \LogicException('getConversationFolderName called while conversation subfolders are disabled'); + } + return $this->buildConversationFolderName($room->getDisplayName($userId), $room->getToken()); + } + + public function buildConversationFolderName(string $displayName, string $token): string { + return $this->sanitizeDisplayName($displayName, 64) . '-' . $token; + } + + /** + * Returns the per-user subfolder name within a conversation folder. + * + * Format: "-" where the prefix length is capped so that + * the total segment length stays under 64 characters on all filesystems. + * If the uid is 63+ characters, the prefix is omitted. + */ + public function getConversationSubfolderName(string $userId): string { + if (!$this->isConversationSubfoldersEnabled()) { + throw new \LogicException('getConversationSubfolderName called while conversation subfolders are disabled'); + } + $displayName = $this->userManager->getDisplayName($userId) ?? ''; + return $this->buildConversationSubfolderName($userId, $displayName); + } + + /** + * Builds the per-user subfolder name from a pre-fetched display name. + * Use this when the caller already holds the IUser object (e.g. the current + * user from IUserSession) to avoid an extra IUserManager::get() lookup. + */ + public function buildConversationSubfolderName(string $userId, string $displayName): string { + $prefixLen = min(16, max(0, 63 - strlen($userId))); + if ($prefixLen > 0) { + $prefix = $this->sanitizeDisplayName($displayName, $prefixLen); + if ($prefix !== '') { + return $prefix . '-' . $userId; + } + } + return $userId; + } + + /** + * Sanitizes a display name for use as (part of) a filesystem folder name. + * + * Replaces forward slash, backslash, and ASCII control characters (U+0000–U+001F) + * with a space, trims whitespace, then truncates to $maxChars Unicode code points. + */ + private function sanitizeDisplayName(string $name, int $maxChars): string { + // phpcs:ignore -- control characters are intentional + $name = preg_replace('/[\x00-\x1f\/\\\\]+/', ' ', $name); + $name = trim((string)$name); + if (mb_strlen($name) > $maxChars) { + $name = trim(mb_substr($name, 0, $maxChars)); + } + return $name; + } + /** * @return string[] */ diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index dfe6ea20da0..766e2df83bf 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -15,6 +15,7 @@ use OCA\Talk\Chat\MessageParser; use OCA\Talk\Chat\Notifier; use OCA\Talk\Chat\ReactionManager; +use OCA\Talk\Config; use OCA\Talk\Exceptions\CannotReachRemoteException; use OCA\Talk\Exceptions\ChatSummaryException; use OCA\Talk\Exceptions\ParticipantNotFoundException; @@ -44,6 +45,7 @@ use OCA\Talk\Service\AttachmentService; use OCA\Talk\Service\AvatarService; use OCA\Talk\Service\BotService; +use OCA\Talk\Service\ConversationFolderService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\ProxyCacheMessageService; use OCA\Talk\Service\ReminderService; @@ -69,6 +71,9 @@ use OCP\Comments\MessageTooLongException; use OCP\Comments\NotFoundException; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\FileInfo; +use OCP\Files\NotEnoughSpaceException; +use OCP\Files\NotFoundException as FileNotFoundException; use OCP\IL10N; use OCP\IRequest; use OCP\IUserManager; @@ -139,6 +144,8 @@ public function __construct( protected IAppConfig $appConfig, protected LoggerInterface $logger, protected ScheduledMessageService $scheduledMessageManager, + private ConversationFolderService $conversationFolderService, + private Config $talkConfig, ) { parent::__construct($appName, $request); } @@ -2379,4 +2386,209 @@ protected function createMentionString(string $type, string $id): string { // We want "federated_user/admin@example.tld" so we have to strip off the trailing "s" from the type "federated_users" return substr($type, 0, -1) . '/' . $id; } + + /** + * Prepare the conversation attachment folder and probe filename conflicts + * + * Creates the caller's conversation subfolder (and the room share) if not + * yet present, then creates or returns a Draft folder for staging uploads. + * Simulates the rename-on-conflict logic for each requested filename without + * requiring the files to already exist. + * + * The returned `folder` is the Draft path — a staging area that is NOT shared + * with the room, so other participants cannot see in-progress uploads or files + * from aborted messages. Files are moved into the shared subfolder atomically + * when the attachment endpoint is called. + * + * Recommended client flow: + * 1. Call this endpoint to obtain the Draft folder path and predicted final names. + * 2. Display the predicted names as accessibility placeholders immediately. + * 3. Upload each file to a random temporary name (e.g. a UUID) inside the + * returned Draft folder — do NOT upload to the predicted final name. + * 4. Call the attachment endpoint with the temp path as `filePath` and the + * original desired name as `fileName`. The server moves the file from Draft + * into the shared subfolder, resolves any conflicts, and posts the message. + * + * @param list $fileNames Desired filenames to probe + * @return DataResponse>}, array{}>|DataResponse + * + * 200: Draft folder path and rename map returned + * 500: Could not prepare the conversation folder + * 501: Conversation subfolders feature is disabled + * 507: User storage quota exceeded + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireLoggedInParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/attachment/folder', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function probeAttachmentFolder(array $fileNames = []): DataResponse { + if (!$this->talkConfig->isConversationSubfoldersEnabled()) { + return new DataResponse(['error' => $this->l->t('Conversation subfolders are disabled')], Http::STATUS_NOT_IMPLEMENTED); + } + + /** @var string $uid — non-null, guaranteed by RequireLoggedInParticipant */ + $uid = $this->userId; + + try { + $subfolder = $this->conversationFolderService->getOrCreateSubfolder($uid, $this->room); + $draftFolder = $this->conversationFolderService->getOrCreateDraftFolder($subfolder); + } catch (NotEnoughSpaceException) { + return new DataResponse(['error' => $this->l->t('Storage quota exceeded')], Http::STATUS_INSUFFICIENT_STORAGE); + } catch (\Exception $e) { + $this->logger->error('probeAttachmentFolder: failed to prepare conversation folder: {error}', [ + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + return new DataResponse(['error' => $this->l->t('Could not prepare conversation folder')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Return the Draft path — conflicts are checked against the shared subfolder + // (the final destination), not the Draft folder itself. + $folderPath = $this->conversationFolderService->getRelativePath($uid, $draftFolder); + $renames = $this->conversationFolderService->probeFilenames($subfolder, $fileNames); + + return new DataResponse(['folder' => $folderPath, 'renames' => $renames]); + } + + /** + * Post a file from the conversation Draft folder as a chat message + * + * The file must be inside the Draft folder returned by the probe endpoint. + * This endpoint moves the file from Draft into the shared conversation + * subfolder (resolving any name conflicts), creates the chat message, and + * returns the actual final filename. The subfolder is shared with the room + * via a folder-level TYPE_ROOM share — no per-file share is created, keeping + * the Share Overview clean. + * + * @param string $filePath Path of the file relative to the user's home root + * (e.g. "Talk/Group Chat-abc123/Draft/uuid.jpg") + * @param string $referenceId Client-generated reference ID for the message + * @param string $talkMetaData JSON-encoded metadata (caption, messageType, silent, …) + * @param string $fileName Desired final file name; the service resolves conflicts + * by appending " (1)", " (2)", … if already taken + * @return DataResponse>}, array{}>|DataResponse + * + * 200: File moved from Draft and posted as chat message + * 400: Path does not point to a file + * 404: File not found + * 422: File is not inside the conversation Draft folder for this room + * 500: Could not prepare the conversation folder + * 501: Conversation subfolders feature is disabled + * 507: User storage quota exceeded + */ + #[NoAdminRequired] + #[RequireModeratorOrNoLobby] + #[RequireLoggedInParticipant] + #[RequirePermission(permission: RequirePermission::CHAT)] + #[RequireReadWriteConversation] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/attachment', requirements: [ + 'apiVersion' => '(v1)', + 'token' => '[a-z0-9]{4,30}', + ])] + public function postAttachmentToRoom(string $filePath, string $referenceId, string $talkMetaData = '', string $fileName = ''): DataResponse { + if (!$this->talkConfig->isConversationSubfoldersEnabled()) { + return new DataResponse(['error' => $this->l->t('Conversation subfolders are disabled')], Http::STATUS_NOT_IMPLEMENTED); + } + + /** @var string $uid — non-null, guaranteed by RequireLoggedInParticipant */ + $uid = $this->userId; + + // Ensure the user's conversation subfolder exists and is shared with + // the room. The service creates the full folder hierarchy if missing. + try { + $subfolder = $this->conversationFolderService->getOrCreateSubfolder($uid, $this->room); + $draftFolder = $this->conversationFolderService->getOrCreateDraftFolder($subfolder); + } catch (NotEnoughSpaceException) { + return new DataResponse(['error' => $this->l->t('Storage quota exceeded')], Http::STATUS_INSUFFICIENT_STORAGE); + } catch (\Exception $e) { + $this->logger->error('postAttachmentToRoom: failed to prepare conversation folder: {error}', [ + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + return new DataResponse(['error' => $this->l->t('Could not prepare conversation folder')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // Look up the file in the caller's file tree. + try { + $node = $this->conversationFolderService->getFileNode($uid, $filePath); + } catch (FileNotFoundException) { + return new DataResponse(['error' => $this->l->t('File not found')], Http::STATUS_NOT_FOUND); + } + if ($node->getType() !== FileInfo::TYPE_FILE) { + return new DataResponse(['error' => $this->l->t('Path must point to a file')], Http::STATUS_BAD_REQUEST); + } + + // Verify the file is inside the Draft folder for this conversation. + if ($node->getParent()->getId() !== $draftFolder->getId()) { + return new DataResponse(['error' => $this->l->t('File is not inside the conversation draft folder for this room')], Http::STATUS_UNPROCESSABLE_ENTITY); + } + + // Move the file from Draft into the shared subfolder, resolving any name + // conflicts by appending " (1)", " (2)", … to the base name. + $desiredName = $fileName !== '' ? $fileName : $node->getName(); + $result = $this->conversationFolderService->finalizeUploadedFile($subfolder, $node, $desiredName); + $renameFrom = $result['from']; + $renameTo = $result['to']; + $node = $result['node']; + + // Parse talkMetaData for caption, messageType, silent, replyTo, threadId. + $metaData = json_decode($talkMetaData, true); + $metaData = is_array($metaData) ? $metaData : []; + + // Validate and sanitize messageType. + if (isset($metaData['messageType']) && $metaData['messageType'] === ChatManager::VERB_VOICE_MESSAGE) { + $mime = $node->getMimeType(); + if ($mime !== 'audio/mpeg' && $mime !== 'audio/wav') { + unset($metaData['messageType']); + } + } + $metaData['mimeType'] = $node->getMimeType(); + + if (isset($metaData['caption'])) { + if (is_string($metaData['caption']) && trim($metaData['caption']) !== '') { + $metaData['caption'] = trim($metaData['caption']); + } else { + unset($metaData['caption']); + } + } + + $silent = (bool)($metaData[Message::METADATA_SILENT] ?? false); + $replyToId = isset($metaData['replyTo']) ? (int)$metaData['replyTo'] : null; + $threadId = isset($metaData['threadId']) ? (int)$metaData['threadId'] : 0; + unset($metaData['replyTo'], $metaData['threadId'], $metaData[Message::METADATA_SILENT]); + + $replyToComment = null; + if ($replyToId !== null) { + try { + $replyToComment = $this->chatManager->getComment($this->room, (string)$replyToId); + } catch (\Exception) { + // Invalid replyTo — ignore. + } + } + + // Create the file_shared system message referencing the file by node ID. + // The parameters use 'fileId' instead of 'share' so no per-file TYPE_ROOM + // share is needed; access is controlled by the folder-level share. + $this->chatManager->addSystemMessage( + $this->room, + $this->participant, + Attendee::ACTOR_USERS, + $uid, + json_encode(['message' => 'file_shared', 'parameters' => ['fileId' => (string)$node->getId(), 'metaData' => $metaData]]), + $this->timeFactory->getDateTime(), + true, + $referenceId !== '' ? $referenceId : null, + $replyToComment, + false, + $silent, + $threadId, + ); + + return new DataResponse(['renames' => [[$renameFrom => $renameTo]]], Http::STATUS_OK); + } } diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index a4762f35fe6..1e1e9ca838d 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -3097,6 +3097,9 @@ public function getCapabilities(): DataResponse { if (isset($data['config']['conversations']['list-style'])) { $data['config']['conversations']['list-style'] = $this->talkConfig->getConversationsListStyle($this->userId); } + if (isset($data['config']['attachments']['conversation-subfolders'])) { + $data['config']['attachments']['conversation-subfolders'] = $this->talkConfig->isConversationSubfoldersEnabled(); + } if ($response->getHeaders()['X-Nextcloud-Talk-Hash']) { $headers['X-Nextcloud-Talk-Proxy-Hash'] = $response->getHeaders()['X-Nextcloud-Talk-Hash']; diff --git a/lib/Federation/Proxy/TalkV1/ProxyRequest.php b/lib/Federation/Proxy/TalkV1/ProxyRequest.php index 830cc69e17c..216295db91d 100644 --- a/lib/Federation/Proxy/TalkV1/ProxyRequest.php +++ b/lib/Federation/Proxy/TalkV1/ProxyRequest.php @@ -59,6 +59,9 @@ public function overwrittenRemoteTalkHash(string $hash): string { 'conversations' => [ 'list-style', ], + 'attachments' => [ + 'conversation-subfolders' => $this->talkConfig->isConversationSubfoldersEnabled(), + ], ], ] ])); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e620ab11f0c..6f1a8e00aec 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -743,6 +743,8 @@ * allowed: bool, * // User's attachment folder (only available for logged in users) * folder?: string, + * // Whether per-conversation subfolders are used for attachments + * 'conversation-subfolders': bool, * }, * call: array{ * // Whether calls are enabled diff --git a/lib/Service/ConversationFolderService.php b/lib/Service/ConversationFolderService.php new file mode 100644 index 00000000000..7bc0dabbd50 --- /dev/null +++ b/lib/Service/ConversationFolderService.php @@ -0,0 +1,287 @@ +/// + * + * and ensures a TYPE_ROOM share exists on the user subfolder so all + * room members can access files uploaded there. + */ +class ConversationFolderService { + public function __construct( + private TalkConfig $talkConfig, + private IRootFolder $rootFolder, + private IShareManager $shareManager, + private LoggerInterface $logger, + ) { + } + + /** + * Returns the user's conversation subfolder for the given room, + * creating the full folder hierarchy and the share if not yet present. + * + * @throws NotEnoughSpaceException if the user's storage quota is exhausted + * @throws \RuntimeException if a path component exists but is not a folder + * @throws \OCP\Files\NotPermittedException if a folder cannot be created + */ + public function getOrCreateSubfolder(string $userId, Room $room): Folder { + $userFolder = $this->rootFolder->getUserFolder($userId); + + $freeSpace = $userFolder->getFreeSpace(); + if ($freeSpace === 0) { + throw new NotEnoughSpaceException('User ' . $userId . ' has no free storage quota'); + } + $attachmentFolder = ltrim($this->talkConfig->getAttachmentFolder($userId), '/'); + + // Get or create attachment root (e.g. Talk/) + try { + $attachmentNode = $userFolder->get($attachmentFolder); + if (!$attachmentNode instanceof Folder) { + throw new \RuntimeException('Attachment folder path is not a directory: ' . $attachmentFolder); + } + } catch (NotFoundException) { + $attachmentNode = $userFolder->newFolder($attachmentFolder); + } + + // Get or create conversation folder (e.g. Talk/Room Name-token/). + // Use getConversationFolderName() (based on getDisplayName()) so the name + // matches what the client computes from conversation.displayName. + $convFolderName = $this->talkConfig->getConversationFolderName($room, $userId); + try { + $convFolder = $attachmentNode->get($convFolderName); + if (!$convFolder instanceof Folder) { + throw new \RuntimeException('Conversation folder path is not a directory: ' . $convFolderName); + } + } catch (NotFoundException) { + $convFolder = $attachmentNode->newFolder($convFolderName); + } + + // Get or create user subfolder (e.g. Talk/Room Name-token/Alice-alice/) + $subfolderName = $this->talkConfig->getConversationSubfolderName($userId); + try { + $subfolder = $convFolder->get($subfolderName); + if (!$subfolder instanceof Folder) { + throw new \RuntimeException('User subfolder path is not a directory: ' . $subfolderName); + } + } catch (NotFoundException) { + $subfolder = $convFolder->newFolder($subfolderName); + } + + $this->ensureSubfolderShared($subfolder, $userId, $room->getToken()); + + return $subfolder; + } + + /** + * Returns the draft upload folder for the given conversation subfolder. + * + * The Draft folder is a sibling of the user subfolder inside the conversation + * folder (e.g. Talk/Group Chat-abc123/Draft/). It is NOT shared with the room, + * so room members cannot see files while they are being composed. Files are + * moved into the shared user subfolder atomically when the message is posted. + * + * @throws \RuntimeException if the Draft path exists but is not a directory + */ + public function getOrCreateDraftFolder(Folder $subfolder): Folder { + $convFolder = $subfolder->getParent(); + try { + $draft = $convFolder->get('Draft'); + if (!$draft instanceof Folder) { + throw new \RuntimeException('Draft path inside conversation folder is not a directory'); + } + return $draft; + } catch (NotFoundException) { + return $convFolder->newFolder('Draft'); + } + } + + /** + * Return the path of $folder relative to the user's home folder root + * (e.g. "Talk/Group Chat-abc123/alice-alice"). + */ + public function getRelativePath(string $userId, Folder $folder): string { + $userFolder = $this->rootFolder->getUserFolder($userId); + return ltrim($userFolder->getRelativePath($folder->getPath()), '/'); + } + + /** + * Look up a file node by path inside the given user's home folder. + * + * @throws NotFoundException if the path does not exist + */ + public function getFileNode(string $userId, string $filePath): Node { + return $this->rootFolder->getUserFolder($userId)->get($filePath); + } + + /** + * Ensure the uploaded file node is stored under $desiredName inside + * $subfolder, renaming to a unique variant (e.g. "photo (1).jpg") if + * $desiredName is already taken by a different file. + * + * Typical use: the client uploaded to a temp path; pass that node plus the + * original file name and this method moves/renames it in one step. + * + * @return array{from: string, to: string, node: Node} + */ + public function finalizeUploadedFile(Folder $subfolder, Node $node, string $desiredName): array { + $finalName = $this->findUniqueName($subfolder, $desiredName, $node); + $targetPath = $subfolder->getPath() . '/' . $finalName; + $finalNode = $node; + if ($node->getPath() !== $targetPath) { + $finalNode = $node->move($targetPath); + } + return ['from' => $desiredName, 'to' => $finalName, 'node' => $finalNode]; + } + + /** + * Simulate rename-on-conflict for a batch of desired filenames without + * requiring the files to already exist. Intra-batch name reservations are + * tracked so that two files with the same desired name in the same batch get + * distinct final names (e.g. "photo.jpg" and "photo (1).jpg"). + * + * @param list $desiredNames + * @return list> + */ + public function probeFilenames(Folder $subfolder, array $desiredNames): array { + $reserved = []; + $result = []; + foreach ($desiredNames as $desiredName) { + $finalName = $this->findUniqueNameForProbe($subfolder, $desiredName, $reserved); + $result[] = [$desiredName => $finalName]; + $reserved[] = $finalName; + } + return $result; + } + + /** + * Find the first available name in $folder for $desiredName, treating + * $reserved (names claimed by earlier entries in the same probe batch) as + * already taken even if they do not yet exist on disk. + * + * @param list $reserved + */ + private function findUniqueNameForProbe(Folder $folder, string $desiredName, array $reserved): string { + if (!$this->isProbeNameTaken($folder, $desiredName, $reserved)) { + return $desiredName; + } + + $ext = pathinfo($desiredName, PATHINFO_EXTENSION); + $base = $ext !== '' ? mb_substr($desiredName, 0, -(mb_strlen($ext) + 1)) : $desiredName; + + for ($i = 1; $i < 1000; $i++) { + $candidate = $ext !== '' ? "$base ($i).$ext" : "$base ($i)"; + if (!$this->isProbeNameTaken($folder, $candidate, $reserved)) { + return $candidate; + } + } + + do { + $suffix = uniqid(); + $candidate = $ext !== '' ? "{$base}_{$suffix}.{$ext}" : "{$base}_{$suffix}"; + } while ($this->isProbeNameTaken($folder, $candidate, $reserved)); + return $candidate; + } + + /** + * @param list $reserved + */ + private function isProbeNameTaken(Folder $folder, string $name, array $reserved): bool { + return in_array($name, $reserved, true) || $folder->nodeExists($name); + } + + /** + * Find the first available name in $folder for $desiredName that does not + * conflict with any file other than $excludeNode itself. + * Tries "base (1).ext", "base (2).ext", … up to 999 before falling back + * to a uniqid suffix. + */ + private function findUniqueName(Folder $folder, string $desiredName, Node $excludeNode): string { + try { + $existing = $folder->get($desiredName); + if ($existing->getId() === $excludeNode->getId()) { + // File is already stored at the desired name — nothing to rename. + return $desiredName; + } + } catch (NotFoundException) { + return $desiredName; + } + + $ext = pathinfo($desiredName, PATHINFO_EXTENSION); + $base = $ext !== '' ? mb_substr($desiredName, 0, -(mb_strlen($ext) + 1)) : $desiredName; + + for ($i = 1; $i < 1000; $i++) { + $candidate = $ext !== '' ? "$base ($i).$ext" : "$base ($i)"; + try { + $folder->get($candidate); + } catch (NotFoundException) { + return $candidate; + } + } + + // Very unlikely: all 999 variants are taken — fall back to a unique suffix. + do { + $suffix = uniqid(); + $candidate = $ext !== '' ? "{$base}_{$suffix}.{$ext}" : "{$base}_{$suffix}"; + try { + $existing = $folder->get($candidate); + if ($existing->getId() === $excludeNode->getId()) { + return $candidate; + } + } catch (NotFoundException) { + return $candidate; + } + } while (true); + } + + /** + * Ensure a TYPE_ROOM share exists on $folder for the given room token. + * Uses an optimistic create-and-catch approach so it works correctly even + * when the folder is already shared with many rooms (no limit on the check). + */ + private function ensureSubfolderShared(Folder $folder, string $userId, string $token): void { + $share = $this->shareManager->newShare(); + $share->setNode($folder) + ->setShareType(IShare::TYPE_ROOM) + ->setSharedBy($userId) + ->setShareOwner($userId) + ->setSharedWith($token) + ->setPermissions(Constants::PERMISSION_READ) + ->setMailSend(false); + + try { + $this->shareManager->createShare($share); + $this->logger->debug('ConversationFolderService: created TYPE_ROOM share on {path} for room {token}', [ + 'path' => $folder->getPath(), + 'token' => $token, + ]); + } catch (GenericShareException $e) { + if ($e->getMessage() !== 'Already shared') { + throw $e; + } + // Share already exists — nothing to do. + } + } +} diff --git a/lib/Share/Listener.php b/lib/Share/Listener.php index b20af6c4c0f..62fb1216a10 100644 --- a/lib/Share/Listener.php +++ b/lib/Share/Listener.php @@ -11,6 +11,8 @@ use OC\Files\Filesystem; use OCA\Talk\Config; use OCA\Talk\Events\RoomDeletedEvent; +use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Manager; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Share\Events\BeforeShareCreatedEvent; @@ -24,16 +26,18 @@ class Listener implements IEventListener { public function __construct( protected Config $config, + protected Manager $manager, protected RoomShareProvider $roomShareProvider, ) { } #[\Override] public function handle(Event $event): void { - match (get_class($event)) { - BeforeShareCreatedEvent::class => $this->overwriteShareTarget($event), - VerifyMountPointEvent::class => $this->overwriteMountPoint($event), - RoomDeletedEvent::class => $this->roomDeletedEvent($event), + match (true) { + $event instanceof BeforeShareCreatedEvent => $this->overwriteShareTarget($event), + $event instanceof VerifyMountPointEvent => $this->overwriteMountPoint($event), + $event instanceof RoomDeletedEvent => $this->roomDeletedEvent($event), + default => null, }; } @@ -45,8 +49,22 @@ protected function overwriteShareTarget(BeforeShareCreatedEvent $event): void { return; } - $target = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/' . $share->getNode()->getName(); - $target = Filesystem::normalizePath($target); + // For shares of nodes that live inside the user's attachment subfolder + // hierarchy (e.g. /Talk//) we want the full + // relative path in the target so that recipients see the correct mount + // point under their own attachment folder. + $ownerUid = $share->getShareOwner(); + $relativePath = $share->getNode()->getName(); + if ($this->config->isConversationSubfoldersEnabled() && $ownerUid !== null) { + $attachmentFolder = ltrim($this->config->getAttachmentFolder($ownerUid), '/'); + $internalPath = $share->getNode()->getPath(); + $prefix = '/' . $ownerUid . '/files/' . $attachmentFolder . '/'; + if (str_starts_with($internalPath, $prefix)) { + $relativePath = substr($internalPath, strlen($prefix)); + } + } + + $target = Filesystem::normalizePath(RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/' . $relativePath); $share->setTarget($target); } @@ -58,11 +76,57 @@ protected function overwriteMountPoint(VerifyMountPointEvent $event): void { return; } - if ($event->getParent() === RoomShareProvider::TALK_FOLDER_PLACEHOLDER) { - $parent = $this->config->getAttachmentFolder($event->getUser()->getUID()); + $parent = $event->getParent(); + $placeholder = RoomShareProvider::TALK_FOLDER_PLACEHOLDER; + + if ($parent !== $placeholder && !str_starts_with($parent, $placeholder . '/')) { + return; + } + + $uid = $event->getUser()->getUID(); + $attachmentFolder = $this->config->getAttachmentFolder($uid); + + // Flat case: target was stored without a conversation subfolder (legacy shares). + if ($parent === $placeholder) { $event->setCreateParent(true); - $event->setParent($parent); + $event->setParent($attachmentFolder); + return; } + + // Nested case: only reached when conversation subfolders are enabled. + if (!$this->config->isConversationSubfoldersEnabled()) { + return; + } + + // Nested case: /{TALK_PLACEHOLDER}/[/] + // The conversation folder name was derived from the sharer's perspective. + // For 1-1 rooms the display name differs per user, so we must recalculate + // the folder name from the recipient's perspective. + // + // The super-share passed to VerifyMountPointEvent by files_sharing only + // carries id/shareOwner/nodeId/shareType/target — sharedWith is NOT set. + // Extract the room token from the conv folder name instead + // (format: "-", token = [a-z0-9]{4,30}). + $rest = substr($parent, strlen($placeholder) + 1); // 'SharersConvFolder[/UserSubfolder]' + $segments = explode('/', $rest, 2); // ['SharersConvFolder', 'UserSubfolder'?] + + $convFolder = $segments[0]; // fallback: keep sharer's name as-is + if (preg_match('/-([a-z0-9]{4,30})$/', $segments[0], $m)) { + try { + $room = $this->manager->getRoomByToken($m[1]); + $convFolder = $this->config->getConversationFolderName($room, $uid); + } catch (RoomNotFoundException) { + // Room gone — keep the sharer's folder name as a fallback. + } + } + + $resolvedParent = $attachmentFolder . '/' . $convFolder; + if (isset($segments[1]) && $segments[1] !== '') { + $resolvedParent .= '/' . $segments[1]; + } + + $event->setCreateParent(true); + $event->setParent($resolvedParent); } protected function roomDeletedEvent(RoomDeletedEvent $event): void { diff --git a/openapi-administration.json b/openapi-administration.json index eb8a3cde2a6..ad991cc2d39 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -141,7 +141,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -151,6 +152,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-backend-recording.json b/openapi-backend-recording.json index 97d7a0cd4b8..ea24c914bbc 100644 --- a/openapi-backend-recording.json +++ b/openapi-backend-recording.json @@ -64,7 +64,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -74,6 +75,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-backend-signaling.json b/openapi-backend-signaling.json index 6c3c373319c..c27f3a08b32 100644 --- a/openapi-backend-signaling.json +++ b/openapi-backend-signaling.json @@ -64,7 +64,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -74,6 +75,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-backend-sipbridge.json b/openapi-backend-sipbridge.json index d8912e5485a..c23432e87eb 100644 --- a/openapi-backend-sipbridge.json +++ b/openapi-backend-sipbridge.json @@ -115,7 +115,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -125,6 +126,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-bots.json b/openapi-bots.json index 8189fe5dab7..e4bdc480ba3 100644 --- a/openapi-bots.json +++ b/openapi-bots.json @@ -64,7 +64,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -74,6 +75,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-federation.json b/openapi-federation.json index 706b61d6369..1f99fd6b5da 100644 --- a/openapi-federation.json +++ b/openapi-federation.json @@ -115,7 +115,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -125,6 +126,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, diff --git a/openapi-full.json b/openapi-full.json index 35753f9ec65..cd620ec092c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -298,7 +298,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -308,6 +309,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, @@ -12159,6 +12164,656 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment/folder": { + "post": { + "operationId": "chat-probe-attachment-folder", + "summary": "Prepare the conversation attachment folder and probe filename conflicts", + "description": "Creates the caller's conversation subfolder (and the room share) if not yet present, then creates or returns a Draft folder for staging uploads. Simulates the rename-on-conflict logic for each requested filename without requiring the files to already exist.\nThe returned `folder` is the Draft path — a staging area that is NOT shared with the room, so other participants cannot see in-progress uploads or files from aborted messages. Files are moved into the shared subfolder atomically when the attachment endpoint is called.\nRecommended client flow: 1. Call this endpoint to obtain the Draft folder path and predicted final names. 2. Display the predicted names as accessibility placeholders immediately. 3. Upload each file to a random temporary name (e.g. a UUID) inside the returned Draft folder — do NOT upload to the predicted final name. 4. Call the attachment endpoint with the temp path as `filePath` and the original desired name as `fileName`. The server moves the file from Draft into the shared subfolder, resolves any conflicts, and posts the message.", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fileNames": { + "type": "array", + "default": [], + "description": "Desired filenames to probe", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Draft folder path and rename map returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "folder", + "renames" + ], + "properties": { + "folder": { + "type": "string" + }, + "renames": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Could not prepare the conversation folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "507": { + "description": "User storage quota exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "501": { + "description": "Conversation subfolders feature is disabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment": { + "post": { + "operationId": "chat-post-attachment-to-room", + "summary": "Post a file from the conversation Draft folder as a chat message", + "description": "The file must be inside the Draft folder returned by the probe endpoint. This endpoint moves the file from Draft into the shared conversation subfolder (resolving any name conflicts), creates the chat message, and returns the actual final filename. The subfolder is shared with the room via a folder-level TYPE_ROOM share — no per-file share is created, keeping the Share Overview clean.", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "filePath", + "referenceId" + ], + "properties": { + "filePath": { + "type": "string", + "description": "Path of the file relative to the user's home root (e.g. \"Talk/Group Chat-abc123/Draft/uuid.jpg\")" + }, + "referenceId": { + "type": "string", + "description": "Client-generated reference ID for the message" + }, + "talkMetaData": { + "type": "string", + "default": "", + "description": "JSON-encoded metadata (caption, messageType, silent, …)" + }, + "fileName": { + "type": "string", + "default": "", + "description": "Desired final file name; the service resolves conflicts by appending \" (1)\", \" (2)\", … if already taken" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "File moved from Draft and posted as chat message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "renames" + ], + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "422": { + "description": "File is not inside the conversation Draft folder for this room", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Could not prepare the conversation folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "507": { + "description": "User storage quota exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Path does not point to a file", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "501": { + "description": "Conversation subfolders feature is disabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { "get": { "operationId": "files_integration-get-room-by-file-id", diff --git a/openapi.json b/openapi.json index 1728eaae3c9..d80abd78355 100644 --- a/openapi.json +++ b/openapi.json @@ -251,7 +251,8 @@ "attachments": { "type": "object", "required": [ - "allowed" + "allowed", + "conversation-subfolders" ], "properties": { "allowed": { @@ -261,6 +262,10 @@ "folder": { "type": "string", "description": "User's attachment folder (only available for logged in users)" + }, + "conversation-subfolders": { + "type": "boolean", + "description": "Whether per-conversation subfolders are used for attachments" } } }, @@ -12047,6 +12052,656 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment/folder": { + "post": { + "operationId": "chat-probe-attachment-folder", + "summary": "Prepare the conversation attachment folder and probe filename conflicts", + "description": "Creates the caller's conversation subfolder (and the room share) if not yet present, then creates or returns a Draft folder for staging uploads. Simulates the rename-on-conflict logic for each requested filename without requiring the files to already exist.\nThe returned `folder` is the Draft path — a staging area that is NOT shared with the room, so other participants cannot see in-progress uploads or files from aborted messages. Files are moved into the shared subfolder atomically when the attachment endpoint is called.\nRecommended client flow: 1. Call this endpoint to obtain the Draft folder path and predicted final names. 2. Display the predicted names as accessibility placeholders immediately. 3. Upload each file to a random temporary name (e.g. a UUID) inside the returned Draft folder — do NOT upload to the predicted final name. 4. Call the attachment endpoint with the temp path as `filePath` and the original desired name as `fileName`. The server moves the file from Draft into the shared subfolder, resolves any conflicts, and posts the message.", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fileNames": { + "type": "array", + "default": [], + "description": "Desired filenames to probe", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Draft folder path and rename map returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "folder", + "renames" + ], + "properties": { + "folder": { + "type": "string" + }, + "renames": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Could not prepare the conversation folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "507": { + "description": "User storage quota exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "501": { + "description": "Conversation subfolders feature is disabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment": { + "post": { + "operationId": "chat-post-attachment-to-room", + "summary": "Post a file from the conversation Draft folder as a chat message", + "description": "The file must be inside the Draft folder returned by the probe endpoint. This endpoint moves the file from Draft into the shared conversation subfolder (resolving any name conflicts), creates the chat message, and returns the actual final filename. The subfolder is shared with the room via a folder-level TYPE_ROOM share — no per-file share is created, keeping the Share Overview clean.", + "tags": [ + "chat" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "filePath", + "referenceId" + ], + "properties": { + "filePath": { + "type": "string", + "description": "Path of the file relative to the user's home root (e.g. \"Talk/Group Chat-abc123/Draft/uuid.jpg\")" + }, + "referenceId": { + "type": "string", + "description": "Client-generated reference ID for the message" + }, + "talkMetaData": { + "type": "string", + "default": "", + "description": "JSON-encoded metadata (caption, messageType, silent, …)" + }, + "fileName": { + "type": "string", + "default": "", + "description": "Desired final file name; the service resolves conflicts by appending \" (1)\", \" (2)\", … if already taken" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9]{4,30}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "File moved from Draft and posted as chat message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "renames" + ], + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "422": { + "description": "File is not inside the conversation Draft folder for this room", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Could not prepare the conversation folder", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "507": { + "description": "User storage quota exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Path does not point to a file", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "501": { + "description": "Conversation subfolders feature is disabled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { "get": { "operationId": "files_integration-get-room-by-file-id", diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 2f888170513..8491ef4e7a3 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -134,6 +134,7 @@ export const mockedCapabilities: Capabilities = { attachments: { allowed: true, folder: '/Talk', + 'conversation-subfolders': true, }, call: { enabled: true, diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 19275376495..a1dc174f13d 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -239,6 +239,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-backend-recording.ts b/src/types/openapi/openapi-backend-recording.ts index 3c2dd490835..b2dfdcb2e89 100644 --- a/src/types/openapi/openapi-backend-recording.ts +++ b/src/types/openapi/openapi-backend-recording.ts @@ -53,6 +53,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-backend-signaling.ts b/src/types/openapi/openapi-backend-signaling.ts index c0ee2a3eeda..e9af4bf678a 100644 --- a/src/types/openapi/openapi-backend-signaling.ts +++ b/src/types/openapi/openapi-backend-signaling.ts @@ -39,6 +39,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-backend-sipbridge.ts b/src/types/openapi/openapi-backend-sipbridge.ts index 54294161636..4fa17d36ce1 100644 --- a/src/types/openapi/openapi-backend-sipbridge.ts +++ b/src/types/openapi/openapi-backend-sipbridge.ts @@ -164,6 +164,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-bots.ts b/src/types/openapi/openapi-bots.ts index a941632d280..0d0dbb40f04 100644 --- a/src/types/openapi/openapi-bots.ts +++ b/src/types/openapi/openapi-bots.ts @@ -57,6 +57,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-federation.ts b/src/types/openapi/openapi-federation.ts index 22532fa1f12..7160990adda 100644 --- a/src/types/openapi/openapi-federation.ts +++ b/src/types/openapi/openapi-federation.ts @@ -175,6 +175,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 308d725c442..d2ba011e53d 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -688,6 +688,48 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment/folder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Prepare the conversation attachment folder and probe filename conflicts + * @description Creates the caller's conversation subfolder (and the room share) if not yet present, then creates or returns a Draft folder for staging uploads. Simulates the rename-on-conflict logic for each requested filename without requiring the files to already exist. + * The returned `folder` is the Draft path — a staging area that is NOT shared with the room, so other participants cannot see in-progress uploads or files from aborted messages. Files are moved into the shared subfolder atomically when the attachment endpoint is called. + * Recommended client flow: 1. Call this endpoint to obtain the Draft folder path and predicted final names. 2. Display the predicted names as accessibility placeholders immediately. 3. Upload each file to a random temporary name (e.g. a UUID) inside the returned Draft folder — do NOT upload to the predicted final name. 4. Call the attachment endpoint with the temp path as `filePath` and the original desired name as `fileName`. The server moves the file from Draft into the shared subfolder, resolves any conflicts, and posts the message. + */ + post: operations["chat-probe-attachment-folder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post a file from the conversation Draft folder as a chat message + * @description The file must be inside the Draft folder returned by the probe endpoint. This endpoint moves the file from Draft into the shared conversation subfolder (resolving any name conflicts), creates the chat message, and returns the actual final filename. The subfolder is shared with the room via a folder-level TYPE_ROOM share — no per-file share is created, keeping the Share Overview clean. + */ + post: operations["chat-post-attachment-to-room"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -2527,6 +2569,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ @@ -7488,6 +7532,278 @@ export interface operations { }; }; }; + "chat-probe-attachment-folder": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Desired filenames to probe + * @default [] + */ + fileNames?: string[]; + }; + }; + }; + responses: { + /** @description Draft folder path and rename map returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + folder: string; + renames: { + [key: string]: string; + }[]; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Could not prepare the conversation folder */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Conversation subfolders feature is disabled */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description User storage quota exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; + "chat-post-attachment-to-room": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Path of the file relative to the user's home root (e.g. "Talk/Group Chat-abc123/Draft/uuid.jpg") */ + filePath: string; + /** @description Client-generated reference ID for the message */ + referenceId: string; + /** + * @description JSON-encoded metadata (caption, messageType, silent, …) + * @default + */ + talkMetaData?: string; + /** + * @description Desired final file name; the service resolves conflicts by appending " (1)", " (2)", … if already taken + * @default + */ + fileName?: string; + }; + }; + }; + responses: { + /** @description File moved from Draft and posted as chat message */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + renames: { + [key: string]: string; + }[]; + }; + }; + }; + }; + }; + /** @description Path does not point to a file */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description File not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description File is not inside the conversation Draft folder for this room */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Could not prepare the conversation folder */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Conversation subfolders feature is disabled */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description User storage quota exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 6f5e80806fb..38444c0a210 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -688,6 +688,48 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment/folder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Prepare the conversation attachment folder and probe filename conflicts + * @description Creates the caller's conversation subfolder (and the room share) if not yet present, then creates or returns a Draft folder for staging uploads. Simulates the rename-on-conflict logic for each requested filename without requiring the files to already exist. + * The returned `folder` is the Draft path — a staging area that is NOT shared with the room, so other participants cannot see in-progress uploads or files from aborted messages. Files are moved into the shared subfolder atomically when the attachment endpoint is called. + * Recommended client flow: 1. Call this endpoint to obtain the Draft folder path and predicted final names. 2. Display the predicted names as accessibility placeholders immediately. 3. Upload each file to a random temporary name (e.g. a UUID) inside the returned Draft folder — do NOT upload to the predicted final name. 4. Call the attachment endpoint with the temp path as `filePath` and the original desired name as `fileName`. The server moves the file from Draft into the shared subfolder, resolves any conflicts, and posts the message. + */ + post: operations["chat-probe-attachment-folder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/chat/{token}/attachment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post a file from the conversation Draft folder as a chat message + * @description The file must be inside the Draft folder returned by the probe endpoint. This endpoint moves the file from Draft into the shared conversation subfolder (resolving any name conflicts), creates the chat message, and returns the actual final filename. The subfolder is shared with the room via a folder-level TYPE_ROOM share — no per-file share is created, keeping the Share Overview clean. + */ + post: operations["chat-post-attachment-to-room"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/file/{fileId}": { parameters: { query?: never; @@ -1993,6 +2035,8 @@ export type components = { allowed: boolean; /** @description User's attachment folder (only available for logged in users) */ folder?: string; + /** @description Whether per-conversation subfolders are used for attachments */ + "conversation-subfolders": boolean; }; call: { /** @description Whether calls are enabled */ @@ -6921,6 +6965,278 @@ export interface operations { }; }; }; + "chat-probe-attachment-folder": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Desired filenames to probe + * @default [] + */ + fileNames?: string[]; + }; + }; + }; + responses: { + /** @description Draft folder path and rename map returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + folder: string; + renames: { + [key: string]: string; + }[]; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Could not prepare the conversation folder */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Conversation subfolders feature is disabled */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description User storage quota exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; + "chat-post-attachment-to-room": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + token: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Path of the file relative to the user's home root (e.g. "Talk/Group Chat-abc123/Draft/uuid.jpg") */ + filePath: string; + /** @description Client-generated reference ID for the message */ + referenceId: string; + /** + * @description JSON-encoded metadata (caption, messageType, silent, …) + * @default + */ + talkMetaData?: string; + /** + * @description Desired final file name; the service resolves conflicts by appending " (1)", " (2)", … if already taken + * @default + */ + fileName?: string; + }; + }; + }; + responses: { + /** @description File moved from Draft and posted as chat message */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + renames: { + [key: string]: string; + }[]; + }; + }; + }; + }; + }; + /** @description Path does not point to a file */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description File not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description File is not inside the conversation Draft folder for this room */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Could not prepare the conversation folder */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description Conversation subfolders feature is disabled */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + /** @description User storage quota exceeded */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + error: string; + }; + }; + }; + }; + }; + }; + }; "files_integration-get-room-by-file-id": { parameters: { query?: never; From fab2466ea669dee2396c62d2fd02e8c6927dc03e Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 30 Mar 2026 12:28:40 +0200 Subject: [PATCH 2/2] test(conversationfolder): add unit and integration tests for conversation subfolder feature Covers ConversationFolderService (quota, share deduplication, rename-on-conflict), Share/Listener mount point resolution (flat, nested group/1-1 rooms, room-not-found fallback), SystemMessage parser for the fileId path, ChatController attachment endpoint, and the full sharing-1/conversation-folder integration suite. AI-assisted-by: Claude Sonnet 4.6 Signed-off-by: Anna Larch --- .../features/bootstrap/CommandLineTrait.php | 19 + .../features/bootstrap/SharingContext.php | 233 ++++++++ .../sharing-1/conversation-folder.feature | 151 +++++ tests/php/CapabilitiesTest.php | 10 + tests/php/Chat/Parser/SystemMessageTest.php | 194 +++++- tests/php/Chat/SystemMessage/ListenerTest.php | 139 +++++ tests/php/Controller/ChatControllerTest.php | 8 + .../Service/ConversationFolderServiceTest.php | 563 ++++++++++++++++++ tests/php/Share/ListenerTest.php | 311 ++++++++++ tests/psalm-baseline.xml | 5 +- 10 files changed, 1619 insertions(+), 14 deletions(-) create mode 100644 tests/integration/features/sharing-1/conversation-folder.feature create mode 100644 tests/php/Service/ConversationFolderServiceTest.php create mode 100644 tests/php/Share/ListenerTest.php diff --git a/tests/integration/features/bootstrap/CommandLineTrait.php b/tests/integration/features/bootstrap/CommandLineTrait.php index 704145bd10b..723043f1a31 100644 --- a/tests/integration/features/bootstrap/CommandLineTrait.php +++ b/tests/integration/features/bootstrap/CommandLineTrait.php @@ -43,6 +43,11 @@ public function runOcc(array $args = [], array $env = []): int { 'maintenance:mode', ], true); + $clearAppConfigCache = in_array($args[0], [ + 'config:app:delete', + 'config:app:set', + ], true); + $args[] = '--no-ansi'; if ($this->currentServer === 'REMOTE') { @@ -79,6 +84,20 @@ public function runOcc(array $args = [], array $env = []): int { } } + if ($clearAppConfigCache) { + // config:app:set/delete writes to DB but NC caches app config in APCu + // (web-server process APCu ≠ CLI APCu), so we must also flush the + // APCu data cache via an HTTP request to make the web server pick up + // the new value on the very next request. + $client = new GuzzleHttp\Client(); + + if ($this->currentServer === 'REMOTE') { + $client->request('GET', $this->remoteServerUrl . 'apps/testing/clean_apcu_cache.php'); + } else { + $client->request('GET', $this->localServerUrl . 'apps/testing/clean_apcu_cache.php'); + } + } + return $this->lastCode; } diff --git a/tests/integration/features/bootstrap/SharingContext.php b/tests/integration/features/bootstrap/SharingContext.php index 878e6451145..8d880478721 100644 --- a/tests/integration/features/bootstrap/SharingContext.php +++ b/tests/integration/features/bootstrap/SharingContext.php @@ -25,6 +25,8 @@ class SharingContext implements Context { private array $adminUser; private string $regularUserPassword; private ?\SimpleXMLElement $lastCreatedShareData = null; + /** @var array Draft folder paths returned by the probe endpoint, keyed by "{user}|{token}". */ + private array $draftFolderByUserToken = []; public function __construct(string $baseUrl, array $admin, string $regularUserPassword) { $this->baseUrl = $baseUrl; @@ -54,6 +56,147 @@ public function userCreatesFolder(string $user, string $destination): void { $this->theHTTPStatusCodeShouldBe(201); } + #[Given('/^user "([^"]*)" uploads file "([^"]*)" with content "([^"]*)" to conversation folder for room "([^"]*)" with name "([^"]*)"$/')] + public function userUploadsFileToConversationFolder(string $user, string $filename, string $content, string $room, string $displayName): void { + $this->currentUser = $user; + + // $displayName is no longer needed: the probe endpoint creates the folder + // hierarchy server-side and returns the Draft path the client should + // upload into. The parameter is kept so the existing Gherkin steps still + // match without rewriting every scenario. + unset($displayName); + + $token = FeatureContext::getTokenForIdentifier($room); + $draftPath = $this->ensureDraftFolderViaProbe($user, $token); + + $this->sendingToDav('PUT', "/$user/$draftPath/$filename", null, $content); + $this->theHTTPStatusCodeShouldBe(201); + } + + #[When('/^user "([^"]*)" posts file "([^"]*)" from conversation folder of room "([^"]*)" with name "([^"]*)" with (\d+) \(v1\)$/')] + public function userPostsFileFromConversationFolder(string $user, string $filename, string $room, string $displayName, int $statusCode, ?TableNode $body = null): void { + $this->currentUser = $user; + unset($displayName); // see userUploadsFileToConversationFolder + + $token = FeatureContext::getTokenForIdentifier($room); + $draftPath = $this->ensureDraftFolderViaProbe($user, $token); + $filePath = $draftPath . '/' . $filename; + + $talkMetaData = []; + if ($body instanceof TableNode) { + foreach ($body->getRowsHash() as $key => $value) { + if ($key === 'talkMetaData.replyTo' || $key === 'talkMetaData.threadId') { + $value = FeatureContext::getMessageIdForText($value); + } + if (str_starts_with($key, 'talkMetaData.')) { + $talkMetaData[substr($key, 13)] = $value; + } + } + } + + $this->sendingToTalkAttachmentEndpoint($token, $filePath, $talkMetaData); + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + #[When('/^user "([^"]*)" posts file "([^"]*)" from their home to room "([^"]*)" with (\d+) \(v1\)$/')] + public function userPostsFileFromHomeToRoom(string $user, string $filename, string $room, int $statusCode): void { + $this->currentUser = $user; + + $token = FeatureContext::getTokenForIdentifier($room); + $this->sendingToTalkAttachmentEndpoint($token, $filename, []); + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + /** + * Post a file that was uploaded under a temporary name, passing the desired + * final name via `fileName` so the backend renames it with conflict resolution. + */ + #[When('/^user "([^"]*)" posts temp file "([^"]*)" with name "([^"]*)" from conversation folder of room "([^"]*)" with name "([^"]*)" with (\d+) \(v1\)$/')] + public function userPostsTempFileWithDesiredName(string $user, string $tempFileName, string $desiredName, string $room, string $displayName, int $statusCode): void { + $this->currentUser = $user; + unset($displayName); // see userUploadsFileToConversationFolder + + $token = FeatureContext::getTokenForIdentifier($room); + $draftPath = $this->ensureDraftFolderViaProbe($user, $token); + $filePath = $draftPath . '/' . $tempFileName; + $this->sendingToTalkAttachmentEndpoint($token, $filePath, [], $desiredName); + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + /** + * Call the probe endpoint for a room and store the response. + * $fileNames is a comma-separated list of desired filenames. + */ + #[When('/^user "([^"]*)" probes attachment folder for room "([^"]*)" with files "([^"]*)" with (\d+) \(v1\)$/')] + public function userProbesAttachmentFolder(string $user, string $room, string $fileNamesCsv, int $statusCode): void { + $this->currentUser = $user; + $token = FeatureContext::getTokenForIdentifier($room); + $fileNames = array_map('trim', explode(',', $fileNamesCsv)); + $this->sendingToTalkAttachmentFolderProbe($token, $fileNames); + $this->theHTTPStatusCodeShouldBe($statusCode); + } + + /** + * Assert that the probe response folder path matches a REGEXP. + */ + #[Then('/^the probe response folder matches "([^"]*)"$/')] + public function theProbeFolderMatches(string $pattern): void { + $body = $this->response->getBody(); + $body->rewind(); + $data = json_decode($body->getContents(), true); + $folder = $data['ocs']['data']['folder'] ?? ''; + \PHPUnit\Framework\Assert::assertMatchesRegularExpression($pattern, $folder, 'Probe folder path did not match expected pattern'); + } + + /** + * Assert that the probe response renames map contains a specific entry. + */ + #[Then('/^the probe response renames "([^"]*)" to "([^"]*)"$/')] + public function theProbeResponseRenames(string $from, string $to): void { + $body = $this->response->getBody(); + $body->rewind(); + $data = json_decode($body->getContents(), true); + $renames = $data['ocs']['data']['renames'] ?? []; + + foreach ($renames as $rename) { + if (isset($rename[$from]) && $rename[$from] === $to) { + return; + } + } + + throw new \RuntimeException(sprintf( + 'Expected probe renames to contain "%s" => "%s", but got: %s', + $from, + $to, + json_encode($renames), + )); + } + + /** + * Assert that the last attachment response contains a specific rename mapping. + * The OCS data is expected to contain renames: [{"$from": "$to"}]. + */ + #[Then('/^the last attachment response renames "([^"]*)" to "([^"]*)"$/')] + public function theLastAttachmentResponseRenames(string $from, string $to): void { + $body = $this->response->getBody(); + $body->rewind(); + $data = json_decode($body->getContents(), true); + $renames = $data['ocs']['data']['renames'] ?? []; + + foreach ($renames as $rename) { + if (isset($rename[$from]) && $rename[$from] === $to) { + return; + } + } + + throw new \RuntimeException(sprintf( + 'Expected renames to contain "%s" => "%s", but got: %s', + $from, + $to, + json_encode($renames), + )); + } + #[Given('user :user moves file :source to :destination')] public function userMovesFileTo(string $user, string $source, string $destination): void { $this->currentUser = $user; @@ -844,4 +987,94 @@ private function getArrayOfShareesResponded(\SimpleXMLElement $response, string } return $sharees; } + + // ------------------------------------------------------------------------- + // Conversation-folder helpers (lazy per-conversation attachment subfolders) + // ------------------------------------------------------------------------- + + /** + * Call the probe endpoint to lazily create (and share) the conversation + * folder hierarchy for $user/$token, then return the Draft folder path the + * client should upload into. Result is cached per (user, token) so repeated + * uploads in the same scenario don't issue redundant probes. + */ + private function ensureDraftFolderViaProbe(string $user, string $token): string { + $cacheKey = $user . '|' . $token; + if (isset($this->draftFolderByUserToken[$cacheKey])) { + return $this->draftFolderByUserToken[$cacheKey]; + } + + $previousUser = $this->currentUser; + $this->currentUser = $user; + $this->sendingToTalkAttachmentFolderProbe($token, []); + $this->currentUser = $previousUser; + + \PHPUnit\Framework\Assert::assertSame( + 200, + $this->response->getStatusCode(), + 'Probe call to prepare the draft folder failed for ' . $cacheKey, + ); + + $body = $this->response->getBody(); + $body->rewind(); + $data = json_decode($body->getContents(), true); + $folder = $data['ocs']['data']['folder'] ?? null; + if (!is_string($folder) || $folder === '') { + throw new \RuntimeException('Probe response did not contain a draft folder path'); + } + + $this->draftFolderByUserToken[$cacheKey] = $folder; + return $folder; + } + + /** + * POST to the Talk attachment folder probe endpoint (OCS v2). + * + * @param list $fileNames + */ + private function sendingToTalkAttachmentFolderProbe(string $token, array $fileNames): void { + $fullUrl = $this->baseUrl . 'ocs/v2.php/apps/spreed/api/v1/chat/' . $token . '/attachment/folder?format=json'; + $client = new Client(); + $formParams = []; + foreach ($fileNames as $i => $name) { + $formParams['fileNames[' . $i . ']'] = $name; + } + $options = [ + 'auth' => [$this->currentUser, $this->regularUserPassword], + 'headers' => ['OCS_APIREQUEST' => 'true'], + 'form_params' => $formParams, + ]; + try { + $this->response = $client->request('POST', $fullUrl, $options); + $this->responseBody = null; + } catch (GuzzleHttp\Exception\RequestException $ex) { + $this->response = $ex->getResponse(); + $this->responseBody = null; + } + } + + /** + * POST to the Talk attachment endpoint (OCS v2). + */ + private function sendingToTalkAttachmentEndpoint(string $token, string $filePath, array $talkMetaData, string $fileName = ''): void { + $fullUrl = $this->baseUrl . 'ocs/v2.php/apps/spreed/api/v1/chat/' . $token . '/attachment?format=json'; + $client = new Client(); + $options = [ + 'auth' => [$this->currentUser, $this->regularUserPassword], + 'headers' => ['OCS_APIREQUEST' => 'true'], + 'form_params' => [ + 'filePath' => $filePath, + 'fileName' => $fileName, + 'referenceId' => '', + 'talkMetaData' => !empty($talkMetaData) ? json_encode($talkMetaData) : '', + ], + ]; + try { + $this->response = $client->request('POST', $fullUrl, $options); + $this->responseBody = null; + } catch (GuzzleHttp\Exception\RequestException $ex) { + $this->response = $ex->getResponse(); + $this->responseBody = null; + } + } } diff --git a/tests/integration/features/sharing-1/conversation-folder.feature b/tests/integration/features/sharing-1/conversation-folder.feature new file mode 100644 index 00000000000..a6b33b2e1d8 --- /dev/null +++ b/tests/integration/features/sharing-1/conversation-folder.feature @@ -0,0 +1,151 @@ +Feature: sharing-1/conversation-folder + + Background: + Given user "participant1" exists + Given user "participant2" exists + Given user "participant3" exists + + Scenario: Upload file to conversation folder and post as attachment to group room + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "group room" with name "Group room" + And user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "Group room" with 200 (v1) + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | group room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + And user "participant2" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | group room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + And user "participant1" gets all shares + And share is returned with + | uid_owner | participant1 | + | displayname_owner | participant1-displayname | + | item_type | folder | + | permissions | 1 | + | file_target | REGEXP /^\/\{TALK_PLACEHOLDER\}\/.+\/participant1-dis-participant1$/ | + And user "participant2" gets all received shares + And share is returned with + | uid_owner | participant1 | + | item_type | folder | + | permissions | 1 | + | path | REGEXP /^\/Talk\/.+\/participant1-dis-participant1$/ | + + Scenario: Upload file to conversation folder and post as attachment to public room + Given user "participant1" creates room "public room" (v4) + | roomType | 3 | + | roomName | room | + And user "participant1" renames room "public room" to "Public room" with 200 (v4) + And user "participant1" adds user "participant2" to room "public room" with 200 (v4) + When user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "public room" with name "Public room" + And user "participant1" posts file "test.txt" from conversation folder of room "public room" with name "Public room" with 200 (v1) + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | public room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + + Scenario: Post with caption and as a reply + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + And user "participant2" sends message "Message 1" to room "group room" with 201 + And user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "group room" with name "Group room" + When user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "Group room" with 200 (v1) + | talkMetaData.caption | Caption text | + | talkMetaData.replyTo | Message 1 | + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | group room | users | participant1 | participant1-displayname | Caption text | "IGNORE" | Message 1 | + | group room | users | participant2 | participant2-displayname | Message 1 | [] | | + + Scenario: Room name with a hyphen does not confuse token extraction + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | My-Group | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "group room" with name "My-Group" + And user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "My-Group" with 200 (v1) + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | group room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + + Scenario: Room name with a slash is sanitized to a space in the folder name + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | Team/Chat | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "group room" with name "Team/Chat" + And user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "Team/Chat" with 200 (v1) + Then user "participant1" sees the following messages in room "group room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | group room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + + Scenario: Posting file outside conversation folder is rejected + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant1" posts file "welcome.txt" from their home to room "group room" with 422 (v1) + + Scenario: Non-participant cannot post attachment + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant3" posts file "welcome.txt" from their home to room "group room" with 404 (v1) + + Scenario: Conflicting file name is renamed with a numeric suffix + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + And user "participant1" uploads file "test.txt" with content "Original" to conversation folder for room "group room" with name "Group room" + And user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "Group room" with 200 (v1) + And user "participant1" uploads file "temp-upload.txt" with content "Second" to conversation folder for room "group room" with name "Group room" + When user "participant1" posts temp file "temp-upload.txt" with name "test.txt" from conversation folder of room "group room" with name "Group room" with 200 (v1) + Then the last attachment response renames "test.txt" to "test (1).txt" + + Scenario: Unchanged file name is echoed back in the renames response + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" uploads file "unique.txt" with content "Hello" to conversation folder for room "group room" with name "Group room" + When user "participant1" posts temp file "unique.txt" with name "unique.txt" from conversation folder of room "group room" with name "Group room" with 200 (v1) + Then the last attachment response renames "unique.txt" to "unique.txt" + + Scenario: Posting attachment returns 501 when conversation subfolders feature is disabled + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + And user "participant1" uploads file "test.txt" with content "Hello!" to conversation folder for room "group room" with name "room" + And the following "spreed" app config is set + | conversation_subfolders | no | + When user "participant1" posts file "test.txt" from conversation folder of room "group room" with name "room" with 501 (v1) + + Scenario: Probe endpoint creates folder and returns Draft path with no-conflict rename map + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + When user "participant1" probes attachment folder for room "group room" with files "photo.jpg, notes.txt" with 200 (v1) + Then the probe response folder matches "/^Talk\/.+-[a-z0-9]+\/Draft$/" + And the probe response renames "photo.jpg" to "photo.jpg" + And the probe response renames "notes.txt" to "notes.txt" + + Scenario: Probe endpoint detects conflict with file already in shared subfolder + Given user "participant1" creates room "group room" (v4) + | roomType | 2 | + | roomName | room | + And user "participant1" renames room "group room" to "Group room" with 200 (v4) + And user "participant1" adds user "participant2" to room "group room" with 200 (v4) + And user "participant1" uploads file "photo.jpg" with content "original" to conversation folder for room "group room" with name "Group room" + And user "participant1" posts file "photo.jpg" from conversation folder of room "group room" with name "Group room" with 200 (v1) + When user "participant1" probes attachment folder for room "group room" with files "photo.jpg" with 200 (v1) + Then the probe response renames "photo.jpg" to "photo (1).jpg" diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 8ee00b3fe29..d8a6dad6649 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -110,6 +110,10 @@ public function testGetCapabilitiesGuest(): void { ->method('isBreakoutRoomsEnabled') ->willReturn(false); + $this->talkConfig->expects($this->once()) + ->method('isConversationSubfoldersEnabled') + ->willReturn(false); + $this->talkConfig->expects($this->once()) ->method('getChatStyle') ->with(null) @@ -161,6 +165,7 @@ public function testGetCapabilitiesGuest(): void { 'config' => [ 'attachments' => [ 'allowed' => false, + 'conversation-subfolders' => false, ], 'call' => [ 'enabled' => true, @@ -286,6 +291,10 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea ->method('isBreakoutRoomsEnabled') ->willReturn(true); + $this->talkConfig->expects($this->once()) + ->method('isConversationSubfoldersEnabled') + ->willReturn(true); + $this->talkConfig->expects($this->once()) ->method('getAttachmentFolder') ->with('uid') @@ -382,6 +391,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'config' => [ 'attachments' => [ 'allowed' => true, + 'conversation-subfolders' => true, 'folder' => '/Talk', ], 'call' => [ diff --git a/tests/php/Chat/Parser/SystemMessageTest.php b/tests/php/Chat/Parser/SystemMessageTest.php index c26f3b54f03..cf8c689a0f0 100644 --- a/tests/php/Chat/Parser/SystemMessageTest.php +++ b/tests/php/Chat/Parser/SystemMessageTest.php @@ -374,6 +374,18 @@ public static function dataParseMessage(): array { '*You shared a file which is no longer available*', ['actor' => ['id' => 'actor', 'type' => 'user']], ], + ['file_shared', ['fileId' => '42'], 'recipient', + '{file}', + ['actor' => ['id' => 'actor', 'type' => 'user'], 'file' => ['id' => 'file-from-node']], + ], + ['file_shared', ['fileId' => NotFoundException::class], 'actor', + '*You shared a file which is no longer available*', + ['actor' => ['id' => 'actor', 'type' => 'user']], + ], + ['file_shared', ['fileId' => '42', 'metaData' => ['messageType' => 'voice-message', 'caption' => 'Hello!']], 'recipient', + 'Hello!', + ['actor' => ['id' => 'actor', 'type' => 'user'], 'file' => ['id' => 'file-from-node']], + ], ['read_only', [], 'recipient', '{actor} locked the conversation', ['actor' => ['id' => 'actor', 'type' => 'user']], @@ -573,7 +585,7 @@ public function testParseMessage(string $message, array $parameters, ?string $re /** @var Room&MockObject $room */ $room = $this->createMock(Room::class); - $parser = $this->getParser(['getActorFromComment', 'getUser', 'getRemoteUser', 'getGroup', 'getGuest', 'parseCall', 'getFileFromShare']); + $parser = $this->getParser(['getActorFromComment', 'getUser', 'getRemoteUser', 'getGroup', 'getGuest', 'parseCall', 'getFileFromShare', 'getFileFromNodeId']); $parser->expects($this->once()) ->method('getActorFromComment') ->with($room, $comment) @@ -613,20 +625,39 @@ public function testParseMessage(string $message, array $parameters, ?string $re } if ($message === 'file_shared') { - if (is_subclass_of($parameters['share'], \Exception::class)) { - $parser->expects($this->once()) - ->method('getFileFromShare') - ->with($room, $participant, $parameters['share']) - ->willThrowException(new $parameters['share']()); - } else { - $parser->expects($this->once()) - ->method('getFileFromShare') - ->with($room, $participant, $parameters['share']) - ->willReturn(['id' => 'file-from-share']); + if (isset($parameters['share'])) { + if (is_subclass_of($parameters['share'], \Exception::class)) { + $parser->expects($this->once()) + ->method('getFileFromShare') + ->with($room, $participant, $parameters['share']) + ->willThrowException(new $parameters['share']()); + } else { + $parser->expects($this->once()) + ->method('getFileFromShare') + ->with($room, $participant, $parameters['share']) + ->willReturn(['id' => 'file-from-share']); + } + $parser->expects($this->never()) + ->method('getFileFromNodeId'); + } elseif (isset($parameters['fileId'])) { + $parser->expects($this->never()) + ->method('getFileFromShare'); + if (is_subclass_of($parameters['fileId'], \Exception::class)) { + $parser->expects($this->once()) + ->method('getFileFromNodeId') + ->willThrowException(new $parameters['fileId']()); + } else { + $parser->expects($this->once()) + ->method('getFileFromNodeId') + ->with($room, $participant, (int)$parameters['fileId']) + ->willReturn(['id' => 'file-from-node']); + } } } else { $parser->expects($this->never()) ->method('getFileFromShare'); + $parser->expects($this->never()) + ->method('getFileFromNodeId'); } $chatMessage = new Message($room, $participant, $comment, $this->l); @@ -640,8 +671,14 @@ public function testParseMessage(string $message, array $parameters, ?string $re $this->assertSame($expectedMessage, $chatMessage->getMessage()); $this->assertSame($expectedParameters, $chatMessage->getMessageParameters()); - if ($message === 'file_shared' && !is_subclass_of($parameters['share'], \Exception::class)) { - $this->assertSame(ChatManager::VERB_MESSAGE, $chatMessage->getMessageType()); + if ($message === 'file_shared') { + if (isset($parameters['share']) && !is_subclass_of($parameters['share'], \Exception::class)) { + $this->assertSame(ChatManager::VERB_MESSAGE, $chatMessage->getMessageType()); + } elseif (isset($parameters['fileId']) && !is_subclass_of($parameters['fileId'], \Exception::class)) { + $metaType = $parameters['metaData']['messageType'] ?? null; + $expected = $metaType === ChatManager::VERB_VOICE_MESSAGE ? ChatManager::VERB_VOICE_MESSAGE : ChatManager::VERB_MESSAGE; + $this->assertSame($expected, $chatMessage->getMessageType()); + } } } @@ -1054,6 +1091,137 @@ public function testGetFileFromShareThrows(): void { self::invokePrivate($parser, 'getFileFromShare', [$room, $participant, '23', false]); } + public function testGetFileFromNodeIdForUser(): void { + $room = $this->createMock(Room::class); + + $node = $this->createMock(Node::class); + $node->expects($this->exactly(2)) + ->method('getId') + ->willReturn(42); + $node->expects($this->once()) + ->method('getName') + ->willReturn('photo.jpg'); + $node->expects($this->once()) + ->method('getPath') + ->willReturn('/alice/files/Talk/Room-TOKEN/Alice-alice/photo.jpg'); + $node->expects($this->once()) + ->method('getSize') + ->willReturn(12345); + $node->expects($this->once()) + ->method('getEtag') + ->willReturn(md5('etag')); + $node->expects($this->once()) + ->method('getPermissions') + ->willReturn(27); + $node->expects($this->atLeastOnce()) + ->method('getMimeType') + ->willReturn('image/jpeg'); + + $userFolder = $this->createMock(Folder::class); + $userFolder->expects($this->once()) + ->method('getFirstNodeById') + ->with(42) + ->willReturn($node); + $userFolder->expects($this->never()) + ->method('getById'); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('alice') + ->willReturn($userFolder); + + $this->previewManager->expects($this->once()) + ->method('isMimeSupported') + ->with('image/jpeg') + ->willReturn(true); + $this->previewManager->expects($this->never()) + ->method('isAvailable'); + + $this->filesMetadataCache->expects($this->once()) + ->method('getImageMetadataForFileId') + ->with(42) + ->willReturn([]); + + $this->url->expects($this->once()) + ->method('linkToRouteAbsolute') + ->with('files.viewcontroller.showFile', ['fileid' => 42]) + ->willReturn('absolute-link'); + + $participant = $this->createMock(Participant::class); + $attendee = Attendee::fromRow([ + 'actor_type' => 'users', + 'actor_id' => 'alice', + ]); + $participant->expects($this->any()) + ->method('getAttendee') + ->willReturn($attendee); + + $parser = $this->getParser(); + $this->assertSame([ + 'type' => 'file', + 'id' => '42', + 'name' => 'photo.jpg', + 'size' => '12345', + 'path' => 'Talk/Room-TOKEN/Alice-alice/photo.jpg', + 'link' => 'absolute-link', + 'etag' => md5('etag'), + 'permissions' => '27', + 'mimetype' => 'image/jpeg', + 'preview-available' => 'yes', + 'hide-download' => 'no', + ], self::invokePrivate($parser, 'getFileFromNodeId', [$room, $participant, 42])); + } + + public function testGetFileFromNodeIdThrowsWhenNotFound(): void { + $room = $this->createMock(Room::class); + + $userFolder = $this->createMock(Folder::class); + $userFolder->expects($this->once()) + ->method('getFirstNodeById') + ->with(42) + ->willReturn(null); + $userFolder->expects($this->never()) + ->method('getById'); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('alice') + ->willReturn($userFolder); + + $participant = $this->createMock(Participant::class); + $attendee = Attendee::fromRow([ + 'actor_type' => 'users', + 'actor_id' => 'alice', + ]); + $participant->expects($this->any()) + ->method('getAttendee') + ->willReturn($attendee); + + $parser = $this->getParser(); + $this->expectException(NotFoundException::class); + self::invokePrivate($parser, 'getFileFromNodeId', [$room, $participant, 42]); + } + + public function testGetFileFromNodeIdThrowsForGuest(): void { + $room = $this->createMock(Room::class); + + $participant = $this->createMock(Participant::class); + $attendee = Attendee::fromRow([ + 'actor_type' => 'guests', + 'actor_id' => 'guest-hash', + ]); + $participant->expects($this->any()) + ->method('getAttendee') + ->willReturn($attendee); + + $this->rootFolder->expects($this->never()) + ->method('getUserFolder'); + + $parser = $this->getParser(); + $this->expectException(ShareNotFound::class); + self::invokePrivate($parser, 'getFileFromNodeId', [$room, $participant, 42]); + } + public static function dataGetActor(): array { return [ ['users', [], ['user'], ['user']], diff --git a/tests/php/Chat/SystemMessage/ListenerTest.php b/tests/php/Chat/SystemMessage/ListenerTest.php index 0f2bfd9df4f..be2e0b262bb 100644 --- a/tests/php/Chat/SystemMessage/ListenerTest.php +++ b/tests/php/Chat/SystemMessage/ListenerTest.php @@ -14,6 +14,7 @@ use OCA\Talk\Events\AParticipantModifiedEvent; use OCA\Talk\Events\ARoomModifiedEvent; use OCA\Talk\Events\AttendeesAddedEvent; +use OCA\Talk\Events\BeforeDuplicateShareSentEvent; use OCA\Talk\Events\ParticipantModifiedEvent; use OCA\Talk\Events\RoomModifiedEvent; use OCA\Talk\Manager; @@ -26,11 +27,14 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Node; use OCP\IL10N; use OCP\IRequest; use OCP\ISession; use OCP\IUser; use OCP\IUserSession; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\IShare; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; @@ -641,4 +645,139 @@ public function testAfterCallRecordingSet(int $newStatus, int $oldStatus, ?strin self::invokePrivate($this->listener, 'handle', [$event]); } + + // ------------------------------------------------------------------------- + // handle() — file share notification routing (ShareCreatedEvent path) + // ------------------------------------------------------------------------- + + /** + * Build a Listener whose request mock returns the given route for _route + * and DUMMY_REFERENCE_ID for referenceId (matching setUp behaviour). + */ + private function makeListenerWithRoute(string $route): Listener { + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnMap([ + ['_route', null, $route], + ['referenceId', null, self::DUMMY_REFERENCE_ID], + ['talkMetaData', null, ''], + ]); + + $l = $this->createMock(IL10N::class); + $l->method('t')->willReturnCallback(fn ($s, $a) => vsprintf($s, $a)); + + return new Listener( + $request, + $this->chatManager, + $this->talkSession, + $this->session, + $this->userSession, + $this->timeFactory, + $this->manager, + $this->participantService, + $this->messageParser, + $this->threadService, + $l, + $this->logger, + ); + } + + private function makeShareMock(int $shareType, string $sharedWith, string $mimeType = 'image/png'): IShare&MockObject { + $node = $this->createMock(Node::class); + $node->method('getMimeType')->willReturn($mimeType); + + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn($shareType); + $share->method('getSharedWith')->willReturn($sharedWith); + $share->method('getNode')->willReturn($node); + $share->method('getId')->willReturn('42'); + return $share; + } + + public function testFixMimeTypeSkippedForAttachmentRoute(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken'); + $room = $this->createMock(Room::class); + $this->manager->method('getRoomByToken')->with('roomtoken')->willReturn($room); + + // ensureOneToOneRoomIsFilled runs for post (attendees must be persisted), + // but not for probe (the actual message post will do it later). + $this->participantService->expects($this->once())->method('ensureOneToOneRoomIsFilled')->with($room); + $this->chatManager->expects($this->never())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.chat.postattachmenttoroom'); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + public function testFixMimeTypeSkippedForProbeRoute(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken'); + + // Probe returns before getRoomByToken — ensureOneToOneRoomIsFilled must NOT run. + $this->manager->expects($this->never())->method('getRoomByToken'); + $this->participantService->expects($this->never())->method('ensureOneToOneRoomIsFilled'); + $this->chatManager->expects($this->never())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.chat.probeattachmentfolder'); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + public function testFixMimeTypeSkippedForShareToChatRoute(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken'); + + // sharetochat returns before getRoomByToken is reached + $this->manager->expects($this->never())->method('getRoomByToken'); + $this->chatManager->expects($this->never())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.recording.sharetochat'); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + public function testFixMimeTypeSkippedForNonRoomShare(): void { + $share = $this->makeShareMock(IShare::TYPE_USER, 'alice'); + + $this->chatManager->expects($this->never())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute(''); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + public function testFixMimeTypeTriggersForRegularFileShare(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken', 'image/png'); + $room = $this->createMock(Room::class); + $this->manager->method('getRoomByToken')->with('roomtoken')->willReturn($room); + $this->mockLoggedInUser('alice'); + + $this->chatManager->expects($this->once())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.filesintegration.getroombyfileid'); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + /** + * Regression: before the fix, folder shares via any route were silenced. + * Now only the attachment-endpoint route is silenced; regular folder shares + * (e.g. a user sharing their Documents folder to a room) must still post a + * file_shared system message. + */ + public function testFixMimeTypeTriggersForRegularFolderShare(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken', 'httpd/unix-directory'); + $room = $this->createMock(Room::class); + $this->manager->method('getRoomByToken')->with('roomtoken')->willReturn($room); + $this->mockLoggedInUser('alice'); + + $this->chatManager->expects($this->once())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.filesintegration.getroombyfileid'); + self::invokePrivate($listener, 'handle', [new ShareCreatedEvent($share)]); + } + + public function testFixMimeTypeSkippedForAttachmentRouteWithDuplicateEvent(): void { + $share = $this->makeShareMock(IShare::TYPE_ROOM, 'roomtoken'); + $room = $this->createMock(Room::class); + $this->manager->method('getRoomByToken')->with('roomtoken')->willReturn($room); + + $this->participantService->expects($this->once())->method('ensureOneToOneRoomIsFilled')->with($room); + $this->chatManager->expects($this->never())->method('addSystemMessage'); + + $listener = $this->makeListenerWithRoute('ocs.spreed.chat.postattachmenttoroom'); + self::invokePrivate($listener, 'handle', [new BeforeDuplicateShareSentEvent($share)]); + } } diff --git a/tests/php/Controller/ChatControllerTest.php b/tests/php/Controller/ChatControllerTest.php index 7f24dcfe03a..a58eebcaca6 100644 --- a/tests/php/Controller/ChatControllerTest.php +++ b/tests/php/Controller/ChatControllerTest.php @@ -13,6 +13,7 @@ use OCA\Talk\Chat\MessageParser; use OCA\Talk\Chat\Notifier; use OCA\Talk\Chat\ReactionManager; +use OCA\Talk\Config; use OCA\Talk\Controller\ChatController; use OCA\Talk\Federation\Authenticator; use OCA\Talk\GuestManager; @@ -25,6 +26,7 @@ use OCA\Talk\Service\AttachmentService; use OCA\Talk\Service\AvatarService; use OCA\Talk\Service\BotService; +use OCA\Talk\Service\ConversationFolderService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\ProxyCacheMessageService; use OCA\Talk\Service\ReminderService; @@ -92,6 +94,8 @@ class ChatControllerTest extends TestCase { private ITaskProcessingManager&MockObject $taskProcessingManager; private IAppConfig&MockObject $appConfig; private LoggerInterface&MockObject $logger; + private ConversationFolderService&MockObject $conversationFolderService; + private Config&MockObject $talkConfig; protected Room&MockObject $room; @@ -138,6 +142,8 @@ public function setUp(): void { $this->appConfig = $this->createMock(IAppConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->scheduledMessageService = $this->createMock(ScheduledMessageService::class); + $this->conversationFolderService = $this->createMock(ConversationFolderService::class); + $this->talkConfig = $this->createMock(Config::class); $this->room = $this->createMock(Room::class); @@ -190,6 +196,8 @@ private function recreateChatController(): void { $this->appConfig, $this->logger, $this->scheduledMessageService, + $this->conversationFolderService, + $this->talkConfig, ); } diff --git a/tests/php/Service/ConversationFolderServiceTest.php b/tests/php/Service/ConversationFolderServiceTest.php new file mode 100644 index 00000000000..cf1aac06648 --- /dev/null +++ b/tests/php/Service/ConversationFolderServiceTest.php @@ -0,0 +1,563 @@ +talkConfig = $this->createMock(TalkConfig::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->shareManager = $this->createMock(IShareManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new ConversationFolderService( + $this->talkConfig, + $this->rootFolder, + $this->shareManager, + $this->logger, + ); + } + + private function makeRoom(string $token = 'abc123'): Room&MockObject { + $room = $this->createMock(Room::class); + $room->method('getToken')->willReturn($token); + return $room; + } + + /** + * Create a user-folder mock with getFreeSpace() pre-configured. + * Defaults to SPACE_UNLIMITED so tests that don't care about quota + * don't trip the quota check. + */ + private function makeUserFolderMock(int|float $freeSpace = FileInfo::SPACE_UNLIMITED): Folder&MockObject { + $userFolder = $this->createMock(Folder::class); + $userFolder->method('getFreeSpace')->willReturn($freeSpace); + return $userFolder; + } + + /** Set up a share mock that accepts all fluent setters and returns itself. */ + private function makeShareMock(): IShare&MockObject { + $share = $this->createMock(IShare::class); + $share->method('setNode')->willReturnSelf(); + $share->method('setShareType')->willReturnSelf(); + $share->method('setSharedBy')->willReturnSelf(); + $share->method('setShareOwner')->willReturnSelf(); + $share->method('setSharedWith')->willReturnSelf(); + $share->method('setPermissions')->willReturnSelf(); + $share->method('setMailSend')->willReturnSelf(); + return $share; + } + + // ------------------------------------------------------------------------- + // getOrCreateSubfolder — quota check + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderThrowsWhenQuotaExhausted(): void { + $room = $this->makeRoom(); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(0); + $this->rootFolder->method('getUserFolder')->with($userId)->willReturn($userFolder); + + $this->expectException(NotEnoughSpaceException::class); + $this->service->getOrCreateSubfolder($userId, $room); + } + + public function testGetOrCreateSubfolderProceedsWithUnlimitedQuota(): void { + $room = $this->makeRoom('tok0a'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(FileInfo::SPACE_UNLIMITED); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok0a'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + // No exception expected + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + public function testGetOrCreateSubfolderProceedsWithNotComputedQuota(): void { + $room = $this->makeRoom('tok0b'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(FileInfo::SPACE_NOT_COMPUTED); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok0b'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + public function testGetOrCreateSubfolderProceedsWhenSpaceAvailable(): void { + $room = $this->makeRoom('tok0c'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(1024 * 1024); // 1 MB free + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok0c'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + // ------------------------------------------------------------------------- + // getOrCreateSubfolder — happy paths + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderAllFoldersExist(): void { + $room = $this->makeRoom('tok1'); + $userId = 'alice'; + + $subfolder = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $attachmentNode = $this->createMock(Folder::class); + $userFolder = $this->makeUserFolderMock(); + + $this->talkConfig->method('getAttachmentFolder')->with($userId)->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('My Room-tok1'); + $this->talkConfig->method('getConversationSubfolderName')->with($userId)->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->with($userId)->willReturn($userFolder); + $userFolder->method('get')->with('Talk')->willReturn($attachmentNode); + $attachmentNode->method('get')->with('My Room-tok1')->willReturn($convFolder); + $convFolder->method('get')->with('Alice-alice')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + $this->shareManager->expects($this->once())->method('createShare'); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + public function testGetOrCreateSubfolderCreatesAttachmentFolder(): void { + $room = $this->makeRoom('tok2'); + $userId = 'bob'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok2'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Bob-bob'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + + // Attachment folder doesn't exist yet + $userFolder->method('get')->with('Talk')->willThrowException(new NotFoundException()); + $userFolder->method('newFolder')->with('Talk')->willReturn($attachmentNode); + + $attachmentNode->method('get')->with('Room-tok2')->willReturn($convFolder); + $convFolder->method('get')->with('Bob-bob')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + public function testGetOrCreateSubfolderCreatesConvFolder(): void { + $room = $this->makeRoom('tok3'); + $userId = 'carol'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok3'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Carol-carol'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->with('Talk')->willReturn($attachmentNode); + + // Conv folder doesn't exist yet + $attachmentNode->method('get')->with('Room-tok3')->willThrowException(new NotFoundException()); + $attachmentNode->method('newFolder')->with('Room-tok3')->willReturn($convFolder); + + $convFolder->method('get')->with('Carol-carol')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + public function testGetOrCreateSubfolderCreatesUserSubfolder(): void { + $room = $this->makeRoom('tok4'); + $userId = 'dave'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok4'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Dave-dave'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->with('Talk')->willReturn($attachmentNode); + $attachmentNode->method('get')->with('Room-tok4')->willReturn($convFolder); + + // User subfolder doesn't exist yet + $convFolder->method('get')->with('Dave-dave')->willThrowException(new NotFoundException()); + $convFolder->method('newFolder')->with('Dave-dave')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + // ------------------------------------------------------------------------- + // getOrCreateSubfolder — error: path component is a file, not a folder + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderThrowsWhenAttachmentFolderIsFile(): void { + $room = $this->makeRoom(); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $fileNode = $this->createMock(Node::class); // not a Folder + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('My Room-abc123'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->with('Talk')->willReturn($fileNode); + + $this->expectException(\RuntimeException::class); + $this->service->getOrCreateSubfolder($userId, $room); + } + + public function testGetOrCreateSubfolderThrowsWhenConvFolderIsFile(): void { + $room = $this->makeRoom(); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $fileNode = $this->createMock(Node::class); // not a Folder + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('My Room-abc123'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($fileNode); + + $this->expectException(\RuntimeException::class); + $this->service->getOrCreateSubfolder($userId, $room); + } + + public function testGetOrCreateSubfolderThrowsWhenUserSubfolderIsFile(): void { + $room = $this->makeRoom(); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $fileNode = $this->createMock(Node::class); // not a Folder + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('My Room-abc123'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($fileNode); + + $this->expectException(\RuntimeException::class); + $this->service->getOrCreateSubfolder($userId, $room); + } + + // ------------------------------------------------------------------------- + // ensureSubfolderShared — duplicate share is silently ignored + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderSilentlyIgnoresDuplicateShare(): void { + $room = $this->makeRoom('tok5'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok5'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + // createShare throws "already shared" — must be swallowed, not propagated + $this->shareManager->method('createShare') + ->willThrowException(new GenericShareException('Already shared', 'Already shared', 403)); + + // No exception expected + $result = $this->service->getOrCreateSubfolder($userId, $room); + $this->assertSame($subfolder, $result); + } + + // ------------------------------------------------------------------------- + // ensureSubfolderShared — non-duplicate exceptions are re-thrown + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderRethrowsUnexpectedShareException(): void { + $room = $this->makeRoom('tok6'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok6'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + $this->shareManager->method('createShare') + ->willThrowException(new \RuntimeException('Unexpected DB error')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unexpected DB error'); + + $this->service->getOrCreateSubfolder($userId, $room); + } + + public function testGetOrCreateSubfolderRethrowsNonDuplicateGenericShareException(): void { + $room = $this->makeRoom('tok6b'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok6b'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + + $this->shareManager->method('newShare')->willReturn($this->makeShareMock()); + // A GenericShareException whose message is NOT 'Already shared' must be rethrown. + $this->shareManager->method('createShare') + ->willThrowException(new GenericShareException('Room not found', 'Conversation not found', 404)); + + $this->expectException(GenericShareException::class); + $this->expectExceptionMessage('Room not found'); + + $this->service->getOrCreateSubfolder($userId, $room); + } + + // ------------------------------------------------------------------------- + // ensureSubfolderShared — share properties + // ------------------------------------------------------------------------- + + public function testGetOrCreateSubfolderSetsCorrectShareProperties(): void { + $room = $this->makeRoom('tok7'); + $userId = 'alice'; + + $userFolder = $this->makeUserFolderMock(); + $attachmentNode = $this->createMock(Folder::class); + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + + $this->talkConfig->method('getAttachmentFolder')->willReturn('/Talk'); + $this->talkConfig->method('getConversationFolderName')->willReturn('Room-tok7'); + $this->talkConfig->method('getConversationSubfolderName')->willReturn('Alice-alice'); + + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + $userFolder->method('get')->willReturn($attachmentNode); + $attachmentNode->method('get')->willReturn($convFolder); + $convFolder->method('get')->willReturn($subfolder); + + $newShare = $this->createMock(IShare::class); + $this->shareManager->method('newShare')->willReturn($newShare); + + $newShare->expects($this->once())->method('setNode')->with($subfolder)->willReturnSelf(); + $newShare->expects($this->once())->method('setShareType')->with(IShare::TYPE_ROOM)->willReturnSelf(); + $newShare->expects($this->once())->method('setSharedBy')->with($userId)->willReturnSelf(); + $newShare->expects($this->once())->method('setShareOwner')->with($userId)->willReturnSelf(); + $newShare->expects($this->once())->method('setSharedWith')->with('tok7')->willReturnSelf(); + $newShare->expects($this->once())->method('setPermissions')->with(Constants::PERMISSION_READ)->willReturnSelf(); + $newShare->expects($this->once())->method('setMailSend')->with(false)->willReturnSelf(); + + $this->service->getOrCreateSubfolder($userId, $room); + } + + // ------------------------------------------------------------------------- + // getFileNode + // ------------------------------------------------------------------------- + + public function testGetFileNodeReturnsNodeFromUserFolder(): void { + $userId = 'alice'; + $filePath = 'Talk/Room-tok/Alice-alice/test.txt'; + + $userFolder = $this->createMock(Folder::class); + $node = $this->createMock(Node::class); + + $this->rootFolder->method('getUserFolder')->with($userId)->willReturn($userFolder); + $userFolder->method('get')->with($filePath)->willReturn($node); + + $result = $this->service->getFileNode($userId, $filePath); + $this->assertSame($node, $result); + } + + public function testGetFileNodeThrowsWhenPathNotFound(): void { + $userId = 'alice'; + $filePath = 'Talk/Room-tok/Alice-alice/missing.txt'; + + $userFolder = $this->createMock(Folder::class); + + $this->rootFolder->method('getUserFolder')->with($userId)->willReturn($userFolder); + $userFolder->method('get')->with($filePath)->willThrowException(new NotFoundException('missing.txt')); + + $this->expectException(NotFoundException::class); + $this->service->getFileNode($userId, $filePath); + } + + // ------------------------------------------------------------------------- + // getRelativePath + // ------------------------------------------------------------------------- + + public function testGetRelativePathStripsLeadingSlash(): void { + $userId = 'alice'; + + $subfolder = $this->createMock(Folder::class); + $subfolder->method('getPath')->willReturn('/alice/files/Talk/Room-tok/Alice-alice'); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('getRelativePath') + ->with('/alice/files/Talk/Room-tok/Alice-alice') + ->willReturn('/Talk/Room-tok/Alice-alice'); + + $this->rootFolder->method('getUserFolder')->with($userId)->willReturn($userFolder); + + $result = $this->service->getRelativePath($userId, $subfolder); + $this->assertSame('Talk/Room-tok/Alice-alice', $result); + } + + // ------------------------------------------------------------------------- + // getOrCreateDraftFolder + // ------------------------------------------------------------------------- + + public function testGetOrCreateDraftFolderReturnsExistingDraft(): void { + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + $draftFolder = $this->createMock(Folder::class); + + $subfolder->method('getParent')->willReturn($convFolder); + $convFolder->method('get')->with('Draft')->willReturn($draftFolder); + + $result = $this->service->getOrCreateDraftFolder($subfolder); + $this->assertSame($draftFolder, $result); + } + + public function testGetOrCreateDraftFolderCreatesWhenMissing(): void { + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + $draftFolder = $this->createMock(Folder::class); + + $subfolder->method('getParent')->willReturn($convFolder); + $convFolder->method('get')->with('Draft')->willThrowException(new NotFoundException('Draft')); + $convFolder->expects($this->once())->method('newFolder')->with('Draft')->willReturn($draftFolder); + + $result = $this->service->getOrCreateDraftFolder($subfolder); + $this->assertSame($draftFolder, $result); + } + + public function testGetOrCreateDraftFolderThrowsWhenDraftIsFile(): void { + $convFolder = $this->createMock(Folder::class); + $subfolder = $this->createMock(Folder::class); + $fileNode = $this->createMock(Node::class); // not a Folder + + $subfolder->method('getParent')->willReturn($convFolder); + $convFolder->method('get')->with('Draft')->willReturn($fileNode); + + $this->expectException(\RuntimeException::class); + $this->service->getOrCreateDraftFolder($subfolder); + } +} diff --git a/tests/php/Share/ListenerTest.php b/tests/php/Share/ListenerTest.php new file mode 100644 index 00000000000..6ccbe0f1d2d --- /dev/null +++ b/tests/php/Share/ListenerTest.php @@ -0,0 +1,311 @@ +config = $this->createMock(Config::class); + $this->manager = $this->createMock(Manager::class); + $this->roomShareProvider = $this->createMock(RoomShareProvider::class); + + $this->listener = new Listener( + $this->config, + $this->manager, + $this->roomShareProvider, + ); + } + + private function makeShare(int $type, string $sharedWith): IShare&MockObject { + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn($type); + $share->method('getSharedWith')->willReturn($sharedWith); + return $share; + } + + private function makeUser(string $uid): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — ignored for non-room share types + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointIgnoresNonRoomShare(): void { + $share = $this->makeShare(IShare::TYPE_USER, 'bob'); + $user = $this->makeUser('bob'); + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->expects($this->never())->method('setParent'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — flat (legacy) case: parent === placeholder + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointFlatCase(): void { + $share = $this->makeShare(IShare::TYPE_ROOM, 'token1'); + $user = $this->makeUser('bob'); + + $this->config->method('getAttachmentFolder')->with('bob')->willReturn('/Talk'); + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn(RoomShareProvider::TALK_FOLDER_PLACEHOLDER); + $event->expects($this->once())->method('setCreateParent')->with(true); + $event->expects($this->once())->method('setParent')->with('/Talk'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — nested case, group room (same name for all users) + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointNestedGroupRoom(): void { + $token = 'grp1'; + $share = $this->makeShare(IShare::TYPE_ROOM, $token); + $user = $this->makeUser('bob'); + + $room = $this->createMock(Room::class); + $this->config->method('isConversationSubfoldersEnabled')->willReturn(true); + $this->manager->method('getRoomByToken')->with($token)->willReturn($room); + $this->config->method('getAttachmentFolder')->with('bob')->willReturn('/Talk'); + $this->config->method('getConversationFolderName')->with($room, 'bob')->willReturn('My Room-grp1'); + + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/My Room-grp1'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn($parent); + $event->expects($this->once())->method('setCreateParent')->with(true); + $event->expects($this->once())->method('setParent')->with('/Talk/My Room-grp1'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — nested case, 1-1 room (display name differs per user) + // ------------------------------------------------------------------------- + + /** + * Alice uploads to a 1-1 room. From Alice's perspective the conversation + * folder is named after Bob ("Bob-TOKEN"). The share target is therefore + * stored as /{TALK_PLACEHOLDER}/Bob-TOKEN/Alice-alice. + * + * When the mount point is resolved for Bob, the conversation folder must be + * named from Bob's perspective ("Alice-TOKEN"), not Alice's ("Bob-TOKEN"). + */ + public function testOverwriteMountPointNestedOneToOneRoom(): void { + $token = 'oneone'; + $share = $this->makeShare(IShare::TYPE_ROOM, $token); + $bob = $this->makeUser('bob'); + + $room = $this->createMock(Room::class); + $this->config->method('isConversationSubfoldersEnabled')->willReturn(true); + $this->manager->method('getRoomByToken')->with($token)->willReturn($room); + $this->config->method('getAttachmentFolder')->with('bob')->willReturn('/Talk'); + // From Bob's perspective the 1-1 room is named after Alice. + $this->config->method('getConversationFolderName')->with($room, 'bob')->willReturn('Alice-oneone'); + + // The stored target uses Alice's view of the room name. + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/Bob-oneone'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($bob); + $event->method('getParent')->willReturn($parent); + $event->expects($this->once())->method('setCreateParent')->with(true); + // Must resolve to Bob's view, not Alice's. + $event->expects($this->once())->method('setParent')->with('/Talk/Alice-oneone'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — nested with user-subfolder segment + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointNestedWithUserSubfolder(): void { + $token = 'tok2'; + $share = $this->makeShare(IShare::TYPE_ROOM, $token); + $user = $this->makeUser('carol'); + + $room = $this->createMock(Room::class); + $this->config->method('isConversationSubfoldersEnabled')->willReturn(true); + $this->manager->method('getRoomByToken')->with($token)->willReturn($room); + $this->config->method('getAttachmentFolder')->with('carol')->willReturn('/Talk'); + $this->config->method('getConversationFolderName')->with($room, 'carol')->willReturn('Room-tok2'); + + // Parent includes user-subfolder segment. + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/Room-tok2/Alice-alice'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn($parent); + $event->expects($this->once())->method('setCreateParent')->with(true); + $event->expects($this->once())->method('setParent')->with('/Talk/Room-tok2/Alice-alice'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — room not found falls back to sharer's folder name + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointFallsBackWhenRoomNotFound(): void { + $token = 'gone'; + $share = $this->makeShare(IShare::TYPE_ROOM, $token); + $user = $this->makeUser('dave'); + + $this->config->method('isConversationSubfoldersEnabled')->willReturn(true); + $this->manager->method('getRoomByToken') + ->with($token) + ->willThrowException(new RoomNotFoundException()); + $this->config->method('getAttachmentFolder')->with('dave')->willReturn('/Talk'); + + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/OldName-gone'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn($parent); + $event->expects($this->once())->method('setCreateParent')->with(true); + // Falls back to the original folder name from the stored path. + $event->expects($this->once())->method('setParent')->with('/Talk/OldName-gone'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — conv folder name has no extractable token (legacy) + // ------------------------------------------------------------------------- + + /** + * If the conv folder name stored in the target does not end with a valid + * token suffix (e.g. a legacy folder named without a token), the listener + * must not call getRoomByToken and must use the stored name verbatim. + */ + public function testOverwriteMountPointUsesStoredNameWhenTokenNotExtractable(): void { + $share = $this->makeShare(IShare::TYPE_ROOM, ''); + $user = $this->makeUser('frank'); + + $this->config->method('isConversationSubfoldersEnabled')->willReturn(true); + $this->manager->expects($this->never())->method('getRoomByToken'); + $this->config->method('getAttachmentFolder')->with('frank')->willReturn('/Talk'); + + // Folder name has no token-like suffix. + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/LegacyFolderName'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn($parent); + $event->expects($this->once())->method('setCreateParent')->with(true); + $event->expects($this->once())->method('setParent')->with('/Talk/LegacyFolderName'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // overwriteMountPoint — unrelated parent (no placeholder) is left alone + // ------------------------------------------------------------------------- + + public function testOverwriteMountPointIgnoresUnrelatedParent(): void { + $share = $this->makeShare(IShare::TYPE_ROOM, 'tok3'); + $user = $this->makeUser('eve'); + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn('/SomeOtherFolder'); + $event->expects($this->never())->method('setParent'); + + $this->listener->handle($event); + } + + // ------------------------------------------------------------------------- + // Feature-flag enforcement + // ------------------------------------------------------------------------- + + /** + * When conversation subfolders are disabled, overwriteShareTarget must fall + * back to the plain node name and must NOT inspect the attachment folder path. + */ + public function testOverwriteShareTargetUsesNodeNameWhenFeatureDisabled(): void { + $node = $this->createMock(Node::class); + $node->method('getName')->willReturn('my-subfolder'); + $node->method('getPath')->willReturn('/alice/files/Talk/Room-tok/my-subfolder'); + + $share = $this->createMock(IShare::class); + $share->method('getShareType')->willReturn(IShare::TYPE_ROOM); + $share->method('getShareOwner')->willReturn('alice'); + $share->method('getNode')->willReturn($node); + + $this->config->method('isConversationSubfoldersEnabled')->willReturn(false); + $this->config->expects($this->never())->method('getAttachmentFolder'); + $share->expects($this->once())->method('setTarget') + ->with(RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/my-subfolder'); + + $event = $this->createMock(BeforeShareCreatedEvent::class); + $event->method('getShare')->willReturn($share); + + $this->listener->handle($event); + } + + /** + * When conversation subfolders are disabled, overwriteMountPoint must not + * resolve nested placeholder paths — setParent must never be called. + */ + public function testOverwriteMountPointSkipsNestedCaseWhenFeatureDisabled(): void { + $share = $this->makeShare(IShare::TYPE_ROOM, 'tok'); + $user = $this->makeUser('bob'); + + $this->config->method('isConversationSubfoldersEnabled')->willReturn(false); + $this->config->method('getAttachmentFolder')->with('bob')->willReturn('/Talk'); + + $parent = RoomShareProvider::TALK_FOLDER_PLACEHOLDER . '/Some Room-tok'; + + $event = $this->createMock(VerifyMountPointEvent::class); + $event->method('getShare')->willReturn($share); + $event->method('getUser')->willReturn($user); + $event->method('getParent')->willReturn($parent); + $event->expects($this->never())->method('setParent'); + + $this->listener->handle($event); + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9d870738ce3..80e2e21481f 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -15,8 +15,11 @@ - + + + +