diff --git a/src/app/Console/Commands/Meet/RoomCreate.php b/src/app/Console/Commands/Meet/RoomCreate.php --- a/src/app/Console/Commands/Meet/RoomCreate.php +++ b/src/app/Console/Commands/Meet/RoomCreate.php @@ -47,9 +47,10 @@ return 1; } - \App\Meet\Room::create([ + $room = \App\Meet\Room::create([ 'name' => $roomName, - 'user_id' => $user->id ]); + + $room->assignToWallet($user->wallets()->first()); } } diff --git a/src/app/Console/Commands/Meet/Sessions.php b/src/app/Console/Commands/Meet/Sessions.php --- a/src/app/Console/Commands/Meet/Sessions.php +++ b/src/app/Console/Commands/Meet/Sessions.php @@ -49,7 +49,7 @@ foreach ($sessions as $session) { $room = \App\Meet\Room::where('session_id', $session['roomId'])->first(); if ($room) { - $owner = $room->owner->email; + $owner = $room->wallet()->owner->email; $roomName = $room->name; } else { $owner = '(none)'; diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -327,6 +327,16 @@ } /** + * Returns domain's namespace (required by the EntitleableTrait). + * + * @return string|null Domain namespace + */ + public function toString(): ?string + { + return $this->namespace; + } + + /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -101,20 +101,6 @@ } /** - * Returns entitleable object title (e.g. email or domain name). - * - * @return string|null An object title/name - */ - public function entitleableTitle(): ?string - { - if ($this->entitleable instanceof Domain) { - return $this->entitleable->namespace; - } - - return $this->entitleable->email; - } - - /** * Simplified Entitlement/SKU information for a specified entitleable object * * @param object $object Entitleable object diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -19,8 +19,8 @@ * to the user/domain when either it is active or there's already an * active entitlement. * - * @param \App\Sku $sku The SKU object - * @param \App\User|\App\Domain $object The user or domain object + * @param \App\Sku $sku The SKU + * @param object $object The entitleable object * * @return bool */ diff --git a/src/app/Handlers/Meet.php b/src/app/Handlers/GroupRoom.php rename from src/app/Handlers/Meet.php rename to src/app/Handlers/GroupRoom.php --- a/src/app/Handlers/Meet.php +++ b/src/app/Handlers/GroupRoom.php @@ -2,7 +2,7 @@ namespace App\Handlers; -class Meet extends Base +class GroupRoom extends Base { /** * The entitleable class for this handler. @@ -11,7 +11,7 @@ */ public static function entitleableClass(): string { - return \App\User::class; + return \App\Meet\Room::class; } /** @@ -25,19 +25,9 @@ { $data = parent::metadata($sku); - $data['required'] = ['Groupware']; + $data['exclusive'] = ['Room']; + $data['controllerOnly'] = true; return $data; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 50; - } } diff --git a/src/app/Handlers/Meet.php b/src/app/Handlers/Room.php rename from src/app/Handlers/Meet.php rename to src/app/Handlers/Room.php --- a/src/app/Handlers/Meet.php +++ b/src/app/Handlers/Room.php @@ -2,7 +2,7 @@ namespace App\Handlers; -class Meet extends Base +class Room extends Base { /** * The entitleable class for this handler. @@ -11,7 +11,7 @@ */ public static function entitleableClass(): string { - return \App\User::class; + return \App\Meet\Room::class; } /** @@ -25,7 +25,8 @@ { $data = parent::metadata($sku); - $data['required'] = ['Groupware']; + $data['enabled'] = true; + $data['exclusive'] = ['GroupRoom']; return $data; } @@ -38,6 +39,6 @@ */ public static function priority(): int { - return 50; + return 10; } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -165,7 +165,7 @@ return response()->json([ 'status' => 'success', - 'message' => __('app.domain-create-success'), + 'message' => \trans('app.domain-create-success'), ]); } diff --git a/src/app/Http/Controllers/API/V4/MeetController.php b/src/app/Http/Controllers/API/V4/MeetController.php --- a/src/app/Http/Controllers/API/V4/MeetController.php +++ b/src/app/Http/Controllers/API/V4/MeetController.php @@ -6,47 +6,10 @@ use App\Meet\Room; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Validator; class MeetController extends Controller { /** - * Listing of rooms that belong to the authenticated user. - * - * @return \Illuminate\Http\JsonResponse - */ - public function index() - { - $user = Auth::guard()->user(); - - $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); - - if (count($rooms) == 0) { - // Create a room for the user (with a random and unique name) - while (true) { - $name = strtolower(\App\Utils::randStr(3, 3, '-')); - if (!Room::where('name', $name)->count()) { - break; - } - } - - $room = Room::create([ - 'name' => $name, - 'user_id' => $user->id - ]); - - $rooms = collect([$room]); - } - - $result = [ - 'list' => $rooms, - 'count' => count($rooms), - ]; - - return response()->json($result); - } - - /** * Join the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). * @@ -59,17 +22,14 @@ $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner || $room->owner->isDegraded(true)) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - // Check if there's still a valid meet entitlement for the room owner - if (!$room->owner->hasSku('meet')) { + if (!$room || !($wallet = $room->wallet()) || !$wallet->owner || $wallet->owner->isDegraded(true)) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); - $isOwner = $user && $user->id == $room->user_id; + $isOwner = $user && ( + $user->id == $wallet->owner->id || $room->permissions()->where('user', $user->email)->exists() + ); $init = !empty(request()->input('init')); // There's no existing session @@ -181,69 +141,6 @@ } /** - * Set the domain configuration. - * - * @param string $id Room identifier (name) - * - * @return \Illuminate\Http\JsonResponse|void - */ - public function setRoomConfig($id) - { - $room = Room::where('name', $id)->first(); - - // Room does not exist, or the owner is deleted - if (!$room || !$room->owner || $room->owner->isDegraded(true)) { - return $this->errorResponse(404); - } - - $user = Auth::guard()->user(); - - // Only room owner can configure the room - if ($user->id != $room->user_id) { - return $this->errorResponse(403); - } - - $input = request()->input(); - $errors = []; - - foreach ($input as $key => $value) { - switch ($key) { - case 'password': - if ($value === null || $value === '') { - $input[$key] = null; - } else { - // TODO: Do we have to validate the password in any way? - } - break; - - case 'locked': - $input[$key] = $value ? 'true' : null; - break; - - case 'nomedia': - $input[$key] = $value ? 'true' : null; - break; - - default: - $errors[$key] = \trans('meet.room-unsupported-option-error'); - } - } - - if (!empty($errors)) { - return response()->json(['status' => 'error', 'errors' => $errors], 422); - } - - if (!empty($input)) { - $room->setSettings($input); - } - - return response()->json([ - 'status' => 'success', - 'message' => \trans('meet.room-setconfig-success'), - ]); - } - - /** * Webhook as triggered from the Meet server * * @param \Illuminate\Http\Request $request The API request. diff --git a/src/app/Http/Controllers/API/V4/RoomsController.php b/src/app/Http/Controllers/API/V4/RoomsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/RoomsController.php @@ -0,0 +1,314 @@ +inputRoom($id); + if (is_int($room)) { + return $this->errorResponse($room); + } + + $room->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.room-delete-success"), + ]); + } + + /** + * Listing of rooms that belong to the authenticated user. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $shared = Room::whereIn('id', function ($query) use ($user) { + $query->select('permissible_id') + ->from('permissions') + ->where('permissible_type', Room::class) + ->where('user', $user->email); + }); + + // Create a "private" room for the user + if (!$user->rooms()->count()) { + $room = Room::create(); + $room->assignToWallet($user->wallets()->first()); + } + + $rooms = $user->rooms(true)->union($shared)->orderBy('name')->get() + ->map(function ($room) { + return $this->objectToClient($room); + }); + + $result = [ + 'list' => $rooms, + 'count' => count($rooms), + ]; + + return response()->json($result); + } + + /** + * Set the room configuration. + * + * @param int|string $id Room identifier (or name) + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $room = $this->inputRoom($id, Permission::ADMIN, $permission); + if (is_int($room)) { + return $this->errorResponse($room); + } + + $request = request()->input(); + + // Room sharees can't manage room ACL + if ($permission) { + unset($request['acl']); + } + + $errors = $room->setConfig($request); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.room-setconfig-success"), + ]); + } + + /** + * Display information of a room specified by $id. + * + * @param string $id The room to show information for. + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $room = $this->inputRoom($id, Permission::READ, $permission); + if (is_int($room)) { + return $this->errorResponse($room); + } + + $wallet = $room->wallet(); + $user = $this->guard()->user(); + + $response = $this->objectToClient($room, true); + + unset($response['session_id']); + + $response['config'] = $room->getConfig(); + + // Room sharees can't manage/see room ACL + if ($permission) { + unset($response['config']['acl']); + } + + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($room); + $response['wallet'] = $wallet->toArray(); + + if ($wallet->discount) { + $response['wallet']['discount'] = $wallet->discount->discount; + $response['wallet']['discount_description'] = $wallet->discount->description; + } + + $isOwner = $user->canDelete($room); + $response['canUpdate'] = $isOwner || $room->permissions()->where('user', $user->email)->exists(); + $response['canDelete'] = $isOwner && $user->wallet()->isController($user); + $response['canShare'] = $isOwner && $room->hasSKU('group-room'); + $response['isOwner'] = $isOwner; + + return response()->json($response); + } + + /** + * Get a list of SKUs available to the room. + * + * @param int $id Room identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function skus($id) + { + $room = $this->inputRoom($id); + if (is_int($room)) { + return $this->errorResponse($room); + } + + return SkusController::objectSkus($room); + } + + /** + * Create a new room. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + $user = $this->guard()->user(); + $wallet = $user->wallet(); + + if (!$wallet->isController($user)) { + return $this->errorResponse(403); + } + + // Validate the input + $v = Validator::make( + $request->all(), + [ + 'description' => 'nullable|string|max:191' + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + $room = Room::create([ + 'description' => $request->input('description'), + ]); + + if (!empty($request->skus)) { + SkusController::updateEntitlements($room, $request->skus, $wallet); + } else { + $room->assignToWallet($wallet); + } + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.room-create-success"), + ]); + } + + /** + * Update a room. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id Room identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function update(Request $request, $id) + { + $room = $this->inputRoom($id, Permission::ADMIN); + if (is_int($room)) { + return $this->errorResponse($room); + } + + // Validate the input + $v = Validator::make( + request()->all(), + [ + 'description' => 'nullable|string|max:191' + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + $room->description = request()->input('description'); + $room->save(); + + if (!empty($request->skus)) { + SkusController::updateEntitlements($room, $request->skus); + } + + if (!$room->hasSKU('group-room')) { + $room->setSetting('acl', null); + } + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.room-update-success"), + ]); + } + + /** + * Get the input room object, check permissions. + * + * @param int|string $id Room identifier (or name) + * @param ?int $rights Required access rights + * @param ?\App\Permission $permission Room permission reference if the user has permissions + * to the room and is not the owner + * + * @return \App\Meet\Room|int File object or error code + */ + protected function inputRoom($id, $rights = 0, &$permission = null): int|Room + { + if (!is_numeric($id)) { + $room = Room::where('name', $id)->first(); + } else { + $room = Room::find($id); + } + + if (!$room) { + return 404; + } + + $user = $this->guard()->user(); + + // Room owner (or another wallet controller)? + if ($room->wallet()->isController($user)) { + return $room; + } + + if ($rights) { + $permission = $room->permissions()->where('user', $user->email)->first(); + + if ($permission && $permission->rights & $rights) { + return $room; + } + } + + return 403; + } +} diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -5,6 +5,8 @@ use App\Http\Controllers\ResourceController; use App\Sku; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Str; class SkusController extends ResourceController { @@ -51,15 +53,14 @@ } /** - * Return SKUs available to the specified user/domain. + * Return SKUs available to the specified entitleable object. * - * @param object $object User or Domain object + * @param object $object Entitleable object * * @return \Illuminate\Http\JsonResponse */ public static function objectSkus($object) { - $type = \lcfirst(\class_basename($object::class)); $response = []; // Note: Order by title for consistent ordering in tests @@ -70,13 +71,20 @@ continue; } + if ($object::class != $sku->handler_class::entitleableClass()) { + continue; + } + if (!$sku->handler_class::isAvailable($sku, $object)) { continue; } if ($data = self::skuElement($sku)) { - if ($type != $data['type']) { - continue; + if (!empty($data['controllerOnly'])) { + $user = Auth::guard()->user(); + if (!$user->wallet()->isController($user)) { + continue; + } } $response[] = $data; @@ -113,10 +121,11 @@ /** * Update object entitlements. * - * @param object $object The object for update - * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] + * @param object $object The object for update + * @param array $rSkus List of SKU IDs requested for the object in the form [id=>qty] + * @param ?\App\Wallet $wallet The target wallet */ - public static function updateEntitlements($object, $rSkus): void + public static function updateEntitlements($object, $rSkus, $wallet = null): void { if (!is_array($rSkus)) { return; @@ -143,6 +152,10 @@ $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; + if (!is_a($object, $sku->handler_class::entitleableClass())) { + continue; + } + if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); @@ -154,7 +167,7 @@ $object->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled - $object->assignSku($sku, ($r - $e)); + $object->assignSku($sku, ($r - $e), $wallet); } } } diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php --- a/src/app/Http/Controllers/RelationController.php +++ b/src/app/Http/Controllers/RelationController.php @@ -125,6 +125,8 @@ { if ($full) { $result = $object->toArray(); + + unset($result['tenant_id']); } else { $result = ['id' => $object->id]; diff --git a/src/app/Meet/Room.php b/src/app/Meet/Room.php --- a/src/app/Meet/Room.php +++ b/src/app/Meet/Room.php @@ -2,21 +2,34 @@ namespace App\Meet; +use App\Traits\BelongsToTenantTrait; +use App\Traits\EntitleableTrait; +use App\Traits\Meet\RoomConfigTrait; +use App\Traits\PermissibleTrait; use App\Traits\SettingsTrait; +use Dyrynda\Database\Support\NullableFields; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; /** * The eloquent definition of a Room. * - * @property int $id Room identifier - * @property string $name Room name - * @property int $user_id Room owner - * @property ?string $session_id Meet session identifier + * @property int $id Room identifier + * @property ?string $description Description + * @property string $name Room name + * @property int $tenant_id Tenant identifier + * @property ?string $session_id Meet session identifier */ class Room extends Model { + use BelongsToTenantTrait; + use EntitleableTrait; + use RoomConfigTrait; + use NullableFields; use SettingsTrait; + use PermissibleTrait; + use SoftDeletes; public const ROLE_SUBSCRIBER = 1 << 0; public const ROLE_PUBLISHER = 1 << 1; @@ -27,8 +40,18 @@ public const REQUEST_ACCEPTED = 'accepted'; public const REQUEST_DENIED = 'denied'; + /** @var array The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', + ]; + /** @var array The attributes that are mass assignable */ - protected $fillable = ['user_id', 'name']; + protected $fillable = ['name', 'description']; + + /** @var array The attributes that can be not set */ + protected $nullable = ['description']; /** @var string Database table name */ protected $table = 'openvidu_rooms'; @@ -181,16 +204,6 @@ } /** - * The room owner. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function owner() - { - return $this->belongsTo('\App\User', 'user_id', 'id'); - } - - /** * Accept the join request. * * @param string $id Request identifier @@ -260,16 +273,6 @@ } /** - * Any (additional) properties of this room. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function settings() - { - return $this->hasMany('App\Meet\RoomSetting', 'room_id'); - } - - /** * Send a signal to the Meet session participants (peers) * * @param string $name Signal name (type) @@ -300,6 +303,28 @@ } /** + * Returns a map of supported ACL labels. + * + * @return array Map of supported permission rights/ACL labels + */ + protected function supportedACL(): array + { + return [ + 'full' => \App\Permission::READ | \App\Permission::WRITE | \App\Permission::ADMIN, + ]; + } + + /** + * Returns room name (required by the EntitleableTrait). + * + * @return string|null Room name + */ + public function toString(): ?string + { + return $this->name; + } + + /** * Log an error for a failed request to the meet server * * @param string $str The error string diff --git a/src/app/Meet/RoomSetting.php b/src/app/Meet/RoomSetting.php --- a/src/app/Meet/RoomSetting.php +++ b/src/app/Meet/RoomSetting.php @@ -14,10 +14,10 @@ */ class RoomSetting extends Model { - protected $fillable = [ - 'room_id', 'key', 'value' - ]; + /** @var array The attributes that are mass assignable */ + protected $fillable = ['room_id', 'key', 'value']; + /** @var string Database table name */ protected $table = 'openvidu_room_settings'; /** @@ -27,6 +27,6 @@ */ public function room() { - return $this->belongsTo('\App\Meet\Room', 'room_id', 'id'); + return $this->belongsTo(Room::class); } } diff --git a/src/app/Observers/Meet/RoomObserver.php b/src/app/Observers/Meet/RoomObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/Meet/RoomObserver.php @@ -0,0 +1,28 @@ +name)) { + // Generate a random and unique room name + while (true) { + $room->name = strtolower(\App\Utils::randStr(3, 3, '-')); + if (!Room::where('name', $room->name)->exists()) { + break; + } + } + } + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -95,6 +95,9 @@ } }); } + + // TODO: Remove Permission records for the user + // TODO: Remove file permissions for the user } /** @@ -263,6 +266,10 @@ return; } + if (!$entitlement->entitleable) { + return; + } + // Objects need to be deleted one by one to make sure observers can do the proper cleanup if ($force) { $entitlement->entitleable->forceDelete(); diff --git a/src/app/Permission.php b/src/app/Permission.php new file mode 100644 --- /dev/null +++ b/src/app/Permission.php @@ -0,0 +1,57 @@ + The attributes that are mass assignable */ + protected $fillable = [ + 'permissible_id', + 'permissible_type', + 'rights', + 'user', + ]; + + /** @var array The attributes that should be cast */ + protected $casts = [ + 'rights' => 'integer', + ]; + + /** + * Principally permissible object such as Room. + * Note that it may be trashed (soft-deleted). + * + * @return mixed + */ + public function permissible() + { + return $this->morphTo()->withTrashed(); // @phpstan-ignore-line + } + + /** + * Rights mutator. Make sure rights is integer. + */ + public function setRightsAttribute($rights): void + { + $this->attributes['rights'] = (int) $rights; + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -48,6 +48,7 @@ \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); + \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php --- a/src/app/Traits/EntitleableTrait.php +++ b/src/app/Traits/EntitleableTrait.php @@ -39,21 +39,26 @@ } /** - * Assign a Sku to an entitleable object. + * Assign a SKU to an entitleable object. * - * @param \App\Sku $sku The sku to assign. - * @param int $count Count of entitlements to add + * @param \App\Sku $sku The sku to assign. + * @param int $count Count of entitlements to add + * @param ?\App\Wallet $wallet The wallet to use when objects's wallet is unknown * * @return $this * @throws \Exception */ - public function assignSku(Sku $sku, int $count = 1) + public function assignSku(Sku $sku, int $count = 1, $wallet = null) { - // TODO: I guess wallet could be parametrized in future - $wallet = $this->wallet(); - $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); + if (!$wallet) { + $wallet = $this->wallet(); + } + + if (!$wallet) { + throw new \Exception("No wallet specified for the new entitlement"); + } - // TODO: Make sure the SKU can be assigned to the object + $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { Entitlement::create([ @@ -76,11 +81,12 @@ * Assign the object to a wallet. * * @param \App\Wallet $wallet The wallet + * @param ?string $title Optional SKU title * * @return $this * @throws \Exception */ - public function assignToWallet(Wallet $wallet) + public function assignToWallet(Wallet $wallet, $title = null) { if (empty($this->id)) { throw new \Exception("Object not yet exists"); @@ -92,9 +98,11 @@ // Find the SKU title, e.g. \App\SharedFolder -> shared-folder // Note: it does not work with User/Domain model (yet) - $title = Str::kebab(\class_basename(self::class)); + if (!$title) { + $title = Str::kebab(\class_basename(self::class)); + } - $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); + $sku = $this->skuByTitle($title); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); Entitlement::create([ @@ -168,7 +176,7 @@ */ public function hasSku(string $title): bool { - $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); + $sku = $this->skuByTitle($title); if (!$sku) { return false; @@ -235,6 +243,30 @@ } /** + * Find the SKU object by title. Use current object's tenant context. + * + * @param string $title SKU title. + * + * @return ?\App\Sku A SKU object + */ + protected function skuByTitle(string $title): ?Sku + { + return Sku::withObjectTenantContext($this)->where('title', $title)->first(); + } + + /** + * Returns entitleable object title (e.g. email or domain name). + * + * @return string|null An object title/name + */ + public function toString(): ?string + { + // This method should be overloaded by the model class + // if the object has not email attribute + return $this->email; + } + + /** * Returns the wallet by which the object is controlled * * @return ?\App\Wallet A wallet object diff --git a/src/app/Traits/Meet/RoomConfigTrait.php b/src/app/Traits/Meet/RoomConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/Meet/RoomConfigTrait.php @@ -0,0 +1,65 @@ +getSettings(['password', 'locked', 'nomedia']); + + $config = [ + 'acl' => $this->getACL(), + 'locked' => $settings['locked'] === 'true', + 'nomedia' => $settings['nomedia'] === 'true', + 'password' => $settings['password'], + ]; + + return $config; + } + + /** + * A helper to update room configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation error messages + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + if ($key == 'password') { + if ($value === null || $value === '') { + $value = null; + } else { + // TODO: Do we have to validate the password in any way? + } + $this->setSetting($key, $value); + } elseif ($key == 'locked' || $key == 'nomedia') { + $this->setSetting($key, $value ? 'true' : null); + } elseif ($key == 'acl') { + if (!empty($value) && !$this->hasSKU('group-room')) { + $errors[$key] = \trans('validation.invalid-config-parameter'); + continue; + } + + $acl_errors = $this->validateACL($value); + + if (empty($acl_errors)) { + $this->setACL($value); + } else { + $errors[$key] = $acl_errors; + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } +} diff --git a/src/app/Traits/PermissibleTrait.php b/src/app/Traits/PermissibleTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/PermissibleTrait.php @@ -0,0 +1,163 @@ +permissions()->delete(); + }); + } + + /** + * Permissions for this object. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function permissions() + { + return $this->hasMany(Permission::class, 'permissible_id', 'id') + ->where('permissible_type', self::class); + } + + /** + * Validate ACL input + * + * @param mixed $input Common ACL input + * + * @return array List of validation errors + */ + protected function validateACL(&$input): array + { + if (!is_array($input)) { + $input = (array) $input; + } + + $users = []; + $errors = []; + $supported = $this->supportedACL(); + + foreach ($input as $i => $v) { + if (!is_string($v) || empty($v) || !substr_count($v, ',')) { + $errors[$i] = \trans('validation.acl-entry-invalid'); + } else { + list($user, $acl) = explode(',', $v, 2); + $user = trim($user); + $acl = trim($acl); + $error = null; + + if (!isset($supported[$acl])) { + $errors[$i] = \trans('validation.acl-permission-invalid'); + } elseif (in_array($user, $users) || ($error = $this->validateACLIdentifier($user))) { + $errors[$i] = $error ?: \trans('validation.acl-entry-invalid'); + } + + $input[$i] = "$user, $acl"; + $users[] = $user; + } + } + + return $errors; + } + + /** + * Validate an ACL identifier. + * + * @param string $identifier Email address + * + * @return ?string Error message on validation error + */ + protected function validateACLIdentifier(string $identifier): ?string + { + $v = Validator::make(['email' => $identifier], ['email' => 'required|email']); + + if ($v->fails()) { + return \trans('validation.emailinvalid'); + } + + $user = \App\User::where('email', \strtolower($identifier))->first(); + + if ($user) { + return null; + } + + return \trans('validation.notalocaluser'); + } + + /** + * Build an ACL list from the object's permissions + * + * @return array ACL list in a "common" format + */ + protected function getACL(): array + { + $supported = $this->supportedACL(); + + return $this->permissions()->get() + ->map(function ($permission) use ($supported) { + $acl = array_search($permission->rights, $supported) ?: 'none'; + return "{$permission->user}, {$acl}"; + }) + ->all(); + } + + /** + * Update the permissions based on the ACL input. + * + * @param array $acl ACL list in a "common" format + */ + protected function setACL(array $acl): void + { + $users = []; + $supported = $this->supportedACL(); + + foreach ($acl as $item) { + list($user, $right) = explode(',', $item, 2); + $users[\strtolower($user)] = $supported[trim($right)] ?? 0; + } + + // Compare the input with existing shares + $this->permissions()->get()->each(function ($permission) use (&$users) { + if (isset($users[$permission->user])) { + if ($permission->rights != $users[$permission->user]) { + $permission->rights = $users[$permission->user]; + $permission->save(); + } + unset($users[$permission->user]); + } else { + $permission->delete(); + } + }); + + foreach ($users as $user => $rights) { + $this->permissions()->create([ + 'user' => $user, + 'rights' => $rights, + 'permissible_type' => self::class, + ]); + } + } + + /** + * Returns a map of supported ACL labels. + * + * @return array Map of supported share rights/ACL labels + */ + protected function supportedACL(): array + { + return [ + 'read-only' => Permission::RIGHT_READ, + 'read-write' => Permission::RIGHT_READ | Permission::RIGHT_WRITE, + 'full' => Permission::RIGHT_ADMIN, + ]; + } +} diff --git a/src/app/Transaction.php b/src/app/Transaction.php --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -158,7 +158,7 @@ $discount = $entitlement->wallet->getDiscountRate(); $result['entitlement_cost'] = $cost * $discount; - $result['object'] = $entitlement->entitleableTitle(); + $result['object'] = $entitlement->entitleable->toString(); $result['sku_title'] = $entitlement->sku->title; } else { $wallet = $this->wallet(); diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -436,6 +436,19 @@ } /** + * Return rooms controlled by the current user. + * + * @param bool $with_accounts Include rooms assigned to wallets + * the current user controls but not owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function rooms($with_accounts = true) + { + return $this->entitleables(Meet\Room::class, $with_accounts); + } + + /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -355,6 +355,18 @@ } /** + * Check if the specified user is a controller to this wallet. + * + * @param \App\User $user The user object. + * + * @return bool True if the user is one of the wallet controllers (including user), False otherwise + */ + public function isController(User $user): bool + { + return $user->id == $this->user_id || $this->controllers->contains($user); + } + + /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * diff --git a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php --- a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php +++ b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php @@ -32,7 +32,7 @@ Schema::table( 'openvidu_rooms', function (Blueprint $table) { - $table->string('session_id', 16)->change(); + // $table->string('session_id', 16)->change(); } ); } diff --git a/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php b/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php @@ -0,0 +1,150 @@ +string('id', 36)->primary(); + $table->bigInteger('permissible_id'); + $table->string('permissible_type'); + $table->integer('rights')->default(0); + $table->string('user'); + $table->timestamps(); + + $table->index('user'); + $table->index(['permissible_id', 'permissible_type']); + } + ); + + Schema::table( + 'openvidu_rooms', + function (Blueprint $table) { + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->string('description')->nullable(); + $table->softDeletes(); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + // Create the new SKUs + if (!\App\Sku::where('title', 'room')->first()) { + $sku = \App\Sku::create([ + 'title' => 'group-room', + 'name' => 'Group conference room', + 'description' => 'Shareable audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\GroupRoom', + 'active' => true, + ]); + + $sku = \App\Sku::create([ + 'title' => 'room', + 'name' => 'Standard conference room', + 'description' => 'Audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ]); + + // Create the entitlement for every existing room + foreach (\App\Meet\Room::get() as $room) { + $user = \App\User::find($room->user_id); // @phpstan-ignore-line + if (!$user) { + $room->forceDelete(); + continue; + } + + // Set tenant_id + if ($user->tenant_id) { + $room->tenant_id = $user->tenant_id; + $room->save(); + } + + $wallet = $user->wallets()->first(); + + \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => 0, + 'fee' => 0, + 'entitleable_id' => $room->id, + 'entitleable_type' => \App\Meet\Room::class + ]); + } + } + + // Remove 'meet' SKU/entitlements + \App\Sku::where('title', 'meet')->delete(); + + Schema::table( + 'openvidu_rooms', + function (Blueprint $table) { + $table->dropForeign('openvidu_rooms_user_id_foreign'); + $table->dropColumn('user_id'); + } + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table( + 'openvidu_rooms', + function (Blueprint $table) { + $table->dropForeign('openvidu_rooms_tenant_id_foreign'); + $table->dropColumn('tenant_id'); + $table->dropColumn('description'); + $table->dropSoftDeletes(); + + $table->bigInteger('user_id')->nullable(); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + } + ); + + // Set user_id back + foreach (\App\Meet\Room::get() as $room) { + $wallet = $room->wallet(); + if (!$wallet) { + $room->forceDelete(); + continue; + } + + $room->user_id = $wallet->user_id; // @phpstan-ignore-line + $room->save(); + } + + \App\Entitlement::where('entitleable_type', \App\Meet\Room::class)->forceDelete(); + \App\Sku::where('title', 'room')->delete(); + \App\Sku::where('title', 'group-room')->delete(); + + \App\Sku::create([ + 'title' => 'meet', + 'name' => 'Voice & Video Conferencing (public beta)', + 'description' => 'Video conferencing tool', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Meet', + 'active' => true, + ]); + + Schema::dropIfExists('permissions'); + } +}; diff --git a/src/database/seeds/local/MeetRoomSeeder.php b/src/database/seeds/local/MeetRoomSeeder.php --- a/src/database/seeds/local/MeetRoomSeeder.php +++ b/src/database/seeds/local/MeetRoomSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeds\Local; -use App\Meet\Room; use Illuminate\Database\Seeder; class MeetRoomSeeder extends Seeder @@ -15,20 +14,26 @@ public function run() { $john = \App\User::where('email', 'john@kolab.org')->first(); - $jack = \App\User::where('email', 'jack@kolab.org')->first(); + $wallet = $john->wallets()->first(); - \App\Meet\Room::create( + $rooms = [ [ - 'user_id' => $john->id, - 'name' => 'john' - ] - ); - - \App\Meet\Room::create( + 'name' => 'john', + 'description' => "Standard room" + ], [ - 'user_id' => $jack->id, - 'name' => strtolower(\App\Utils::randStr(3, 3, '-')) + 'name' => 'shared', + 'description' => "Shared room" ] - ); + ]; + + foreach ($rooms as $idx => $room) { + $room = \App\Meet\Room::create($room); + $rooms[$idx] = $room; + } + + $rooms[0]->assignToWallet($wallet, 'room'); + $rooms[1]->assignToWallet($wallet, 'group-room'); + $rooms[1]->setConfig(['acl' => 'jack@kolab.org, full']); } } diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -14,7 +14,7 @@ */ public function run() { - Sku::create( + $skus = [ [ 'title' => 'mailbox', 'name' => 'User Mailbox', @@ -24,10 +24,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'domain', 'name' => 'Hosted Domain', @@ -36,10 +33,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-registration', 'name' => 'Domain Registration', @@ -48,10 +42,7 @@ 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-hosting', 'name' => 'External Domain', @@ -61,10 +52,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-relay', 'name' => 'Domain Relay', @@ -73,10 +61,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'storage', 'name' => 'Storage Quota', @@ -86,10 +71,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'groupware', 'name' => 'Groupware Features', @@ -99,10 +81,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'resource', 'name' => 'Resource', @@ -111,10 +90,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'shared-folder', 'name' => 'Shared Folder', @@ -123,10 +99,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => '2fa', 'name' => '2-Factor Authentication', @@ -136,10 +109,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'activesync', 'name' => 'Activesync', @@ -149,166 +119,132 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, - ] - ); - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create( - [ - 'title' => 'beta', - 'name' => 'Private Beta (invitation only)', - 'description' => 'Access to the private beta program features', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta', - 'active' => false, - ] - ); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create( - [ - 'title' => 'meet', - 'name' => 'Voice & Video Conferencing (public beta)', - 'description' => 'Video conferencing tool', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Meet', - 'active' => true, - ] - ); + ], + [ + 'title' => 'beta', + 'name' => 'Private Beta (invitation only)', + 'description' => 'Access to the private beta program features', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ], + [ + 'title' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ], + [ + 'title' => 'group-room', + 'name' => 'Group conference room', + 'description' => 'Shareable audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\GroupRoom', + 'active' => true, + ], + [ + 'title' => 'room', + 'name' => 'Standard conference room', + 'description' => 'Audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ], + ]; + + foreach ($skus as $sku) { + // Check existence because migration might have added this already + if (!Sku::where('title', $sku['title'])->where('tenant_id', \config('app.tenant_id'))->first()) { + Sku::create($sku); + } } - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create( - [ - 'title' => 'group', - 'name' => 'Group', - 'description' => 'Distribution list', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Group', - 'active' => true, - ] - ); - } + $skus = [ + [ + 'title' => 'mailbox', + 'name' => 'User Mailbox', + 'description' => 'Just a mailbox', + 'cost' => 500, + 'fee' => 333, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Mailbox', + 'active' => true, + ], + [ + 'title' => 'storage', + 'name' => 'Storage Quota', + 'description' => 'Some wiggle room', + 'cost' => 25, + 'fee' => 16, + 'units_free' => 5, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Storage', + 'active' => true, + ], + [ + 'title' => 'domain-hosting', + 'name' => 'External Domain', + 'description' => 'Host a domain that is externally registered', + 'cost' => 100, + 'fee' => 66, + 'units_free' => 1, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\DomainHosting', + 'active' => true, + ], + [ + 'title' => 'groupware', + 'name' => 'Groupware Features', + 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', + 'cost' => 490, + 'fee' => 327, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Groupware', + 'active' => true, + ], + [ + 'title' => '2fa', + 'name' => '2-Factor Authentication', + 'description' => 'Two factor authentication for webmail and administration panel', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Auth2F', + 'active' => true, + ], + [ + 'title' => 'activesync', + 'name' => 'Activesync', + 'description' => 'Mobile synchronization', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Activesync', + 'active' => true, + ], + ]; // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { - $sku = Sku::create( - [ - 'title' => 'mailbox', - 'name' => 'User Mailbox', - 'description' => 'Just a mailbox', - 'cost' => 500, - 'fee' => 333, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Mailbox', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); - - $sku = Sku::create( - [ - 'title' => 'storage', - 'name' => 'Storage Quota', - 'description' => 'Some wiggle room', - 'cost' => 25, - 'fee' => 16, - 'units_free' => 5, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Storage', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); - - $sku = Sku::create( - [ - 'title' => 'domain-hosting', - 'name' => 'External Domain', - 'description' => 'Host a domain that is externally registered', - 'cost' => 100, - 'fee' => 66, - 'units_free' => 1, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\DomainHosting', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); - - $sku = Sku::create( - [ - 'title' => 'groupware', - 'name' => 'Groupware Features', - 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', - 'cost' => 490, - 'fee' => 327, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Groupware', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); - - $sku = Sku::create( - [ - 'title' => '2fa', - 'name' => '2-Factor Authentication', - 'description' => 'Two factor authentication for webmail and administration panel', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Auth2F', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); - - $sku = Sku::create( - [ - 'title' => 'activesync', - 'name' => 'Activesync', - 'description' => 'Mobile synchronization', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Activesync', - 'active' => true, - ] - ); - - $sku->tenant_id = $tenant->id; - $sku->save(); + foreach ($skus as $sku) { + $sku = Sku::create($sku); + $sku->tenant_id = $tenant->id; + $sku->save(); + } } } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -14,7 +14,7 @@ */ public function run() { - Sku::create( + $skus = [ [ 'title' => 'mailbox', 'name' => 'User Mailbox', @@ -24,10 +24,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'domain', 'name' => 'Hosted Domain', @@ -36,10 +33,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-registration', 'name' => 'Domain Registration', @@ -48,10 +42,7 @@ 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-hosting', 'name' => 'External Domain', @@ -61,10 +52,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'domain-relay', 'name' => 'Domain Relay', @@ -73,10 +61,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => 'storage', 'name' => 'Storage Quota', @@ -86,10 +71,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'groupware', 'name' => 'Groupware Features', @@ -99,10 +81,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'resource', 'name' => 'Resource', @@ -111,10 +90,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'shared-folder', 'name' => 'Shared Folder', @@ -123,10 +99,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, - ] - ); - - Sku::create( + ], [ 'title' => '2fa', 'name' => '2-Factor Authentication', @@ -136,10 +109,7 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, - ] - ); - - Sku::create( + ], [ 'title' => 'activesync', 'name' => 'Activesync', @@ -149,55 +119,54 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, - ] - ); - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta')->first()) { - Sku::create( - [ - 'title' => 'beta', - 'name' => 'Private Beta (invitation only)', - 'description' => 'Access to the private beta program subscriptions', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta', - 'active' => false, - ] - ); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'meet')->first()) { - Sku::create( - [ - 'title' => 'meet', - 'name' => 'Voice & Video Conferencing (public beta)', - 'description' => 'Video conferencing tool', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Meet', - 'active' => true, - ] - ); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'group')->first()) { - Sku::create( - [ - 'title' => 'group', - 'name' => 'Group', - 'description' => 'Distribution list', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Group', - 'active' => true, - ] - ); + ], + [ + 'title' => 'beta', + 'name' => 'Private Beta (invitation only)', + 'description' => 'Access to the private beta program subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ], + [ + 'title' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ], + [ + 'title' => 'group-room', + 'name' => 'Group conference room', + 'description' => 'Shareable audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\GroupRoom', + 'active' => true, + ], + [ + 'title' => 'room', + 'name' => 'Standard conference room', + 'description' => 'Audio & video conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ], + ]; + + foreach ($skus as $sku) { + // Check existence because migration might have added this already + if (!Sku::where('title', $sku['title'])->first()) { + Sku::create($sku); + } } } } diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js --- a/src/resources/js/meet/room.js +++ b/src/resources/js/meet/room.js @@ -739,7 +739,6 @@ // TODO: This probably could be better done with css let elements = { - '.dropdown-menu': withMenu, '.permissions': withPerm, '.interpreting': withPerm && rolePublisher, 'svg.moderator': roleModerator, diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -16,9 +16,10 @@ const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info') const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List') -const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') +const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info') +const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List') const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings') const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info') const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List') @@ -27,7 +28,7 @@ const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile') const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete') const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet') -const RoomComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') +const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue') const routes = [ { @@ -89,6 +90,12 @@ component: LogoutComponent }, { + name: 'meet', + path: '/meet/:room', + component: MeetComponent, + meta: { loading: true } + }, + { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent @@ -118,16 +125,16 @@ meta: { requiresAuth: true, perm: 'resources' } }, { - component: RoomComponent, + path: '/room/:room', name: 'room', - path: '/meet/:room', - meta: { loading: true } + component: RoomInfoComponent, + meta: { requiresAuth: true, perm: 'rooms' } }, { path: '/rooms', name: 'rooms', - component: MeetComponent, - meta: { requiresAuth: true } + component: RoomListComponent, + meta: { requiresAuth: true, perm: 'rooms' } }, { path: '/settings', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -82,6 +82,12 @@ 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', + 'room-update-success' => 'Room updated successfully.', + 'room-create-success' => 'Room created successfully.', + 'room-delete-success' => 'Room deleted successfully.', + 'room-setconfig-success' => 'Room configuration updated successfully.', + 'room-unsupported-option-error' => 'Invalid room configuration option.', + 'shared-folder-update-success' => 'Shared folder updated successfully.', 'shared-folder-create-success' => 'Shared folder created successfully.', 'shared-folder-delete-success' => 'Shared folder deleted successfully.', diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php --- a/src/resources/lang/en/meet.php +++ b/src/resources/lang/en/meet.php @@ -16,8 +16,6 @@ 'connection-not-found' => 'The connection does not exist.', 'connection-dismiss-error' => 'Failed to dismiss the connection.', 'room-not-found' => 'The room does not exist.', - 'room-setconfig-success' => 'Room configuration updated successfully.', - 'room-unsupported-option-error' => 'Invalid room configuration option.', 'session-not-found' => 'The session does not exist.', 'session-create-error' => 'Failed to create the session.', 'session-join-error' => 'Failed to join the session.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -215,31 +215,6 @@ ], 'meet' => [ - 'title' => "Voice & Video Conferencing", - 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", - 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", - 'notice' => "This is a work in progress and more features will be added over time. Current features include:", - 'sharing' => "Screen Sharing", - 'sharing-text' => "Share your screen for presentations or show-and-tell.", - 'security' => "Room Security", - 'security-text' => "Increase the room security by setting a password that attendees will need to know" - . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", - 'qa-title' => "Raise Hand (Q&A)", - 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", - 'moderation' => "Moderator Delegation", - 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" - . " interrupted with attendees knocking and other moderator duties.", - 'eject' => "Eject Attendees", - 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" - . " violations. Click the user icon for effective dismissal.", - 'silent' => "Silent Audience Members", - 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", - 'interpreters' => "Language Specific Audio Channels", - 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" - . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", - 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." - . " Should you encounter any on your way, let us know by contacting support.", - // Room options dialog 'options' => "Room options", 'password' => "Password", @@ -355,6 +330,22 @@ 'new' => "New resource", ], + 'room' => [ + 'create' => "Create room", + 'delete' => "Delete room", + 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", + 'goto' => "Enter the room", + 'list-empty' => "There are no conference rooms in this account.", + 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", + 'list-title' => "Voice & video conferencing rooms", + 'moderators' => "Moderators", + 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", + 'new' => "New room", + 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", + 'title' => "Room: {name}", + 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", + ], + 'settings' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -153,6 +153,7 @@ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', + 'acl-permission-invalid' => 'The specified permission is invalid.', 'file-perm-exists' => 'File permission already exists.', 'file-perm-invalid' => 'The file permission is invalid.', 'file-name-exists' => 'The file name already exists.', diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php --- a/src/resources/lang/fr/app.php +++ b/src/resources/lang/fr/app.php @@ -74,6 +74,9 @@ 'resource-delete-success' => "Ressource suprimmée avec succès.", 'resource-setconfig-success' => "Les paramètres des ressources ont été mis à jour avec succès.", + 'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.', + 'room-unsupported-option-error' => 'Option de configuration de la salle invalide.', + 'shared-folder-update-success' => "Dossier partagé mis à jour avec succès.", 'shared-folder-create-success' => "Dossier partagé créé avec succès.", 'shared-folder-delete-success' => "Dossier partagé supprimé avec succès.", diff --git a/src/resources/lang/fr/meet.php b/src/resources/lang/fr/meet.php --- a/src/resources/lang/fr/meet.php +++ b/src/resources/lang/fr/meet.php @@ -16,8 +16,6 @@ 'connection-not-found' => 'La connexion n´existe pas.', 'connection-dismiss-error' => 'Échec du rejet de la connexion.', 'room-not-found' => 'La salle n´existe pas.', - 'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.', - 'room-unsupported-option-error' => 'Option de configuration de la salle invalide.', 'session-not-found' => 'La session n\'existe pas.', 'session-create-error' => 'Échec de la création de la session.', 'session-join-error' => 'Échec de se joindre à la session.', diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -131,6 +131,7 @@ 'phone' => "Téléphone", 'shared-folder' => "Dossier partagé", 'status' => "État", + 'subscriptions' => "Subscriptions", 'surname' => "Nom de famille", 'type' => "Type", 'user' => "Utilisateur", @@ -176,9 +177,7 @@ ], 'meet' => [ - 'title' => "Voix et vidéo-conférence", 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", - 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.", 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", 'sharing' => "Partage d'écran", 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", @@ -438,7 +437,6 @@ 'search-pl' => "ID utilisateur, e-mail ou domamine", 'skureq' => "{sku} demande {list}.", 'subscription' => "Subscription", - 'subscriptions' => "Subscriptions", 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", 'users' => "Utilisateurs", ], diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -28,11 +28,11 @@ {{ $t('dashboard.wallet') }} {{ $root.price(balance, currency) }} - + {{ $t('dashboard.chat') }} {{ $t('dashboard.beta') }} - + {{ $t('dashboard.files') }} {{ $t('dashboard.beta') }} diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue --- a/src/resources/vue/Meet/RoomOptions.vue +++ b/src/resources/vue/Meet/RoomOptions.vue @@ -57,7 +57,7 @@ configSave(name, value, callback) { const post = { [name]: value } - axios.post('/api/v4/meet/rooms/' + this.room + '/config', post) + axios.post('/api/v4/rooms/' + this.room + '/config', post) .then(response => { this.$set(this.config, name, value) if (callback) { diff --git a/src/resources/vue/Room/Info.vue b/src/resources/vue/Room/Info.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Room/Info.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/resources/vue/Room/List.vue b/src/resources/vue/Room/List.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Room/List.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue deleted file mode 100644 --- a/src/resources/vue/Rooms.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -77,7 +77,7 @@
- +
{{ $t('btn.submit') }} @@ -231,15 +231,7 @@ if (this.user_id !== 'new') { method = 'put' location += '/' + this.user_id - - let skus = {} - $('#user-skus input[type=checkbox]:checked').each((idx, input) => { - let id = $(input).val() - let range = $(input).parents('tr').first().find('input[type=range]').val() - - skus[id] = range || 1 - }) - post.skus = skus + post.skus = this.$refs.skus.getSkus() } else { post.package = $('#user-packages input:checked').val() } diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue --- a/src/resources/vue/Widgets/AclInput.vue +++ b/src/resources/vue/Widgets/AclInput.vue @@ -6,7 +6,7 @@ - @@ -15,7 +15,7 @@
- @@ -26,17 +26,19 @@