diff --git a/appinfo/routes.php b/appinfo/routes.php index a9fb817..92fc1f2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -2,6 +2,7 @@ return [ 'resources' => [ 'room' => ['url' => '/rooms'], + 'roomShare' => ['url' => '/roomShares'], 'room_api' => ['url' => '/api/0.1/rooms'], ], 'routes' => [ diff --git a/lib/BigBlueButton/API.php b/lib/BigBlueButton/API.php index 9deac0e..23f1f95 100644 --- a/lib/BigBlueButton/API.php +++ b/lib/BigBlueButton/API.php @@ -10,8 +10,11 @@ use BigBlueButton\Core\Record; use BigBlueButton\Parameters\DeleteRecordingsParameters; use BigBlueButton\Parameters\IsMeetingRunningParameters; use OCA\BigBlueButton\Db\Room; +use OCA\BigBlueButton\Db\RoomShare; +use OCA\BigBlueButton\Service\RoomShareService; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\IGroupManager; class API { @@ -21,15 +24,25 @@ class API /** @var IURLGenerator */ private $urlGenerator; + /** @var IGroupManager */ + private $groupManager; + + /** @var RoomShareService */ + private $roomShareService; + /** @var BigBlueButton */ private $server; public function __construct( IConfig $config, - IURLGenerator $urlGenerator + IURLGenerator $urlGenerator, + IGroupManager $groupManager, + RoomShareService $roomShareService ) { $this->config = $config; $this->urlGenerator = $urlGenerator; + $this->groupManager = $groupManager; + $this->roomShareService = $roomShareService; } private function getServer() @@ -51,7 +64,7 @@ class API */ public function createJoinUrl(Room $room, int $creationTime, string $displayname, string $uid = null) { - $password = $uid === $room->userId ? $room->moderatorPassword : $room->attendeePassword; + $password = $this->isModerator($room, $uid) ? $room->moderatorPassword : $room->attendeePassword; $joinMeetingParams = new JoinMeetingParameters($room->uid, $displayname, $password); @@ -68,6 +81,38 @@ class API return $this->getServer()->getJoinMeetingURL($joinMeetingParams); } + private function isModerator(Room $room, string $uid): bool + { + if ($uid === null) { + return false; + } + + if ($uid === $room->userId) { + return true; + } + + $shares = $this->roomShareService->findAll($room->id); + + /** @var RoomShare $share */ + foreach ($shares as $share) { + if (!$share->hasModeratorPermission()) { + continue; + } + + if ($share->getShareType() === RoomShare::SHARE_TYPE_USER) { + if ($share->getShareWith() === $uid) { + return true; + } + } elseif ($share->getShareType() === RoomShare::SHARE_TYPE_GROUP) { + if ($this->groupManager->isInGroup($uid, $share->getShareWith())) { + return true; + } + } + } + + return false; + } + /** * Create meeting room. * diff --git a/lib/Controller/Errors.php b/lib/Controller/Errors.php index 80a0c40..f49708a 100644 --- a/lib/Controller/Errors.php +++ b/lib/Controller/Errors.php @@ -3,21 +3,28 @@ namespace OCA\BigBlueButton\Controller; use Closure; - +use Exception; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCA\BigBlueButton\Service\RoomNotFound; +use OCA\BigBlueButton\Service\RoomShareNotFound; trait Errors { protected function handleNotFound(Closure $callback): DataResponse { try { - return new DataResponse($callback()); - } catch (RoomNotFound $e) { - $message = ['message' => $e->getMessage()]; - return new DataResponse($message, Http::STATUS_NOT_FOUND); + $return = $callback(); + return ($return instanceof DataResponse) ? $return : new DataResponse($return); + } catch (Exception $e) { + if ($e instanceof RoomNotFound || + $e instanceof RoomShareNotFound) { + $message = ['message' => $e->getMessage()]; + return new DataResponse($message, Http::STATUS_NOT_FOUND); + } + + throw $e; } } } diff --git a/lib/Controller/RoomShareController.php b/lib/Controller/RoomShareController.php new file mode 100644 index 0000000..d545ee2 --- /dev/null +++ b/lib/Controller/RoomShareController.php @@ -0,0 +1,153 @@ +service = $service; + $this->userManager = $userManager; + $this->roomService = $roomService; + $this->userId = $userId; + } + + /** + * @NoAdminRequired + */ + public function index(): DataResponse + { + $roomId = $this->request->getParam('id'); + + if ($roomId === null) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + if (!$this->isUserAllowed($roomId)) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + $roomShares = $this->service->findAll($roomId); + + /** @var RoomShare $roomShare */ + foreach ($roomShares as $roomShare) { + $shareWithUser = $this->userManager->get($roomShare->getShareWith()); + + if ($shareWithUser !== null) { + $roomShare->setShareWithDisplayName($shareWithUser->getDisplayName()); + } + } + + return new DataResponse($roomShares); + } + + /** + * @NoAdminRequired + */ + public function create( + int $roomId, + int $shareType, + string $shareWith, + int $permission + ): DataResponse { + if (!$this->isUserAllowed($roomId)) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + return new DataResponse($this->service->create( + $roomId, + $shareType, + $shareWith, + $permission + )); + } + + /** + * @NoAdminRequired + */ + public function update( + int $id, + int $roomId, + int $shareType, + string $shareWith, + int $permission + ): DataResponse { + if (!$this->isUserAllowed($roomId)) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + return $this->handleNotFound(function () use ( + $id, + $roomId, + $shareType, + $shareWith, + $permission) { + return $this->service->update( + $id, + $roomId, + $shareType, + $shareWith, + $permission + ); + }); + } + + /** + * @NoAdminRequired + */ + public function destroy(int $id): DataResponse + { + return $this->handleNotFound(function () use ($id) { + $roomShare = $this->service->find($id); + + if (!$this->isUserAllowed($roomShare->getRoomId())) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + return $this->service->delete($id); + }); + } + + private function isUserAllowed(int $roomId): bool + { + try { + $room = $this->roomService->find($roomId, $this->userId); + + return $room !== null; + } catch (RoomShareNotFound $e) { + return false; + } + } +} diff --git a/lib/Db/RoomShare.php b/lib/Db/RoomShare.php new file mode 100644 index 0000000..f41696c --- /dev/null +++ b/lib/Db/RoomShare.php @@ -0,0 +1,56 @@ +addType('roomId', 'integer'); + $this->addType('shareType', 'integer'); + $this->addType('permission', 'integer'); + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'roomId' => $this->roomId, + 'shareType' => $this->shareType, + 'shareWith' => $this->shareWith, + 'shareWithDisplayName' => $this->shareWithDisplayName, + 'permission' => $this->permission, + ]; + } + + public function hasUserPermission(): bool + { + return $this->permission === self::PERMISSION_ADMIN || $this->permission === self::PERMISSION_MODERATOR || $this->permission === self::PERMISSION_USER; + } + + public function hasModeratorPermission(): bool + { + return $this->permission === self::PERMISSION_ADMIN || $this->permission === self::PERMISSION_MODERATOR; + } + + public function hasAdminPermission(): bool + { + return $this->permission === self::PERMISSION_ADMIN; + } +} diff --git a/lib/Db/RoomShareMapper.php b/lib/Db/RoomShareMapper.php new file mode 100644 index 0000000..37c32d4 --- /dev/null +++ b/lib/Db/RoomShareMapper.php @@ -0,0 +1,42 @@ +db->getQueryBuilder(); + $qb->select('*') + ->from('bbb_room_shares') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + public function findAll(int $roomId): array + { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('bbb_room_shares') + ->where($qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT))); + return $this->findEntities($qb); + } +} diff --git a/lib/Migration/Version000000Date20200613111242.php b/lib/Migration/Version000000Date20200613111242.php new file mode 100644 index 0000000..58e1afb --- /dev/null +++ b/lib/Migration/Version000000Date20200613111242.php @@ -0,0 +1,51 @@ +hasTable('bbb_room_shares')) { + $table = $schema->createTable('bbb_room_shares'); + $table->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('room_id', 'integer', [ + 'notnull' => true, + ]); + $table->addColumn('share_with', 'string', [ + 'notnull' => true, + 'length' => 200, + ]); + $table->addColumn('share_type', 'integer', [ + 'notnull' => true, + ]); + $table->addColumn('permission', 'integer', [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + } + + return $schema; + } +} diff --git a/lib/Service/RoomShareNotFound.php b/lib/Service/RoomShareNotFound.php new file mode 100644 index 0000000..7ae8fb8 --- /dev/null +++ b/lib/Service/RoomShareNotFound.php @@ -0,0 +1,7 @@ +mapper = $mapper; + } + + public function findAll(int $roomId): array + { + return $this->mapper->findAll($roomId); + } + + private function handleException(Exception $e): void + { + if ($e instanceof DoesNotExistException || + $e instanceof MultipleObjectsReturnedException) { + throw new RoomShareNotFound($e->getMessage()); + } else { + throw $e; + } + } + + public function find($id): RoomShare + { + try { + return $this->mapper->find($id); + } catch (Exception $e) { + $this->handleException($e); + } + } + + public function create(int $roomId, int $shareType, string $shareWith, int $permission): RoomShare + { + $roomShare = new RoomShare(); + + $roomShare->setRoomId($roomId); + $roomShare->setShareType($shareType); + $roomShare->setShareWith($shareWith); + $roomShare->setPermission($permission); + + return $this->mapper->insert($roomShare); + } + + public function update(int $id, int $roomId, int $shareType, string $shareWith, int $permission): RoomShare + { + try { + $roomShare = $this->mapper->find($id); + + $roomShare->setRoomId($roomId); + $roomShare->setShareType($shareType); + $roomShare->setShareWith($shareWith); + $roomShare->setPermission($permission); + + return $this->mapper->update($roomShare); + } catch (Exception $e) { + $this->handleException($e); + } + } + + public function delete(int $id): RoomShare + { + try { + $roomShare = $this->mapper->find($id); + $this->mapper->delete($roomShare); + + return $roomShare; + } catch (Exception $e) { + $this->handleException($e); + } + } +} diff --git a/tests/Unit/Controller/RoomShareControllerTest.php b/tests/Unit/Controller/RoomShareControllerTest.php new file mode 100644 index 0000000..8fedae0 --- /dev/null +++ b/tests/Unit/Controller/RoomShareControllerTest.php @@ -0,0 +1,121 @@ +request = $this->createMock(IRequest::class); + $this->service = $this->createMock(RoomShareService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->roomService = $this->createMock(RoomService::class); + + $this->controller = new RoomShareController( + 'bbb', + $this->request, + $this->service, + $this->userManager, + $this->roomService, + 'user_foo' + ); + } + + public function testIndexWithoutRoomId() + { + $response = $this->controller->index(); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testIndexWithoutPermission() + { + $this->request + ->expects($this->once()) + ->method('getParam') + ->with('id') + ->willReturn(1234); + + $this->roomService + ->expects($this->once()) + ->method('find') + ->will($this->throwException(new RoomShareNotFound)); + + $response = $this->controller->index(); + + $this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testIndexWithoutShares() + { + $roomId = 1234; + $this->request + ->expects($this->once()) + ->method('getParam') + ->with('id') + ->willReturn($roomId); + + $this->roomService + ->expects($this->once()) + ->method('find') + ->willReturn(new Room()); + + $this->service + ->expects($this->once()) + ->method('findAll') + ->with($roomId) + ->willReturn([]); + + $response = $this->controller->index(); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $this->assertEquals([], $response->getData()); + } + + public function testIndexWithShares() + { + $roomId = 1234; + $this->request + ->expects($this->once()) + ->method('getParam') + ->with('id') + ->willReturn($roomId); + + $this->roomService + ->expects($this->once()) + ->method('find') + ->willReturn(new Room()); + + $this->service + ->expects($this->once()) + ->method('findAll') + ->with($roomId) + ->willReturn([ + new RoomShare() + ]); + + $response = $this->controller->index(); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $this->assertCount(1, $response->getData()); + } +} diff --git a/ts/Manager/Api.ts b/ts/Manager/Api.ts index ce2cf34..b662f48 100644 --- a/ts/Manager/Api.ts +++ b/ts/Manager/Api.ts @@ -1,5 +1,9 @@ import axios from '@nextcloud/axios'; +export enum ShareType { User, Group }; + +export enum Permission { Admin, Moderator, User }; + export enum Access { Public = 'public', Password = 'password', @@ -19,6 +23,15 @@ export interface Room { password?: string; } +export interface RoomShare { + id: number; + roomId: number; + shareType: ShareType; + shareWith: string; + shareWithDisplayName?: string; + permission: Permission; +} + export type Recording = { id: string; name: string; @@ -32,6 +45,23 @@ export type Recording = { meta: any; } +export interface ShareWith { + users: { + label: string; + value: { + shareType: ShareType; + shareWith: string; + }; + }[]; + groups: { + label: string; + value: { + shareType: ShareType; + shareWith: string; + }; + }[]; +} + class Api { public getUrl(endpoint: string): string { return OC.generateUrl(`apps/bbb/${endpoint}`); @@ -65,7 +95,7 @@ class Api { } public async deleteRoom(id: number) { - const response = await axios.delete( this.getUrl(`rooms/${id}`)); + const response = await axios.delete(this.getUrl(`rooms/${id}`)); return response.data; } @@ -101,7 +131,7 @@ class Api { return filename; } - public async checkServer(url: string, secret: string): Promise<'success'|'invalid-url'|'invalid:secret'> { + public async checkServer(url: string, secret: string): Promise<'success' | 'invalid-url' | 'invalid:secret'> { const response = await axios.post(this.getUrl('server/check'), { url, secret, @@ -109,6 +139,66 @@ class Api { return response.data; } + + public async getRoomShares(roomId: number): Promise { + const response = await axios.get(this.getUrl('roomShares'), { + params: { + id: roomId, + }, + }); + + return response.data; + } + + public async createRoomShare(roomId: number, shareType: ShareType, shareWith: string, permission: Permission): Promise { + const response = await axios.post(this.getUrl('roomShares'), { + roomId, + shareType, + shareWith, + permission, + }); + + return response.data; + } + + public async deleteRoomShare(id: number) { + const response = await axios.delete(this.getUrl(`roomShares/${id}`)); + + return response.data; + } + + public async getRecommendedShareWith(): Promise { + const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees_recommended'; + const response = await axios.get(url, { + params: { + itemType: 'room', + format: 'json', + }, + }); + + return { + users: response.data.ocs.data.exact.users, + groups: response.data.ocs.data.exact.groups, + }; + } + + public async searchShareWith(search = ''): Promise { + const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees'; + const response = await axios.get(url, { + params: { + search, + shareType: [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP], + itemType: 'room', + format: 'json', + lookup: false, + }, + }); + + return { + users: response.data.ocs.data.users, + groups: response.data.ocs.data.groups, + }; + } } export const api = new Api(); diff --git a/ts/Manager/EditRoomDialog.tsx b/ts/Manager/EditRoomDialog.tsx index 48460dd..d99b006 100644 --- a/ts/Manager/EditRoomDialog.tsx +++ b/ts/Manager/EditRoomDialog.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; +import { Access, Room } from './Api'; import Dialog from './Dialog'; -import { Room, Access } from './Api'; +import ShareWith from './ShareWith'; import { SubmitInput } from './SubmitInput'; const descriptions: { [key: string]: string } = { @@ -74,6 +75,15 @@ const EditRoomDialog: React.FC = ({ room, updateProperty }) => { updateProperty('access', value); })} +
+ + + +
+ +

{t('bbb', 'Miscellaneous')}

void): void; } - function generateUrl(url: string, parameters?: { [key: string]: string }, options?: EscapeOptions) + function generateUrl(url: string, parameters?: { [key: string]: string|number }, options?: EscapeOptions) function linkToOCS(service: string, version: number): string; @@ -71,6 +73,10 @@ declare namespace OC { const currentUser: string; + function getCurrentUser(): {uid: string; displayName: string} + + const requestToken: string; + const config: { blacklist_files_regex: string; enable_avatars: boolean; diff --git a/ts/Manager/ShareWith.scss b/ts/Manager/ShareWith.scss new file mode 100644 index 0000000..13ab919 --- /dev/null +++ b/ts/Manager/ShareWith.scss @@ -0,0 +1,53 @@ +.bbb-shareWith { + margin: 1em 0; + + &__item { + height: 44px; + display: flex; + align-items: center; + + &:hover { + background-color: var(--color-background-dark); + } + + .avatardiv { + height: 32px; + width: 32px; + overflow: hidden; + } + + .icon { + height: 44px; + width: 44px; + } + + &__label { + padding: 0 1em; + flex-grow: 1; + } + } +} + +.bbb-selection-container { + position: relative; +} + +.bbb-selection { + position: absolute; + width: 100%; + background-color: #ffffff; + border: 1px solid var(--color-border-dark); + max-height: 88px; + overflow: auto; + box-shadow: 0 5px 10px -5px var(--color-box-shadow); + + li { + padding: 0 1em; + cursor: pointer; + line-height: 44px; + + &:hover { + background-color: var(--color-background-hover); + } + } +} \ No newline at end of file diff --git a/ts/Manager/ShareWith.tsx b/ts/Manager/ShareWith.tsx new file mode 100644 index 0000000..ea6397a --- /dev/null +++ b/ts/Manager/ShareWith.tsx @@ -0,0 +1,129 @@ +import React, { useState, useEffect } from 'react'; +import { api, ShareWith, ShareType, RoomShare, Room, Permission } from './Api'; +import './ShareWith.scss'; + +type Props = { + room: Room; +} + +const SearchInput: React.FC = ({ room }) => { + const [search, setSearch] = useState(''); + const [hasFocus, setFocus] = useState(false); + const [recommendations, setRecommendations] = useState(); + const [searchResults, setSearchResults] = useState(); + const [shares, setShares] = useState(); + + const userShares = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : []; + const groupShares = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : []; + + useEffect(() => { + api.getRoomShares(room.id).then(roomShares => { + setShares(roomShares); + }).catch(err => { + console.warn('Could not load room shares.', err); + + setShares([]); + }); + }, [room.id]); + + useEffect(() => { + api.searchShareWith(search).then(result => { + setSearchResults(result); + }); + }, [search]); + + useEffect(() => { + api.getRecommendedShareWith().then(result => setRecommendations(result)); + }, []); + + async function addRoomShare(shareWith: string, shareType: number, displayName: string) { + const roomShare = await api.createRoomShare(room.id, shareType, shareWith, Permission.Moderator); + + roomShare.shareWithDisplayName = displayName; + + setShares([...(shares || []), roomShare]); + } + + async function deleteRoomShare(id: number) { + await api.deleteRoomShare(id); + + setShares(shares?.filter(share => share.id !== id)); + } + + function renderSearchResults(options: ShareWith) { + return ( +
    + {[ + ...options.users.filter(user => !userShares.includes(user.value.shareWith)), + ...options.groups.filter(group => !groupShares.includes(group.value.shareWith)), + ].map(option => { + return (
  • addRoomShare(option.value.shareWith, option.value.shareType, option.label)}> + {option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''} +
  • ); + })} +
+ ); + } + + function renderShares(shares: RoomShare[]) { + const currentUser = OC.getCurrentUser(); + const ownShare = { + id: -1, + roomId: room.id, + shareType: ShareType.User, + shareWith: currentUser.uid, + shareWithDisplayName: currentUser.displayName, + permission: Permission.Admin, + }; + + return ( +
    + {[ownShare, ...shares].map(share => { + const avatarUrl = share.shareType === ShareType.User ? OC.generateUrl('/avatar/' + encodeURIComponent(share.shareWith) + '/' + 32, { + user: share.shareWith, + size: 32, + requesttoken: OC.requestToken, + }) : undefined; + const displayName = share.shareWithDisplayName || share.shareWith; + + return ( +
  • +
    + {avatarUrl && {`Avatar} +
    +
    +
    {displayName}{share.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}
    +
    + {share.id > -1 && } +
  • + ); + })} +
+ ); + } + + const loading = <> {t('bbb', 'Loading')}; + + return ( + <> + {shares ? renderShares(shares) : loading} + +
+ setSearch(ev.currentTarget.value)} + onFocus={() => setFocus(true)} + onBlur={() => setTimeout(() => setFocus(false), 100)} + placeholder={t('bbb', 'Name, group, ...')} /> + {hasFocus && (searchResults ? renderSearchResults(searchResults) : (recommendations ? renderSearchResults(recommendations) : loading))} +
+ + ); +}; + +export default SearchInput; \ No newline at end of file