Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ConversationName>-<token>/<DisplayName>-<uid>/` before calling the attachment endpoint
2 changes: 2 additions & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ class Capabilities implements IPublicCapability {
'attachments' => [
'allowed',
'folder',
'conversation-subfolders',
],
'call' => [
'predefined-backgrounds',
Expand Down Expand Up @@ -259,6 +260,7 @@ public function getCapabilities(): array {
'attachments' => [
'allowed' => $user instanceof IUser && $user->getBackendClassName() !== UserBackend::class,
// 'folder' => string,
'conversation-subfolders' => $this->talkConfig->isConversationSubfoldersEnabled(),
Comment thread
miaulalala marked this conversation as resolved.
],
'call' => [
'enabled' => ((int)$this->serverConfig->getAppValue('spreed', 'start_calls', (string)Room::START_CALL_EVERYONE)) !== Room::START_CALL_NOONE,
Expand Down
98 changes: 97 additions & 1 deletion lib/Chat/Parser/SystemMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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();
Comment thread
miaulalala marked this conversation as resolved.
}

$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
Expand Down Expand Up @@ -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());

Expand Down
51 changes: 39 additions & 12 deletions lib/Chat/SystemMessage/Listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
miaulalala marked this conversation as resolved.

// 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);
}
}

Expand Down Expand Up @@ -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();
Expand All @@ -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 : [];
Expand Down
68 changes: 68 additions & 0 deletions lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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: "<sanitizedDisplayName>-<token>"
* 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 {
Comment thread
miaulalala marked this conversation as resolved.
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: "<displayPrefix>-<uid>" 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[]
*/
Expand Down
Loading
Loading