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 @@ -326,6 +326,16 @@ ); } + /** + * Returns domain's namespace (required by the EntitleableTrait). + * + * @return string|null Domain namespace + */ + public function toString(): ?string + { + return $this->namespace; + } + /** * Unsuspend this domain. * diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -13,7 +13,7 @@ * * @property int $cost * @property ?string $description - * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property ?object $entitleable The entitled object (receiver of the entitlement). * @property int $entitleable_id * @property string $entitleable_type * @property int $fee @@ -100,20 +100,6 @@ return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } - /** - * 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 * diff --git a/src/app/Handlers/Room.php b/src/app/Handlers/Room.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Room.php @@ -0,0 +1,33 @@ +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,46 +6,9 @@ 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,17 @@ $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner || $room->owner->isDegraded(true)) { + if (!$room || !($wallet = $room->wallet()) || !$wallet->owner || $wallet->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 (!$wallet->owner->hasSku('meet')) { 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->shares()->where('user', $user->email)->exists()); $init = !empty(request()->input('init')); // There's no existing session @@ -180,69 +143,6 @@ return response()->json($response, $response_code); } - /** - * 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 * 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,278 @@ +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('shareable_id') + ->from('shares') + ->where('shareable_type', Room::class) + ->where('user', $user->email); + }); + + $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, Share::ADMIN); + if (is_int($room)) { + return $this->errorResponse($room); + } + + $errors = $room->setConfig(request()->input()); + + 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, Share::READ); + 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(); + $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->shares()->where('user', $user->email)->exists(); + $response['canDelete'] = $isOwner; + $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' => 'string|max:191' + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + $room = Room::create([ + 'description' => $request->input('description'), + ]); + + $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, Share::ADMIN); + if (is_int($room)) { + return $this->errorResponse($room); + } + + // Validate the input + $v = Validator::make( + request()->all(), + [ + 'description' => 'string|max:191' + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $room->description = request()->input('description'); + $room->save(); + + // TODO: Update entitlements + + 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 + * + * @return \App\Meet\Room|int File object or error code + */ + protected function inputRoom($id, $rights = 0): 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) { + $share = $room->shares()->where('user', $user->email)->first(); + + if ($share && $share->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,7 @@ use App\Http\Controllers\ResourceController; use App\Sku; use Illuminate\Http\Request; +use Illuminate\Support\Str; class SkusController extends ResourceController { @@ -78,15 +79,15 @@ } /** - * Return SKUs available to the specified user/domain. + * Return SKUs available to the specified object. * - * @param object $object User or Domain object + * @param object $object User/Domain/etc object * * @return \Illuminate\Http\JsonResponse */ - protected static function objectSkus($object) + public static function objectSkus($object) { - $type = $object instanceof \App\Domain ? 'domain' : 'user'; + $type = Str::kebab(\class_basename($object::class)); $response = []; // Note: Order by title for consistent ordering in tests diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -165,6 +165,7 @@ // Check if the user is a controller of his wallet $isController = $user->canDelete($user); + $isDegraded = $user->isDegraded(); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; @@ -185,11 +186,12 @@ 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), - 'enableFiles' => in_array('files', $skus), + 'enableFiles' => !$isDegraded && in_array('files', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), + 'enableRooms' => !$isDegraded && in_array('meet', $skus), 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, 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\SettingsTrait; +use App\Traits\ShareableTrait; +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 ShareableTrait; + 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'; @@ -180,16 +203,6 @@ return $response->getStatusCode() == 200; } - /** - * The room owner. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function owner() - { - return $this->belongsTo('\App\User', 'user_id', 'id'); - } - /** * Accept the join request. * @@ -259,16 +272,6 @@ return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); } - /** - * 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) * @@ -299,6 +302,28 @@ return $response->getStatusCode() == 200; } + /** + * Returns a map of supported ACL labels. + * + * @return array Map of supported share rights/ACL labels + */ + protected function supportedACL(): array + { + return [ + 'full' => \App\Share::READ | \App\Share::WRITE | \App\Share::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 * 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 Share 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/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/Share.php b/src/app/Share.php new file mode 100644 --- /dev/null +++ b/src/app/Share.php @@ -0,0 +1,57 @@ + The attributes that are mass assignable */ + protected $fillable = [ + 'shareable_id', + 'shareable_type', + 'rights', + 'user', + ]; + + /** @var array The attributes that should be cast */ + protected $casts = [ + 'rights' => 'integer', + ]; + + /** + * Principally shareable object such as Room. + * Note that it may be trashed (soft-deleted). + * + * @return mixed + */ + public function shareable() + { + return $this->morphTo()->withTrashed(); // @phpstan-ignore-line + } + + /** + * Rights mutator. Make sure rights is integer. + */ + public function setRightsAttribute($rights): void + { + $this->attributes['rights'] = $rights; + } +} 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 @@ -94,7 +94,7 @@ // Note: it does not work with User/Domain model (yet) $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 +168,7 @@ */ public function hasSku(string $title): bool { - $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); + $sku = $this->skuByTitle($title); if (!$sku) { return false; @@ -234,6 +234,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 * 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,60 @@ +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') { + $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/ShareableTrait.php b/src/app/Traits/ShareableTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/ShareableTrait.php @@ -0,0 +1,163 @@ +shares()->delete(); + }); + } + + /** + * Shares for this object. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function shares() + { + return $this->hasMany(Share::class, 'shareable_id', 'id') + ->where('shareable_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 shares + * + * @return array ACL list in a "common" format + */ + protected function getACL(): array + { + $supported = $this->supportedACL(); + + return $this->shares()->get() + ->map(function ($share) use ($supported) { + $acl = array_search($share->rights, $supported) ?: 'none'; + return "{$share->user}, {$acl}"; + }) + ->all(); + } + + /** + * Update the shares 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->shares()->get()->each(function ($share) use (&$users) { + if (isset($users[$share->user])) { + if ($share->rights != $users[$share->user]) { + $share->rights = $users[$share->user]; + $share->save(); + } + unset($users[$share->user]); + } else { + $share->delete(); + } + }); + + foreach ($users as $user => $rights) { + $this->shares()->create([ + 'user' => $user, + 'rights' => $rights, + 'shareable_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' => Share::RIGHT_READ, + 'read-write' => Share::RIGHT_READ | Share::RIGHT_WRITE, + 'full' => Share::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 @@ -434,6 +434,19 @@ return $this->entitleables(Resource::class, $with_accounts); } + /** + * 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. * diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -354,6 +354,18 @@ return (100 - $this->getDiscount()) / 100; } + /** + * 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_shares_and_room_subscriptions.php b/src/database/migrations/2022_05_13_100000_shares_and_room_subscriptions.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_05_13_100000_shares_and_room_subscriptions.php @@ -0,0 +1,125 @@ +string('id', 36)->primary(); + $table->bigInteger('shareable_id'); + $table->string('shareable_type'); + $table->integer('rights')->default(0); + $table->string('user'); + $table->timestamps(); + + $table->index('user'); + $table->index(['shareable_id', 'shareable_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 SKU + if (!\App\Sku::where('title', 'room')->first()) { + $sku = \App\Sku::create([ + 'title' => 'room', + 'name' => 'Conference room', + 'description' => 'Audio & Video Conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ]); + + $counts = []; + + // Create the entitlement for every 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->wallet(); + $counts[$wallet->id] = ($counts[$wallet->id] ?? 0) + 1; + + \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $counts[$wallet->id] > $sku->units_free ? $sku->cost : 0, + 'fee' => $counts[$wallet->id] > $sku->units_free ? $sku->fee : 0, + 'entitleable_id' => $room->id, + 'entitleable_type' => \App\Meet\Room::class + ]); + + // Set shares for room users that do not own them + if ($wallet->id != $user->wallets()->first()->id) { + $room->shares()->create([ + 'user' => $user->email, + 'rights' => \App\Share::ADMIN, + ]); + } + } + } + + 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'); + } + ); + + // TODO: Set user_id back + + \App\Entitlement::where('entitleable_type', \App\Meet\Room::class)->forceDelete(); + \App\Sku::where('title', 'room')->delete(); + + Schema::dropIfExists('shares'); + } +}; 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,29 @@ 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' => "John's room" + ], [ - 'user_id' => $jack->id, - 'name' => strtolower(\App\Utils::randStr(3, 3, '-')) + 'name' => 'jack', + 'description' => "Jack's room" ] - ); + ]; + + foreach ($rooms as $idx => $room) { + $room = \App\Meet\Room::create($room); + $room->assignToWallet($wallet); + $rooms[$idx] = $room; + } + + $rooms[1]->shares()->create([ + 'user' => 'jack@kolab.org', + 'rights' => \App\Share::ADMIN, + 'shareable_type' => \App\Meet\Room::class, + ]); } } 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,86 +119,58 @@ '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 subscriptions', - '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, - ] - ); - } - - // 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, - ] - ); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-distlists', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create( - [ - 'title' => 'beta-distlists', - 'name' => 'Distribution lists', - 'description' => 'Access to mail distribution lists', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Distlists', - 'active' => true, - ] - ); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-resources', '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 subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ], + [ + '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' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ], + [ + 'title' => 'room', + 'name' => 'Conference room', + 'description' => 'Audio & Video Conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ], + [ + 'title' => 'beta-distlists', + 'name' => 'Distribution lists', + 'description' => 'Access to mail distribution lists', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Distlists', + 'active' => true, + ], + [ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', @@ -237,14 +179,8 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, - ]); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create([ + ], + [ 'title' => 'beta-shared-folders', 'name' => 'Shared folders', 'description' => 'Access to shared folders', @@ -253,14 +189,8 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\SharedFolders', 'active' => true, - ]); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'files', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create([ + ], + [ 'title' => 'files', 'name' => 'File storage', 'description' => 'Access to file storage', @@ -269,112 +199,92 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Files', '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); + } } + $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,60 +119,48 @@ '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, - ] - ); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-distlists')->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, + ], + [ + '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' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ], + [ + 'title' => 'room', + 'name' => 'Conference room', + 'description' => 'Audio & Video Conference room', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Room', + 'active' => true, + ], + [ 'title' => 'beta-distlists', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', @@ -211,12 +169,8 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Distlists', 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-resources')->first()) { - Sku::create([ + ], + [ 'title' => 'beta-resources', 'name' => 'Calendaring resources', 'description' => 'Access to calendaring resources', @@ -225,12 +179,8 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\Resources', 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-shared-folders')->first()) { - Sku::create([ + ], + [ 'title' => 'beta-shared-folders', 'name' => 'Shared folders', 'description' => 'Access to shared folders', @@ -239,12 +189,8 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta\SharedFolders', 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'files')->first()) { - Sku::create([ + ], + [ 'title' => 'files', 'name' => 'File storage', 'description' => 'Access to file storage', @@ -253,7 +199,14 @@ 'period' => 'monthly', 'handler_class' => 'App\Handlers\Files', '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/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 = [ { @@ -88,6 +89,12 @@ name: 'logout', component: LogoutComponent }, + { + name: 'meet', + path: '/meet/:room', + component: MeetComponent, + meta: { loading: true } + }, { path: '/password-reset/:code?', name: 'password-reset', @@ -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 @@ -173,6 +173,7 @@ 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", + 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'user' => "User", @@ -214,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", @@ -354,6 +330,21 @@ '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-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", @@ -490,7 +481,6 @@ 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", - 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 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/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -336,7 +336,7 @@ tabs: [ { label: 'user.finances' }, { label: 'user.aliases', count: 0 }, - { label: 'user.subscriptions', count: 0 }, + { label: 'form.subscriptions', count: 0 }, { label: 'user.domains', count: 0 }, { label: 'user.users', count: 0 }, { label: 'user.distlists', count: 0 }, 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/Domain/Info.vue b/src/resources/vue/Domain/Info.vue --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -30,7 +30,7 @@
- +
{{ $t('btn.submit') }} 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,162 @@ + + + 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,71 @@ + + + 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 @@ -76,8 +76,8 @@
- - + +
{{ $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 @@