Fix FLEXIAPI-220 Migrate SIP Domains to Spaces

This commit is contained in:
Timothée Jaussoin 2024-12-02 13:04:49 +00:00
parent 0d6bc37207
commit 93c98ae73f
84 changed files with 1470 additions and 1299 deletions

View file

@ -14,14 +14,6 @@ rocky9-deploy:
- rocky9-package
- rocky9-test
debian11-deploy:
extends: .deploy
script:
- ./deploy_packages.sh debian bullseye
needs:
- debian11-package
- debian11-test
debian12-deploy:
extends: .deploy
script:

View file

@ -22,10 +22,6 @@ rocky9-package:
- dnf -y install php-sodium
- make rpm-el9
debian11-package:
extends: .debian_package
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
debian12-package:
extends: .debian_package
image: gitlab.linphone.org:4567/bc/public/docker/debian12-php:$DEBIAN_12_IMAGE_VERSION

View file

@ -24,12 +24,6 @@ rocky9-test:
- php artisan key:generate
- vendor/bin/phpunit --log-junit $CI_PROJECT_DIR/flexiapi_phpunit.log
debian11-test:
extends: .debian-test
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
needs:
- debian11-package
debian12-test:
extends: .debian-test
image: gitlab.linphone.org:4567/bc/public/docker/debian12-php:$DEBIAN_12_IMAGE_VERSION

View file

@ -1,7 +1,6 @@
variables:
ROCKY_8_IMAGE_VERSION: 20241113_143521_update_php_82
ROCKY_9_IMAGE_VERSION: 20241114_161138_remove_redis
DEBIAN_11_IMAGE_VERSION: 20241112_113527_update_package_and_dependencies
DEBIAN_12_IMAGE_VERSION: 20241112_113948_update_package_and_dependencies
PHP_REDIS_REMI_VERSION: php-pecl-redis6-6.1.0-1
PHP_IGBINARY_REMI_VERSION: php-pecl-igbinary-3.2.16-2

View file

@ -2,7 +2,8 @@
v1.7
----
- Fix FLEXIAPI-206 Upgrade to Laravel 10, PHP 8.1 minimum and bump all the related dependencies
- Fix FLEXIAPI-206 Upgrade to Laravel 10, PHP 8.1 minimum and bump all the related dependencies, drop Debian 11 Bullseye
- Fix FLEXIAPI-220 Migrate SIP Domains to Spaces
v1.6
----
@ -29,7 +30,7 @@ v1.5
- Fix FLEXIAPI-185 Return null if the account dictionary is empty in the API
- Fix FLEXIAPI-184 Append phone_change_code and email_change_code to the admin /accounts/<id> endpoint if they are available
- Fix FLEXIAPI-183 Complete the account hooks on the dictionnary actions
- Fix FLEXIAPI-182 Replace APP_SUPER_ADMINS_SIP_DOMAINS with a proper sip_domains table, API endpoints, UI panels, console command, tests and documentation
- Fix FLEXIAPI-182 Replace APP_SUPER_ADMINS_SIP_DOMAINS with a proper spaces table, API endpoints, UI panels, console command, tests and documentation
- Fix FLEXIAPI-181 Replace APP_ADMINS_MANAGE_MULTI_DOMAINS with APP_SUPER_ADMINS_SIP_DOMAINS
- Fix FLEXIAPI-180 Fix the token and activation flow for the provisioning with token endpoint when the header is missing
- Fix FLEXIAPI-179 Add Localization support as a Middleware that handles Accept-Language HTTP header

View file

@ -57,7 +57,7 @@ package-end-common:
rm -rf $(OUTPUT_DIR)/rpmbuild/SPECS $(OUTPUT_DIR)/rpmbuild/SOURCES $(OUTPUT_DIR)/rpmbuild/SRPMS $(OUTPUT_DIR)/rpmbuild/BUILD $(OUTPUT_DIR)/rpmbuild/BUILDROOT
rpm-el8-only:
sed -i 's/Requires:.*/Requires: php >= 8.0, php-gd, php-pdo, php-redis, php-mysqlnd, php-mbstring/g' $(OUTPUT_DIR)/rpmbuild/SPECS/flexisip-account-manager.spec
sed -i 's/Requires:.*/Requires: php >= 8.1, php-gd, php-pdo, php-redis, php-mysqlnd, php-mbstring/g' $(OUTPUT_DIR)/rpmbuild/SPECS/flexisip-account-manager.spec
rpmbuild -v -bb --define 'dist .el8' --define '_topdir $(OUTPUT_DIR)/rpmbuild' --define "_rpmdir $(OUTPUT_DIR)/rpmbuild" $(OUTPUT_DIR)/rpmbuild/SPECS/flexisip-account-manager.spec
@echo "📦✅ RPM el8 Package Created"
@ -75,7 +75,7 @@ deb-only:
fakeroot alien -g -k --scripts $(OUTPUT_DIR)/rpmbuild/tmp.rpm
rm -r $(OUTPUT_DIR)/rpmbuild
rm -rf $(OUTPUT_DIR)/*.orig
sed -i 's/Depends:.*/Depends: $${shlibs:Depends}, php (>= 8.0), php-xml, php-pdo, php-gd, php-redis, php-mysql, php-mbstring, php-sqlite3/g' $(OUTPUT_DIR)/bc-flexisip-account-manager*/debian/control
sed -i 's/Depends:.*/Depends: $${shlibs:Depends}, php (>= 8.1), php-xml, php-pdo, php-gd, php-redis, php-mysql, php-mbstring, php-sqlite3/g' $(OUTPUT_DIR)/bc-flexisip-account-manager*/debian/control
cd `ls -rt $(OUTPUT_DIR) | tail -1` && dpkg-buildpackage --no-sign
@echo "📦✅ DEB Package Created"

View file

@ -148,11 +148,11 @@ FlexiAPI is also providing endpoints to provision Liblinphone powered devices. Y
FlexiAPI is shipped with several console commands that you can launch using the `artisan` executable available at the root of this project.
### Create or update a SIP Domain
### Create or update a Space
Create or update a SIP Domain, required to then create accounts afterward. The `super` option enable/disable the domain as a super domain.
Create or update a Space, required to then create accounts afterward. The `super` option enable/disable the domain as a super domain.
php artisan sip_domains:create-update {domain} {--super}
php artisan spaces:create-update {domain} {--super}
### Create an admin account

View file

@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- **New DotEnv variable:** `JWT_RSA_PUBLIC_KEY_PEM=`
- **New DotEnv variable:** `JWT_SIP_IDENTIFIER=sip_identifier`
- **Super-domains and super-admins support:** Introduce SIP domains management. The app accounts are now divided by their domains with their own respective administrators that can only see and manage their own domain accounts and settings. On top of that it is possible to configure a SIP domain as a "super-domain" and then allow its admins to become "super-admins". Those super-admins will then be able to manage all the accounts handled by the instance and create/edit/delete the other SIP domains. Add new endpoints and a new super-admin role in the API to manage the SIP domains. SIP domains can also be created and updated directly from the console using a new artisan script (documented in the README);
- **New Artisan script:** `php artisan sip_domains:create-update {domain} {--super}`
- **New Artisan script:** `php artisan spaces:create-update {domain} {--super}`
- **Account Dictionary:** Each account can now handle a specific dictionary, configurable by the API or directly the web panel. This dictionary allows developers to store arbitrary `key -> value pairs` on each accounts.
- **Vcard storage:** Attach custom vCards on a dedicated account using new endpoints in the API. The published vCard are validated before being stored.

View file

@ -2,8 +2,7 @@ APP_NAME=FlexiAPI
APP_ENV=local
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_SIP_DOMAIN=sip.example.com
APP_ROOT_DOMAIN=
APP_LINPHONE_DAEMON_UNIX_PATH=
APP_FLEXISIP_PUSHER_PATH=

View file

@ -178,9 +178,9 @@ class Account extends Authenticatable
return $this->belongsToMany(AccountType::class);
}
public function sipDomain()
public function space()
{
return $this->hasOne(SipDomain::class, 'domain', 'domain');
return $this->hasOne(Space::class, 'domain', 'domain');
}
public function statisticsFromCalls()
@ -336,7 +336,7 @@ class Account extends Authenticatable
public function getSuperAdminAttribute(): bool
{
return SipDomain::where('domain', $this->domain)->where('super', true)->exists() && $this->admin;
return Space::where('domain', $this->domain)->where('super', true)->exists() && $this->admin;
}
/**

View file

@ -23,7 +23,7 @@ use Illuminate\Console\Command;
use Carbon\Carbon;
use App\Account;
use App\SipDomain;
use App\Space;
class CreateAdminAccount extends Command
{
@ -37,7 +37,7 @@ class CreateAdminAccount extends Command
public function handle()
{
$sipDomains = SipDomain::all('domain')->pluck('domain');
$spaces = Space::all('domain')->pluck('domain');
$this->info('Your will create a new admin account in the database, existing accounts with the same credentials will be overwritten');
@ -50,7 +50,7 @@ class CreateAdminAccount extends Command
}
if (!$this->option('domain')) {
$domain = $this->ask('What will be the admin domain? Default: ' . $sipDomains->first());
$domain = $this->ask('What will be the admin domain? Default: ' . $spaces->first());
}
if (!$this->option('password')) {
@ -58,11 +58,11 @@ class CreateAdminAccount extends Command
}
$username = $username ?? 'admin';
$domain = $domain ?? $sipDomains->first();
$domain = $domain ?? $spaces->first();
$password = $password ?? 'change_me';
if (!$sipDomains->contains($domain)) {
$this->error('The domain must be one of the following ones: ' . $sipDomains->implode(', '));
if (!$spaces->contains($domain)) {
$this->error('The domain must be one of the following ones: ' . $spaces->implode(', '));
$this->comment('You can create an extra domain using the dedicated console command');
return Command::FAILURE;
}

View file

@ -40,7 +40,7 @@ class CreateAdminTest extends Command
$username = 'admin_test';
$domain = 'sip.example.org';
$this->call('sip_domains:create-update', [
$this->call('spaces:create-update', [
'domain' => $domain,
'--super' => 'true'
]);

View file

@ -17,33 +17,34 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Console\Commands\SipDomains;
namespace App\Console\Commands\Spaces;
use App\SipDomain;
use App\Space;
use Illuminate\Console\Command;
class CreateUpdate extends Command
{
protected $signature = 'sip_domains:create-update {domain} {--super}';
protected $description = 'Create a SIP Domain';
protected $signature = 'spaces:create-update {sip_domain} {host} {--super}';
protected $description = 'Create a Space';
public function handle()
{
$this->info('Your will create or update a SIP Domain in the database');
$this->info('Your will create or update a Space in the database');
$sipDomain = SipDomain::where('domain', $this->argument('domain'))->firstOrNew();
$sipDomain->domain = $this->argument('domain');
$space = Space::where('domain', $this->argument('sip_domain'))->firstOrNew();
$space->host = $this->argument('host');
$space->domain = $this->argument('sip_domain');
$sipDomain->exists
$space->exists
? $this->info('The domain already exists, updating it')
: $this->info('A new domain will be created');
$sipDomain->super = (bool)$this->option('super');
$sipDomain->super
$space->super = (bool)$this->option('super');
$space->super
? $this->info('Set as a super domain')
: $this->info('Set as a normal domain');
$sipDomain->save();
$space->save();
return Command::SUCCESS;
}

View file

@ -42,7 +42,7 @@ function generateNonce(): string
function getRequestBoolean(Request $request, string $key): bool
{
return $request->has($key) ? $request->get($key) == "true" : false;
return $request->has($key) ? $request->get($key) == "on" : false;
}
function generateValidNonce(Account $account): string

View file

@ -72,7 +72,7 @@ class AuthTokenController extends Controller
$authToken->delete();
return redirect()->route('account.dashboard');
return redirect()->route('account.home');
}
/**
@ -87,6 +87,6 @@ class AuthTokenController extends Controller
$authToken->save();
}
return redirect()->route('account.dashboard');
return redirect()->route('account.home');
}
}

View file

@ -33,7 +33,13 @@ class AuthenticateController extends Controller
public function login(Request $request)
{
if (Auth::user()) {
if ($request->user()) {
if ($request->user()->superAdmin) {
return redirect()->route('admin.spaces.index');
} elseif ($request->user()->admin) {
return redirect()->route('admin.spaces.me');
}
return redirect()->route('account.dashboard');
}
@ -67,7 +73,7 @@ class AuthenticateController extends Controller
bchash($account->username, $account->resolvedRealm, $request->get('password'), $password->algorithm)
)) {
Auth::login($account);
return redirect()->route('account.dashboard');
return redirect()->route('account.home');
}
}
@ -94,7 +100,7 @@ class AuthenticateController extends Controller
Auth::login($account);
return redirect()->route('account.dashboard');
return redirect()->route('account.home');
}
public function loginAuthToken(Request $request, ?string $token = null)
@ -120,7 +126,7 @@ class AuthenticateController extends Controller
$authToken->delete();
return redirect()->route('account.dashboard');
return redirect()->route('account.home');
}
return view('account.authenticate.auth_token', [

View file

@ -206,7 +206,7 @@ class ProvisioningController extends Controller
if ($account) {
$ui = $xpath->query("//section[@name='ui']")->item(0);
if ($ui == null && $account->sipDomain) {
if ($ui == null && $account->space) {
$section = $dom->createElement('section');
$section->setAttribute('name', 'ui');
@ -225,7 +225,7 @@ class ProvisioningController extends Controller
'max_account',
] as $key) {
// Cast the booleans into integers
$entry = $dom->createElement('entry', (int)$account->sipDomain->$key);
$entry = $dom->createElement('entry', (int)$account->space->$key);
$entry->setAttribute('name', $key);
$section->appendChild($entry);
}

View file

@ -28,7 +28,7 @@ use App\ContactsList;
use App\Http\Requests\Account\Create\Web\AsAdminRequest;
use App\Http\Requests\Account\Update\Web\AsAdminRequest as WebAsAdminRequest;
use App\Services\AccountService;
use App\SipDomain;
use App\Space;
class AccountController extends Controller
{
@ -61,6 +61,9 @@ class AccountController extends Controller
}
return view('admin.account.index', [
'space' => (!$request->user()->superAdmin)
? $request->user()->space
: null,
'domains' => Account::groupBy('domain')->pluck('domain'),
'contacts_lists' => ContactsList::all()->pluck('title', 'id'),
'accounts' => $accounts->paginate(20)->appends($request->query()),
@ -74,11 +77,21 @@ class AccountController extends Controller
public function create(Request $request)
{
$account = new Account;
if ($request->has('admin')) {
$account->admin = true;
}
if ($request->has('domain')) {
$account->domain = $request->get('domain');
}
return view('admin.account.create_edit', [
'account' => new Account,
'account' => $account,
'domains' => $request->user()?->superAdmin
? SipDomain::all()
: SipDomain::where('domain', $request->user()->domain)->get(),
? Space::notFull()->get()
: Space::where('domain', $request->user()->domain)->get(),
'protocols' => [null => 'None'] + Account::$dtmfProtocols
]);
}
@ -100,8 +113,8 @@ class AccountController extends Controller
'account' => $account,
'protocols' => [null => 'None'] + Account::$dtmfProtocols,
'domains' => $request->user()?->superAdmin
? SipDomain::all()
: SipDomain::where('domain', $account->domain)->get(),
? Space::all()
: Space::where('domain', $account->domain)->get(),
'contacts_lists' => ContactsList::whereNotIn('id', function ($query) use ($accountId) {
$query->select('contacts_list_id')
->from('account_contacts_list')

View file

@ -1,120 +0,0 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\SipDomain;
use Illuminate\Validation\Rule;
class SipDomainController extends Controller
{
public function index()
{
return view('admin.sip_domain.index', ['sip_domains' => SipDomain::withCount('accounts')->get()]);
}
public function create()
{
return view('admin.sip_domain.create_edit', [
'sip_domain' => new SipDomain
]);
}
public function store(Request $request)
{
$request->validate([
'domain' => 'required|unique:sip_domains',
]);
$sipDomain = new SipDomain;
$sipDomain->domain = $request->get('domain');
$sipDomain = $this->setConfig($request, $sipDomain);
$sipDomain->save();
return redirect()->route('admin.sip_domains.index');
}
public function edit(int $id)
{
return view('admin.sip_domain.create_edit', [
'sip_domain' => SipDomain::findOrFail($id)
]);
}
public function update(Request $request, int $id)
{
$request->validate([
'max_account' => 'required|integer',
]);
$sipDomain = SipDomain::findOrFail($id);
$sipDomain = $this->setConfig($request, $sipDomain);
$sipDomain->save();
return redirect()->back();
}
private function setConfig(Request $request, SipDomain $sipDomain)
{
$request->validate([
'max_account' => 'required|integer',
]);
$sipDomain->super = getRequestBoolean($request, 'super');
$sipDomain->disable_chat_feature = getRequestBoolean($request, 'disable_chat_feature');
$sipDomain->disable_meetings_feature = getRequestBoolean($request, 'disable_meetings_feature');
$sipDomain->disable_broadcast_feature = getRequestBoolean($request, 'disable_broadcast_feature');
$sipDomain->hide_settings = getRequestBoolean($request, 'hide_settings');
$sipDomain->max_account = $request->get('max_account', 0);
$sipDomain->hide_account_settings = getRequestBoolean($request, 'hide_account_settings');
$sipDomain->disable_call_recordings_feature = getRequestBoolean($request, 'disable_call_recordings_feature');
$sipDomain->only_display_sip_uri_username = getRequestBoolean($request, 'only_display_sip_uri_username');
$sipDomain->assistant_hide_create_account = getRequestBoolean($request, 'assistant_hide_create_account');
$sipDomain->assistant_disable_qr_code = getRequestBoolean($request, 'assistant_disable_qr_code');
$sipDomain->assistant_hide_third_party_account = getRequestBoolean($request, 'assistant_hide_third_party_account');
return $sipDomain;
}
public function delete(int $id)
{
return view('admin.sip_domain.delete', [
'sip_domain' => SipDomain::findOrFail($id)
]);
}
public function destroy(Request $request, int $id)
{
$sipDomain = SipDomain::findOrFail($id);
$request->validate([
'domain' => [
'required',
Rule::in(['first-zone', $sipDomain->domain]),
]
]);
$sipDomain->delete();
return redirect()->route('admin.sip_domains.index');
}
}

View file

@ -0,0 +1,164 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Space;
use Illuminate\Validation\Rule;
class SpaceController extends Controller
{
public function index()
{
return view('admin.space.index', ['spaces' => Space::withCount('accounts')->get()]);
}
public function me(Request $request)
{
return view('admin.space.show', [
'space' => $request->user()->space
]);
}
public function show(Space $space)
{
return view('admin.space.show', [
'space' => $space
]);
}
public function create()
{
return view('admin.space.create', [
'space' => new Space()
]);
}
public function store(Request $request)
{
$request->merge(['full_host' => $request->get('host') . '.' . config('app.root_domain')]);
$request->validate([
'domain' => 'required|unique:spaces|regex:/'. Space::DOMAIN_REGEX . '/',
'host' => 'required|regex:/'. Space::HOST_REGEX . '/',
'full_host' => 'required|unique:spaces,host',
]);
$space = new Space();
$space->domain = $request->get('domain');
$space->host = $request->get('full_host');
$space->save();
return redirect()->route('admin.spaces.index');
}
public function edit(Space $space)
{
return view('admin.space.edit', [
'space' => $space
]);
}
public function update(Request $request, Space $space)
{
$request->validate([
'max_account' => 'required|integer',
]);
$space = $this->setConfig($request, $space);
$space->save();
return redirect()->back();
}
public function parameters(Space $space)
{
return view('admin.space.parameters', [
'space' => $space
]);
}
public function parametersUpdate(Request $request, Space $space)
{
$request->validate([
'max_accounts' => 'required|integer|min:0',
'expire_at' => 'nullable|date|after_or_equal:today'
]);
if ($request->get('max_accounts') > 0) {
$request->validate([
'max_accounts' => 'integer|min:' . $space->accounts()->count()
]);
}
$space->super = getRequestBoolean($request, 'super');
$space->max_accounts = $request->get('max_accounts');
$space->expire_at = $request->get('expire_at');
$space->save();
return redirect()->route('admin.spaces.show', $space);
}
private function setConfig(Request $request, Space $space)
{
$request->validate([
'max_account' => 'required|integer',
]);
$space->disable_chat_feature = getRequestBoolean($request, 'disable_chat_feature');
$space->disable_meetings_feature = getRequestBoolean($request, 'disable_meetings_feature');
$space->disable_broadcast_feature = getRequestBoolean($request, 'disable_broadcast_feature');
$space->hide_settings = getRequestBoolean($request, 'hide_settings');
$space->max_account = $request->get('max_account', 0);
$space->hide_account_settings = getRequestBoolean($request, 'hide_account_settings');
$space->disable_call_recordings_feature = getRequestBoolean($request, 'disable_call_recordings_feature');
$space->only_display_sip_uri_username = getRequestBoolean($request, 'only_display_sip_uri_username');
$space->assistant_hide_create_account = getRequestBoolean($request, 'assistant_hide_create_account');
$space->assistant_disable_qr_code = getRequestBoolean($request, 'assistant_disable_qr_code');
$space->assistant_hide_third_party_account = getRequestBoolean($request, 'assistant_hide_third_party_account');
return $space;
}
public function delete(Request $request, int $id)
{
$space = Space::findOrFail($id);
return view('admin.space.delete', [
'space' => $space
]);
}
public function destroy(Request $request, int $id)
{
$space = Space::findOrFail($id);
$request->validate([
'domain' => [
'required',
Rule::in(['first-zone', $space->domain]),
]
]);
$space->delete();
return redirect()->route('admin.spaces.index');
}
}

View file

@ -30,7 +30,7 @@ use App\ContactsList;
use App\Http\Requests\Account\Create\Api\AsAdminRequest;
use App\Http\Requests\Account\Update\Api\AsAdminRequest as ApiAsAdminRequest;
use App\Services\AccountService;
use App\SipDomain;
use App\Space;
class AccountController extends Controller
{
@ -139,14 +139,15 @@ class AccountController extends Controller
public function store(AsAdminRequest $request)
{
// Create the missing SipDomain
if ($request->user()->superAdmin
// Create the missing Space
/*if ($request->user()->superAdmin
&& $request->has('domain')
&& !SipDomain::pluck('domain')->contains($request->get('domain'))) {
$sipDomain = new SipDomain();
$sipDomain->domain = $request->get('domain');
$sipDomain->save();
}
&& !Space::pluck('domain')->contains($request->get('domain'))) {
$space = new Space();
$space->domain = $request->get('domain');
$space->host = $request->get('host');
$space->save();
}*/
return (new AccountService())->store($request)->makeVisible(['confirmation_key', 'provisioning_token']);
}

View file

@ -1,111 +0,0 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api\Admin;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\SipDomain;
class SipDomainController extends Controller
{
public function index()
{
return SipDomain::all();
}
public function store(Request $request)
{
$request->validate([
'domain' => 'required|unique:sip_domains',
]);
$sipDomain = new SipDomain;
$sipDomain->domain = $request->get('domain');
$this->setRequestBoolean($request, $sipDomain, 'super');
$this->setRequestBoolean($request, $sipDomain, 'disable_chat_feature');
$this->setRequestBoolean($request, $sipDomain, 'disable_meetings_feature');
$this->setRequestBoolean($request, $sipDomain, 'disable_broadcast_feature');
$this->setRequestBoolean($request, $sipDomain, 'hide_settings');
$this->setRequestBoolean($request, $sipDomain, 'hide_account_settings');
$this->setRequestBoolean($request, $sipDomain, 'disable_call_recordings_feature');
$this->setRequestBoolean($request, $sipDomain, 'only_display_sip_uri_username');
$this->setRequestBoolean($request, $sipDomain, 'assistant_hide_create_account');
$this->setRequestBoolean($request, $sipDomain, 'assistant_disable_qr_code');
$this->setRequestBoolean($request, $sipDomain, 'assistant_hide_third_party_account');
$sipDomain->max_account = $request->get('max_account', 0);
$sipDomain->save();
return $sipDomain->refresh();
}
public function show(string $domain)
{
return SipDomain::where('domain', $domain)->firstOrFail();
}
public function update(Request $request, string $domain)
{
$request->validate([
'super' => 'required|boolean',
'disable_chat_feature' => 'required|boolean',
'disable_meetings_feature' => 'required|boolean',
'disable_broadcast_feature' => 'required|boolean',
'hide_settings' => 'required|boolean',
'hide_account_settings' => 'required|boolean',
'disable_call_recordings_feature' => 'required|boolean',
'only_display_sip_uri_username' => 'required|boolean',
'assistant_hide_create_account' => 'required|boolean',
'assistant_disable_qr_code' => 'required|boolean',
'assistant_hide_third_party_account' => 'required|boolean',
'max_account' => 'required|integer',
]);
$sipDomain = SipDomain::where('domain', $domain)->firstOrFail();
$sipDomain->super = $request->get('super');
$sipDomain->disable_chat_feature = $request->get('disable_chat_feature');
$sipDomain->disable_meetings_feature = $request->get('disable_meetings_feature');
$sipDomain->disable_broadcast_feature = $request->get('disable_broadcast_feature');
$sipDomain->hide_settings = $request->get('hide_settings');
$sipDomain->hide_account_settings = $request->get('hide_account_settings');
$sipDomain->disable_call_recordings_feature = $request->get('disable_call_recordings_feature');
$sipDomain->only_display_sip_uri_username = $request->get('only_display_sip_uri_username');
$sipDomain->assistant_hide_create_account = $request->get('assistant_hide_create_account');
$sipDomain->assistant_disable_qr_code = $request->get('assistant_disable_qr_code');
$sipDomain->assistant_hide_third_party_account = $request->get('assistant_hide_third_party_account');
$sipDomain->max_account = $request->get('max_account', 0);
$sipDomain->save();
return $sipDomain;
}
private function setRequestBoolean(Request $request, SipDomain $sipDomain, string $key)
{
if ($request->has($key)) {
$sipDomain->$key = (bool)$request->get($key);
}
}
public function destroy(string $domain)
{
return SipDomain::where('domain', $domain)->delete();
}
}

View file

@ -0,0 +1,134 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api\Admin;
use App\Space;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class SpaceController extends Controller
{
public function index()
{
return Space::all();
}
public function store(Request $request)
{
$request->validate([
'domain' => 'required|unique:spaces',
'host' => 'required|unique:spaces',
'max_accounts' => 'nullable|integer',
'expire_at' => 'nullable|date|after_or_equal:today'
]);
$space = new Space;
$space->domain = $request->get('domain');
$space->host = $request->get('host');
$this->setRequestBoolean($request, $space, 'super');
$this->setRequestBoolean($request, $space, 'disable_chat_feature');
$this->setRequestBoolean($request, $space, 'disable_meetings_feature');
$this->setRequestBoolean($request, $space, 'disable_broadcast_feature');
$this->setRequestBoolean($request, $space, 'hide_settings');
$this->setRequestBoolean($request, $space, 'hide_account_settings');
$this->setRequestBoolean($request, $space, 'disable_call_recordings_feature');
$this->setRequestBoolean($request, $space, 'only_display_sip_uri_username');
$this->setRequestBoolean($request, $space, 'assistant_hide_create_account');
$this->setRequestBoolean($request, $space, 'assistant_disable_qr_code');
$this->setRequestBoolean($request, $space, 'assistant_hide_third_party_account');
$space->max_account = $request->get('max_account', 0);
$space->max_accounts = $request->get('max_accounts', 0);
$space->expire_at = $request->get('expire_at');
$space->save();
return $space->refresh();
}
public function show(string $domain)
{
return Space::where('domain', $domain)->firstOrFail();
}
public function update(Request $request, string $domain)
{
$request->validate([
'super' => 'required|boolean',
'disable_chat_feature' => 'required|boolean',
'disable_meetings_feature' => 'required|boolean',
'disable_broadcast_feature' => 'required|boolean',
'hide_settings' => 'required|boolean',
'hide_account_settings' => 'required|boolean',
'disable_call_recordings_feature' => 'required|boolean',
'only_display_sip_uri_username' => 'required|boolean',
'assistant_hide_create_account' => 'required|boolean',
'assistant_disable_qr_code' => 'required|boolean',
'assistant_hide_third_party_account' => 'required|boolean',
'max_account' => 'required|integer',
'max_accounts' => 'required|integer',
'expire_at' => 'nullable|date|after_or_equal:today',
]);
$space = Space::where('domain', $domain)->firstOrFail();
if ($request->get('max_accounts') > 0) {
$request->validate([
'max_accounts' => 'integer|min:' . $space->accounts()->count()
]);
}
$request->validate([
'host' => ['required', Rule::unique('spaces')->ignore($space->id)]
]);
$space->host = $request->get('host');
$space->super = $request->get('super');
$space->disable_chat_feature = $request->get('disable_chat_feature');
$space->disable_meetings_feature = $request->get('disable_meetings_feature');
$space->disable_broadcast_feature = $request->get('disable_broadcast_feature');
$space->hide_settings = $request->get('hide_settings');
$space->hide_account_settings = $request->get('hide_account_settings');
$space->disable_call_recordings_feature = $request->get('disable_call_recordings_feature');
$space->only_display_sip_uri_username = $request->get('only_display_sip_uri_username');
$space->assistant_hide_create_account = $request->get('assistant_hide_create_account');
$space->assistant_disable_qr_code = $request->get('assistant_disable_qr_code');
$space->assistant_hide_third_party_account = $request->get('assistant_hide_third_party_account');
$space->max_account = $request->get('max_account', 0);
$space->max_accounts = $request->get('max_accounts', 0);
$space->expire_at = $request->get('expire_at');
$space->save();
return $space;
}
private function setRequestBoolean(Request $request, Space $space, string $key)
{
if ($request->has($key)) {
$space->$key = (bool)$request->get($key);
}
}
public function destroy(string $domain)
{
return Space::where('domain', $domain)->delete();
}
}

View file

@ -36,6 +36,7 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\Space::class
];
/**
@ -59,6 +60,7 @@ class Kernel extends HttpKernel
'bindings',
'validate_json',
'localization',
'space'
],
];
@ -87,6 +89,8 @@ class Kernel extends HttpKernel
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'space' => \App\Http\Middleware\Space::class,
'space.expired' => \App\Http\Middleware\IsSpaceExpired::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'localization' => \App\Http\Middleware\Localization::class,

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IsSpaceExpired
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && !$request->user()->superAdmin && $request->get('resolvedSpace')?->isExpired()) {
abort(403, 'The related Space has expired');
}
return $next($request);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Symfony\Component\HttpFoundation\Response;
class Space
{
public function handle(Request $request, Closure $next): Response
{
if (empty(config('app.root_domain'))) {
return abort(503, 'APP_ROOT_DOMAIN is not configured');
}
$space = \App\Space::where('host', $request->header('host'))->first();
if ($space) {
if (!str_ends_with($space->host, config('app.root_domain'))) {
return abort(503, 'The APP_ROOT_DOMAIN configured does not match with the current root domain');
}
Config::set('app.url', '://' . $space->host);
Config::set('app.sip_domain', $space->domain);
$request->request->set('resolvedSpace', $space);
return $next($request);
}
return abort(404, 'Host not configured');
}
}

View file

@ -29,11 +29,6 @@ class AsAdminRequest extends Request
{
use RequestsApi, AsAdmin;
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();
@ -46,10 +41,6 @@ class AsAdminRequest extends Request
'nullable',
];
if ($this->user()->superAdmin) {
$rules['domain'] = '';
}
if (config('app.allow_phone_number_username_admin_api') == true) {
array_splice(
$rules['username'],

View file

@ -28,11 +28,6 @@ class Request extends CreateRequest
{
use RequestsApi;
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();

View file

@ -23,6 +23,7 @@ use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Account;
use App\Space;
use App\Rules\BlacklistedUsername;
use App\Rules\Dictionary;
use App\Rules\FilteredPhone;
@ -34,7 +35,8 @@ class Request extends FormRequest
{
public function authorize()
{
return true;
$space = Space::where('domain', resolveDomain($this))->first();
return ($space && !$space->isFull());
}
public function rules()
@ -54,7 +56,7 @@ class Request extends FormRequest
}),
'filled',
],
'domain' => 'exists:sip_domains,domain',
'domain' => 'exists:spaces,domain',
'dictionary' => [new Dictionary()],
'password' => 'required|min:3',
'email' => config('app.account_email_unique')

View file

@ -27,11 +27,6 @@ class AsAdminRequest extends CreateRequest
{
use AsAdmin;
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();

View file

@ -23,11 +23,6 @@ use App\Http\Requests\Account\Create\Request as CreateRequest;
class Request extends CreateRequest
{
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();

View file

@ -29,11 +29,6 @@ class AsAdminRequest extends UpdateRequest
{
use RequestsApi, AsAdmin;
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();

View file

@ -50,7 +50,7 @@ class Request extends FormRequest
})->ignore($this->route('account_id'), 'id'),
'filled',
],
'domain' => 'exists:sip_domains,domain',
'domain' => 'exists:spaces,domain',
'email' => [
'nullable',
'email',

View file

@ -27,11 +27,6 @@ class AsAdminRequest extends UpdateRequest
{
use AsAdmin;
public function authorize()
{
return true;
}
public function rules()
{
$rules = parent::rules();

View file

@ -15,11 +15,5 @@ class AppServiceProvider extends ServiceProvider
public function boot()
{
Validator::extend('iso_date', 'validateIsoDate');
if (!empty(config('app.url'))) {
// Add following lines to force laravel to use APP_URL as root url for the app.
$strBaseURL = $this->app['url'];
$strBaseURL->forceRootUrl(config('app.url'));
}
}
}

View file

@ -30,8 +30,6 @@ class RouteServiceProvider extends ServiceProvider
*/
public function boot()
{
//
parent::boot();
}
@ -43,10 +41,7 @@ class RouteServiceProvider extends ServiceProvider
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
//
}
/**

View file

@ -1,31 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SipDomain extends Model
{
use HasFactory;
protected $hidden = ['id'];
protected $casts = [
'super' => 'boolean',
'disable_chat_feature' => 'boolean',
'disable_meetings_feature' => 'boolean',
'disable_broadcast_feature' => 'boolean',
'hide_settings' => 'boolean',
'hide_account_settings' => 'boolean',
'disable_call_recordings_feature' => 'boolean',
'only_display_sip_uri_username' => 'boolean',
'assistant_hide_create_account' => 'boolean',
'assistant_disable_qr_code' => 'boolean',
'assistant_hide_third_party_account' => 'boolean',
];
public function accounts()
{
return $this->hasMany(Account::class, 'domain', 'domain');
}
}

89
flexiapi/app/Space.php Normal file
View file

@ -0,0 +1,89 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class Space extends Model
{
use HasFactory;
protected $hidden = ['id'];
protected $casts = [
'super' => 'boolean',
'disable_chat_feature' => 'boolean',
'disable_meetings_feature' => 'boolean',
'disable_broadcast_feature' => 'boolean',
'hide_settings' => 'boolean',
'hide_account_settings' => 'boolean',
'disable_call_recordings_feature' => 'boolean',
'only_display_sip_uri_username' => 'boolean',
'assistant_hide_create_account' => 'boolean',
'assistant_disable_qr_code' => 'boolean',
'assistant_hide_third_party_account' => 'boolean',
'expire_at' => 'date',
];
public const HOST_REGEX = '[\w\-]+';
public const DOMAIN_REGEX = '[\w\-\.]+';
public function accounts()
{
return $this->hasMany(Account::class, 'domain', 'domain');
}
public function admins()
{
return $this->accounts()->where('admin', true);
}
public function scopeNotFull(Builder $query)
{
return $query->where('max_accounts', 0)
->orWhereRaw('max_accounts > (select count(*) from accounts where domain = spaces.domain)');
}
public function getAccountsPercentageAttribute(): int
{
if ($this->max_accounts != null) {
return (int)($this->accounts()->count() / $this->max_accounts * 100);
}
return 0;
}
public function isFull(): bool
{
return $this->max_accounts > 0 && ($this->accounts()->count() >= $this->max_accounts);
}
public function isExpired(): bool
{
return $this->expire_at && $this->expire_at->isPast();
}
public function getAccountsPercentageClassAttribute(): string
{
if ($this->getAccountsPercentageAttribute() >= 80) {
return 'orange';
}
if ($this->getAccountsPercentageAttribute() >= 60) {
return 'yellow';
}
return 'green';
}
public function getDaysLeftAttribute(): ?int
{
if ($this->expire_at != null) {
return (int)$this->expire_at->diffInDays(Carbon::now());
}
return null;
}
}

106
flexiapi/composer.lock generated
View file

@ -1191,16 +1191,16 @@
},
{
"name": "fakerphp/faker",
"version": "v1.24.0",
"version": "v1.24.1",
"source": {
"type": "git",
"url": "https://github.com/FakerPHP/Faker.git",
"reference": "a136842a532bac9ecd8a1c723852b09915d7db50"
"reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/FakerPHP/Faker/zipball/a136842a532bac9ecd8a1c723852b09915d7db50",
"reference": "a136842a532bac9ecd8a1c723852b09915d7db50",
"url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
"reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
"shasum": ""
},
"require": {
@ -1248,9 +1248,9 @@
],
"support": {
"issues": "https://github.com/FakerPHP/Faker/issues",
"source": "https://github.com/FakerPHP/Faker/tree/v1.24.0"
"source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
},
"time": "2024-11-07T15:11:20+00:00"
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "fruitcake/php-cors",
@ -1325,16 +1325,16 @@
},
{
"name": "giggsey/libphonenumber-for-php-lite",
"version": "8.13.49",
"version": "8.13.50",
"source": {
"type": "git",
"url": "https://github.com/giggsey/libphonenumber-for-php-lite.git",
"reference": "f2bc102ccd614fde128b463273ce9843e14bf430"
"reference": "57bb2bfd8d4a9896ed961c584141247f2a35bc04"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/f2bc102ccd614fde128b463273ce9843e14bf430",
"reference": "f2bc102ccd614fde128b463273ce9843e14bf430",
"url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/57bb2bfd8d4a9896ed961c584141247f2a35bc04",
"reference": "57bb2bfd8d4a9896ed961c584141247f2a35bc04",
"shasum": ""
},
"require": {
@ -1404,7 +1404,7 @@
"issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues",
"source": "https://github.com/giggsey/libphonenumber-for-php-lite"
},
"time": "2024-11-04T10:42:41+00:00"
"time": "2024-11-18T09:58:30+00:00"
},
{
"name": "graham-campbell/result-type",
@ -1881,16 +1881,16 @@
},
{
"name": "laravel/framework",
"version": "v10.48.23",
"version": "v10.48.25",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "625269ca4881d2b50eded2045cb930960a181d98"
"reference": "f132b23b13909cc22c615c01b0c5640541c3da0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/625269ca4881d2b50eded2045cb930960a181d98",
"reference": "625269ca4881d2b50eded2045cb930960a181d98",
"url": "https://api.github.com/repos/laravel/framework/zipball/f132b23b13909cc22c615c01b0c5640541c3da0c",
"reference": "f132b23b13909cc22c615c01b0c5640541c3da0c",
"shasum": ""
},
"require": {
@ -1997,7 +1997,7 @@
"nyholm/psr7": "^1.2",
"orchestra/testbench-core": "^8.23.4",
"pda/pheanstalk": "^4.0",
"phpstan/phpstan": "^1.4.7",
"phpstan/phpstan": "~1.11.11",
"phpunit/phpunit": "^10.0.7",
"predis/predis": "^2.0.2",
"symfony/cache": "^6.2",
@ -2084,7 +2084,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2024-11-12T15:39:10+00:00"
"time": "2024-11-26T15:32:57+00:00"
},
{
"name": "laravel/prompts",
@ -2146,16 +2146,16 @@
},
{
"name": "laravel/serializable-closure",
"version": "v1.3.6",
"version": "v1.3.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
"reference": "f865a58ea3a0107c336b7045104c75243fa59d96"
"reference": "4f48ade902b94323ca3be7646db16209ec76be3d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/f865a58ea3a0107c336b7045104c75243fa59d96",
"reference": "f865a58ea3a0107c336b7045104c75243fa59d96",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d",
"reference": "4f48ade902b94323ca3be7646db16209ec76be3d",
"shasum": ""
},
"require": {
@ -2203,7 +2203,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
"time": "2024-11-11T17:06:04+00:00"
"time": "2024-11-14T18:34:49+00:00"
},
{
"name": "laravel/tinker",
@ -3394,32 +3394,32 @@
},
{
"name": "nunomaduro/termwind",
"version": "v1.16.0",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/termwind.git",
"reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257"
"reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dcf1ec3dfa36137b7ce41d43866644a7ab8fc257",
"reference": "dcf1ec3dfa36137b7ce41d43866644a7ab8fc257",
"url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301",
"reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.1",
"symfony/console": "^6.4.12"
"symfony/console": "^6.4.15"
},
"require-dev": {
"illuminate/console": "^10.48.22",
"illuminate/support": "^10.48.22",
"laravel/pint": "^1.18.1",
"pestphp/pest": "^2",
"illuminate/console": "^10.48.24",
"illuminate/support": "^10.48.24",
"laravel/pint": "^1.18.2",
"pestphp/pest": "^2.36.0",
"pestphp/pest-plugin-mock": "2.0.0",
"phpstan/phpstan": "^1.12.6",
"phpstan/phpstan": "^1.12.11",
"phpstan/phpstan-strict-rules": "^1.6.1",
"symfony/var-dumper": "^6.4.11",
"symfony/var-dumper": "^6.4.15",
"thecodingmachine/phpstan-strict-rules": "^1.0.0"
},
"type": "library",
@ -3459,7 +3459,7 @@
],
"support": {
"issues": "https://github.com/nunomaduro/termwind/issues",
"source": "https://github.com/nunomaduro/termwind/tree/v1.16.0"
"source": "https://github.com/nunomaduro/termwind/tree/v1.17.0"
},
"funding": [
{
@ -3475,7 +3475,7 @@
"type": "github"
}
],
"time": "2024-10-15T15:27:12+00:00"
"time": "2024-11-21T10:36:35+00:00"
},
{
"name": "ovh/ovh",
@ -5599,16 +5599,16 @@
},
{
"name": "respect/validation",
"version": "2.3.7",
"version": "2.3.8",
"source": {
"type": "git",
"url": "https://github.com/Respect/Validation.git",
"reference": "967f7b6cc71e3728bb0f766cc1aea0604b2955aa"
"reference": "25ce44c7ee9613d260c7c0e44e27daa2131f383a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Respect/Validation/zipball/967f7b6cc71e3728bb0f766cc1aea0604b2955aa",
"reference": "967f7b6cc71e3728bb0f766cc1aea0604b2955aa",
"url": "https://api.github.com/repos/Respect/Validation/zipball/25ce44c7ee9613d260c7c0e44e27daa2131f383a",
"reference": "25ce44c7ee9613d260c7c0e44e27daa2131f383a",
"shasum": ""
},
"require": {
@ -5661,9 +5661,9 @@
],
"support": {
"issues": "https://github.com/Respect/Validation/issues",
"source": "https://github.com/Respect/Validation/tree/2.3.7"
"source": "https://github.com/Respect/Validation/tree/2.3.8"
},
"time": "2024-04-13T09:45:55+00:00"
"time": "2024-11-26T09:14:36+00:00"
},
{
"name": "sabre/uri",
@ -9220,16 +9220,16 @@
},
{
"name": "voku/portable-ascii",
"version": "2.0.1",
"version": "2.0.3",
"source": {
"type": "git",
"url": "https://github.com/voku/portable-ascii.git",
"reference": "b56450eed252f6801410d810c8e1727224ae0743"
"reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743",
"reference": "b56450eed252f6801410d810c8e1727224ae0743",
"url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
"reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
"shasum": ""
},
"require": {
@ -9254,7 +9254,7 @@
"authors": [
{
"name": "Lars Moelleken",
"homepage": "http://www.moelleken.org/"
"homepage": "https://www.moelleken.org/"
}
],
"description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
@ -9266,7 +9266,7 @@
],
"support": {
"issues": "https://github.com/voku/portable-ascii/issues",
"source": "https://github.com/voku/portable-ascii/tree/2.0.1"
"source": "https://github.com/voku/portable-ascii/tree/2.0.3"
},
"funding": [
{
@ -9290,7 +9290,7 @@
"type": "tidelift"
}
],
"time": "2022-03-08T17:03:00+00:00"
"time": "2024-11-21T01:49:47+00:00"
},
{
"name": "webmozart/assert",
@ -9946,16 +9946,16 @@
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.11.0",
"version": "3.11.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
"reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450"
"reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/70c08f8d20c0eb4fe56f26644dd94dae76a7f450",
"reference": "70c08f8d20c0eb4fe56f26644dd94dae76a7f450",
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
"reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87",
"shasum": ""
},
"require": {
@ -10022,7 +10022,7 @@
"type": "open_collective"
}
],
"time": "2024-11-12T09:53:29+00:00"
"time": "2024-11-16T12:02:36+00:00"
},
{
"name": "symfony/config",

View file

@ -127,7 +127,7 @@ return [
*/
'url' => env('APP_URL', 'http://localhost'),
'root_domain' => env('APP_ROOT_DOMAIN', null),
'asset_url' => env('ASSET_URL', null),
/*

View file

@ -66,19 +66,10 @@ return [
*/
'providers' => [
/*'users' => [
'driver' => 'eloquent',
'model' => App\User::class,
],*/
'users' => [
'driver' => 'eloquent',
'model' => App\Account::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*

View file

@ -26,7 +26,7 @@ use Awobaz\Compoships\Database\Eloquent\Factories\ComposhipsFactory;
use App\Account;
use App\AccountCreationToken;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\SipDomain;
use App\Space;
class AccountFactory extends Factory
{
@ -35,9 +35,9 @@ class AccountFactory extends Factory
public function definition()
{
$domain = SipDomain::count() == 0
? SipDomain::factory()->create()
: SipDomain::first();
$domain = Space::count() == 0
? Space::factory()->create()
: Space::first();
return [
'username' => $this->faker->username,
@ -53,6 +53,13 @@ class AccountFactory extends Factory
];
}
public function fromSpace(Space $space)
{
return $this->state(fn (array $attributes) => [
'domain' => $space->domain
]);
}
public function admin()
{
return $this->state(fn (array $attributes) => [
@ -63,9 +70,9 @@ class AccountFactory extends Factory
public function superAdmin()
{
return $this->state(function (array $attributes) {
$sipDomain = SipDomain::where('domain', $attributes['domain'])->first();
$sipDomain->super = true;
$sipDomain->save();
$space = Space::where('domain', $attributes['domain'])->first();
$space->super = true;
$space->save();
return [
'admin' => true,

View file

@ -19,17 +19,19 @@
namespace Database\Factories;
use App\SipDomain;
use App\Space;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\Factory;
class SipDomainFactory extends Factory
class SpaceFactory extends Factory
{
protected $model = SipDomain::class;
protected $model = Space::class;
public function definition()
{
return [
'domain' => config('app.sip_domain'),
'host' => config('app.sip_domain'),
];
}
@ -37,6 +39,14 @@ class SipDomainFactory extends Factory
{
return $this->state(fn (array $attributes) => [
'domain' => 'second_' . config('app.sip_domain'),
'host' => 'second_' . config('app.sip_domain'),
]);
}
public function expired()
{
return $this->state(fn (array $attributes) => [
'expire_at' => Carbon::today()->toDateTimeString()
]);
}
}

View file

@ -1,6 +1,6 @@
<?php
use App\SipDomain;
use App\Space;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
@ -18,10 +18,10 @@ return new class extends Migration
});
foreach (DB::table('accounts')->select('domain')->distinct()->get()->pluck('domain') as $domain) {
$sipDomain = new SipDomain;
$sipDomain->domain = $domain;
$sipDomain->super = env('APP_ADMINS_MANAGE_MULTI_DOMAINS', false); // historical environnement boolean
$sipDomain->save();
$space = new Space;
$space->domain = $domain;
$space->super = env('APP_ADMINS_MANAGE_MULTI_DOMAINS', false); // historical environnement boolean
$space->save();
}
Schema::table('accounts', function (Blueprint $table) {

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Space;
return new class extends Migration
{
public function up(): void
{
Schema::rename('sip_domains', 'spaces');
Schema::table('spaces', function (Blueprint $table) {
$table->string('host')->unique()->nullable();
$table->integer('max_accounts')->default(0);
$table->datetime('expire_at')->nullable();
});
DB::statement("update spaces set host = domain");
Schema::table('spaces', function (Blueprint $table) {
$table->string('host')->nullable(false)->change();
});
}
public function down(): void
{
Schema::rename('spaces', 'sip_domains');
Schema::table('sip_domains', function (Blueprint $table) {
$table->dropColumn('host');
$table->dropColumn('max_accounts');
$table->dropColumn('expire_at');
});
}
};

View file

@ -3,7 +3,7 @@
namespace Database\Seeders;
use App\Account;
use App\SipDomain;
use App\Space;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
@ -92,9 +92,9 @@ class LiblinphoneTesterAccoutSeeder extends Seeder
// Create the domains
foreach ($domains as $domain) {
$sipDomain = SipDomain::where('domain', $domain)->firstOrNew();
$sipDomain->domain = $domain;
$sipDomain->save();
$space = Space::where('domain', $domain)->firstOrNew();
$space->domain = $domain;
$space->save();
}
// And seed the fresh ones

View file

@ -7,12 +7,18 @@
border: 1px solid var(--main-5);
border-radius: 3rem;
font-size: 1.5rem;
line-height: 2rem;
padding: 1rem 2rem;
line-height: 4rem;
padding: 0 2rem;
color: white;
white-space: nowrap;
}
.btn.small {
line-height: 3rem;
padding: 0rem 2rem;
font-size: 1.25rem;
}
p .btn {
margin: 0 1rem;
}
@ -94,7 +100,6 @@ form.inline {
form div {
position: relative;
min-height: 4rem;
}
form div .btn,
@ -202,10 +207,6 @@ form div select {
-webkit-appearance: none;
}
form div.checkbox {
min-height: 2rem;
}
form div.search:after,
form div.select:after {
font-family: 'Phosphor';
@ -228,6 +229,15 @@ form div.search:after {
content: "\e30c";
}
form div span.supporting {
line-height: 2rem;
font-size: 1.25rem;
color: var(--grey-4);
display: block;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
form div input[disabled],
form div textarea[disabled] {
border-color: var(--grey-4);
@ -324,3 +334,66 @@ form div input:invalid:not(:placeholder-shown)+label,
form div textarea:invalid:not(:placeholder-shown)+label {
color: var(--danger-5);
}
/* Checkbox element */
form div.checkbox {
padding: 0.5rem 0;
display: flex;
align-items: center;
}
form div.checkbox > div {
margin-right: 5rem;
}
div.checkbox > input[type="checkbox"] {
display: none;
width: 0;
height: 0;
}
div.checkbox p {
margin-bottom: 0;
}
div.checkbox::before {
content: '';
display: block;
position: absolute;
height: 3rem;
width: 5rem;
background-color: var(--grey-3);
box-sizing: border-box;
transition: background-color 0.3s ease;
top: calc(50% - 1.5rem);
right: 0;
border-radius: 4rem;
}
div.checkbox:has(> input[type="checkbox"]:checked)::before {
background-color: var(--color-green);
}
div.checkbox > input[type="checkbox"] + label {
display: block;
background-color: white;
width: 2rem;
height: 2rem;
border-radius: 2rem;
top: calc(50% - 1rem);
right: 2.5rem;
position: absolute;
font-size: 2rem;
text-align: center;
line-height: normal;
transition: right 0.3s ease;
}
div.checkbox:hover > input[type="checkbox"] + label {
cursor: pointer;
}
div.checkbox > input[type="checkbox"]:checked + label {
right: 0.5rem;
}

View file

@ -340,28 +340,42 @@ content section {
box-sizing: border-box;
}
content section.grid {
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.grid.third {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 800px) {
content section.grid {
.grid {
display: block;
}
.grid.third {
grid-template-columns: repeat(2, 1fr);
}
}
content section.grid header,
content section.grid .large {
.grid header,
.grid .large {
grid-column: span 2;
}
.grid.third header,
.grid.third .large {
grid-column: span 3;
}
content section header {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 3rem;
flex-flow: wrap;
}
content section header p {
@ -381,6 +395,12 @@ content nav+section {
margin-bottom: 4rem;
}
@media screen and (max-width: 800px) {
content nav+section {
min-width: 100%;
}
}
/** Sidenav **/
content>nav {
@ -417,7 +437,7 @@ content>nav a.current {
background-color: white;
border-radius: 4rem;
color: var(--main-5);
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.2);
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.2);
}
content>nav a i {
@ -758,11 +778,16 @@ select.list_toggle {
.card {
background-color: var(--grey-1);
border-radius: 1rem;
padding: 1rem;
padding: 1.5rem;
margin-bottom: 1rem;
overflow: hidden;
}
.card .icon {
font-size: 3rem;
color: var(--second-6);
}
.disabled {
opacity: 0.5;
pointer-events: none;
@ -805,6 +830,28 @@ select.list_toggle {
display: inline-block;
}
/* Progress */
progress {
width: 100%;
border-radius: 2rem;
}
progress::-webkit-progress-bar,
progress::-moz-progress-bar {
background-color: var(--color-green);
}
progress.yellow::-webkit-progress-bar,
progress.yellow::-moz-progress-bar {
background-color: var(--color-yellow);
}
progress.orange::-webkit-progress-bar,
progress.orange::-moz-progress-bar {
background-color: var(--color-orange);
}
/* Steps */
ol.steps {

View file

@ -115,3 +115,15 @@ function digitFilled(element) {
element.nextElementSibling.focus();
}
}
function copyValueTo(from, to, append) {
if (to.value == '') {
let value = from.value;
if (append) {
value += append;
}
to.value = value;
}
}

View file

@ -2,7 +2,7 @@
@section('content')
<header>
<h1><i class="ph">gauge</i> Dashboard</h1>
<h1><i class="ph">gauge</i> My Account</h1>
</header>
<div class="card">
@ -64,7 +64,7 @@
<p><i class="ph">envelope</i> SIP address: sip:{{ $account->identifier }}</p>
<p><i class="ph">user</i> Username: {{ $account->username }}</p>
<p><i class="ph">hard-drives</i> Domain: {{ $account->domain }}</p>
<p><i class="ph">globe-hemisphere-west</i> Domain: {{ $account->domain }}</p>
@if (!empty(config('app.proxy_registrar_address')))
<p><i class="ph">lan</i> Proxy/registrar address: sip:{{ config('app.proxy_registrar_address') }}

View file

@ -48,9 +48,9 @@
</div>
<div class="select">
<select name="domain" @if (auth()->user()?->superAdmin) required @else disabled @endif>
@foreach ($domains as $sipDomain)
<option value="{{ $sipDomain->domain }}" @if ($account->domain == $sipDomain->domain) selected="selected" @endif>
{{ $sipDomain->domain }}</option>
@foreach ($domains as $space)
<option value="{{ $space->domain }}" @if ($account->domain == $space->domain) selected="selected" @endif>
{{ $space->domain }}</option>
@endforeach
</select>
<label for="domain">Domain</label>

View file

@ -9,6 +9,9 @@
@section('content')
<header>
<h1><i class="ph">users</i> Accounts</h1>
@if ($space)
<p>{{ $accounts->count()}} / @if ($space->max_accounts > 0){{ $space->max_accounts }} @else <i class="ph">infinity</i>@endif</p>
@endif
<a class="btn btn-secondary oppose" href="{{ route('admin.account.import.create') }}">
<i class="ph">download-simple</i>
Import Accounts

View file

@ -1,73 +0,0 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.sip_domains.index') }}">SIP Domains</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Edit</li>
@endsection
@section('content')
<header>
@if ($sip_domain->id)
<h1><i class="material-symbols-outlined">hard-drives</i> {{ $sip_domain->domain }}</h1>
<a href="{{ route('admin.sip_domains.index') }}" class="btn btn-secondary oppose">Cancel</a>
<a class="btn btn-secondary" href="{{ route('admin.sip_domains.delete', $sip_domain->id) }}">
<i class="ph">trash</i>
Delete
</a>
<input form="create_edit_sip_domains" class="btn" type="submit" value="Update">
@else
<h1><i class="ph">user-rectangle</i> Create a SIP Domain</h1>
<a href="{{ route('admin.sip_domains.index') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="create_edit_sip_domains" class="btn" type="submit" value="Create">
@endif
</header>
<form method="POST" id="create_edit_sip_domains"
action="{{ $sip_domain->id ? route('admin.sip_domains.update', $sip_domain->id) : route('admin.sip_domains.store') }}"
accept-charset="UTF-8">
@csrf
@method($sip_domain->id ? 'put' : 'post')
@if (!$sip_domain->id)
<div>
<input placeholder="Name" required="required" name="domain" type="text"
value="{{ $sip_domain->domain ?? old('domain') }}">
<label for="username">Domain</label>
@include('parts.errors', ['name' => 'domain'])
</div>
@endif
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'super', 'label' => 'Super domain'])
<h3 class="large">Features</h3>
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'disable_chat_feature', 'label' => 'Chat feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'disable_meetings_feature', 'label' => 'Meeting feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'disable_broadcast_feature', 'label' => 'Conference feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'hide_settings', 'label' => 'General settings', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'hide_account_settings', 'label' => 'Account settings', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'disable_call_recordings_feature', 'label' => 'Record audio/video calls', 'reverse' => true])
<h3 class="large">General toggles</h3>
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'only_display_sip_uri_username', 'label' => 'Only display usernames (hide SIP addresses)'])
<div class="select">
<select name="max_account">
@foreach ([0 => 'No limit', 1 => 'One', 2 => 'Two', 3 => 'Three', 4 => 'Four', 5 => 'Five'] as $key => $value)
<option value="{{ $key }}" @if ($sip_domain->max_account == $key) selected="selected" @endif>
{{ $value }}</option>
@endforeach
</select>
<label for="domain">Max account authorized</label>
</div>
<h3 class="large">Assistant</h3>
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'assistant_hide_create_account', 'label' => 'Account creation panel', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'assistant_disable_qr_code', 'label' => 'QR Code scanning panel', 'reverse' => true])
@include('parts.form.toggle', ['object' => $sip_domain, 'key' => 'assistant_hide_third_party_account', 'label' => 'Third party SIP panel', 'reverse' => true])
</form>
@endsection

View file

@ -1,43 +0,0 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item" aria-current="page">
SIP Domains
</li>
@endsection
@section('content')
<header>
<h1><i class="ph">hard-drives</i> SIP Domains</h1>
<a class="btn oppose" href="{{ route('admin.sip_domains.create') }}">
<i class="ph">plus</i>
New SIP Domain
</a>
</header>
<table>
<thead>
<tr>
<th>SIP Domain</th>
<th>Accounts</th>
</tr>
</thead>
<tbody>
@foreach ($sip_domains as $sip_domain)
<tr>
<td>
<a href="{{ route('admin.sip_domains.edit', $sip_domain->id) }}">
{{ $sip_domain->domain }}
@if ($sip_domain->super) <span class="badge badge-error" title="Super domain">Super</span> @endif
</a>
</td>
<td>
{{ $sip_domain->accounts_count }}
</td>
</tr>
@endforeach
</tbody>
</table>
@endsection

View file

@ -0,0 +1,46 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.spaces.index') }}">Spaces</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Create</li>
@endsection
@section('content')
<header>
<h1><i class="ph">user-rectangle</i> Create a Space</h1>
<a href="{{ route('admin.spaces.index') }}" class="btn btn-secondary oppose">Cancel</a>
</header>
<form method="POST"
action="{{ route('admin.spaces.store') }}"
accept-charset="UTF-8">
@csrf
@method('post')
<div class="large">
<input placeholder="subdomain" required="required" name="host" type="text" pattern="{{ $space::HOST_REGEX}}" style="width: 60%"
value="{{ $space->host ?? old('host') }}" onchange="copyValueTo(this, this.form.querySelector('input[name=domain]'), '.{{ config('app.root_domain') }}')">
<input placeholder=".{{ config('app.root_domain') }}" style="position: absolute; width: calc(40% - 1rem); margin-left: 1rem;" disabled>
<label for="username">Subdomain</label>
@include('parts.errors', ['name' => 'host'])
@include('parts.errors', ['name' => 'full_host'])
<span class="supporting">Cannot be changed once created</span>
</div>
<div class="large">
<input placeholder="domain.sip" required="required" name="domain" type="text" pattern="{{ $space::DOMAIN_REGEX}}" value="{{ $space->domain ?? old('domain') }}">
<label for="username">SIP Domain</label>
@include('parts.errors', ['name' => 'domain'])
<span class="supporting">Cannot be changed once created</span>
</div>
@include('parts.form.toggle', ['object' => $space, 'key' => 'super', 'label' => 'Super space', 'supporting' => 'All the admins in this Space will be Super Admins'])
<div class="large">
<input class="btn" type="submit" value="Create">
</div>
</form>
@endsection

View file

@ -2,27 +2,26 @@
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.sip_domains.index') }}">SIP Domains</a>
<a href="{{ route('admin.spaces.index') }}">Spaces</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Delete</li>
@endsection
@section('content')
<header>
<h1><i class="ph">trash</i> Delete a SIP Domain</h1>
<a href="{{ route('admin.sip_domains.edit', $sip_domain->id) }}" class="btn btn-secondary oppose">Cancel</a>
<input form="delete" class="btn" type="submit" value="Delete">
<h1><i class="ph">trash</i> Delete a Space</h1>
<a href="{{ route('admin.spaces.edit', $space->id) }}" class="btn btn-secondary oppose">Cancel</a>
</header>
<form id="delete" method="POST" action="{{ route('admin.sip_domains.destroy', $sip_domain->id) }}" accept-charset="UTF-8">
<form id="delete" method="POST" action="{{ route('admin.spaces.destroy', $space) }}" accept-charset="UTF-8">
@csrf
@method('delete')
<div class="large">
<p>You are going to permanently delete the following domain please confirm your action.</p>
<h3>{{ $sip_domain->domain }}</h3>
<p>This will also destroy <b>{{ $sip_domain->accounts()->count() }} related accounts</b></p>
<h3>{{ $space->domain }}</h3>
<p>This will also destroy <b>{{ $space->accounts()->count() }} related accounts</b></p>
<input name="sip_domain" type="hidden" value="{{ $sip_domain->id }}">
<input name="sip_domain" type="hidden" value="{{ $space->id }}">
</div>
<div>
@ -30,5 +29,9 @@
<label for="username">Please retype the domain here to confirm</label>
@include('parts.errors', ['name' => 'domain'])
</div>
<div class="large">
<input class="btn" type="submit" value="Delete">
</div>
</form>
@endsection

View file

@ -0,0 +1,57 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.spaces.index') }}">Spaces</a>
</li>
<li class="breadcrumb-item">{{ $space->host }}</li>
<li class="breadcrumb-item active" aria-current="page">Configuration</li>
@endsection
@section('content')
<header>
<h1><i class="ph">globe-hemisphere-west</i> {{ $space->host }}</h1>
</header>
@include('admin.space.tabs')
<form method="POST"
action="{{ route('admin.spaces.update', $space->id) }}"
accept-charset="UTF-8">
@csrf
@method('put')
<h3 class="large">Features</h3>
@include('parts.form.toggle', ['object' => $space, 'key' => 'disable_chat_feature', 'label' => 'Chat feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'disable_meetings_feature', 'label' => 'Meeting feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'disable_broadcast_feature', 'label' => 'Conference feature', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'hide_settings', 'label' => 'General settings', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'hide_account_settings', 'label' => 'Account settings', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'disable_call_recordings_feature', 'label' => 'Record audio/video calls', 'reverse' => true])
<h3 class="large">General toggles</h3>
@include('parts.form.toggle', ['object' => $space, 'key' => 'only_display_sip_uri_username', 'label' => 'Only display usernames (hide SIP addresses)'])
<div class="select">
<select name="max_account">
@foreach ([0 => 'No limit', 1 => 'One', 2 => 'Two', 3 => 'Three', 4 => 'Four', 5 => 'Five'] as $key => $value)
<option value="{{ $key }}" @if ($space->max_account == $key) selected="selected" @endif>
{{ $value }}</option>
@endforeach
</select>
<label for="domain">Max account authorized</label>
</div>
<h3 class="large">Assistant</h3>
@include('parts.form.toggle', ['object' => $space, 'key' => 'assistant_hide_create_account', 'label' => 'Account creation panel', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'assistant_disable_qr_code', 'label' => 'QR Code scanning panel', 'reverse' => true])
@include('parts.form.toggle', ['object' => $space, 'key' => 'assistant_hide_third_party_account', 'label' => 'Third party SIP panel', 'reverse' => true])
<div class="large">
<input class="btn" type="submit" value="Update">
</div>
</form>
@endsection

View file

@ -0,0 +1,55 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item" aria-current="page">
Spaces
</li>
@endsection
@section('content')
<header>
<h1><i class="ph">globe-hemisphere-west</i> Spaces</h1>
<a class="btn oppose" href="{{ route('admin.spaces.create') }}">
<i class="ph">plus</i>
New Space
</a>
</header>
<table>
<thead>
<tr>
<th>Space</th>
<th>SIP Domain</th>
<th>Accounts</th>
<th>Expiration</th>
</tr>
</thead>
<tbody>
@foreach ($spaces as $space)
<tr>
<td>
<a href="{{ route('admin.spaces.show', $space->id) }}">
{{ $space->host }}
@if ($space->super) <span class="badge badge-error" title="Super domain">Super</span> @endif
</a>
</td>
<td>{{ $space->domain }}</td>
<td>
{{ $space->accounts_count }} / @if ($space->max_accounts > 0){{ $space->max_accounts }} @else <i class="ph">infinity</i>@endif
</td>
<td>
@if ($space->isExpired())
Expired
@elseif ($space->expire_at)
In {{ $space->daysLeft }} days
@else
<i class="ph">infinity</i>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@endsection

View file

@ -0,0 +1,45 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.spaces.index') }}">Spaces</a>
</li>
<li class="breadcrumb-item">{{ $space->host }}</li>
<li class="breadcrumb-item active" aria-current="page">Parameters</li>
@endsection
@section('content')
<header>
<h1><i class="ph">globe-hemisphere-west</i> {{ $space->host }}</h1>
</header>
@include('admin.space.tabs')
<form method="POST"
action="{{ route('admin.spaces.parameters.update', $space) }}"
accept-charset="UTF-8">
@csrf
@method('put')
<div>
<input name="max_accounts" id="max_accounts" type="number" min="0" value="{{ $space->max_accounts }}">
<label for="max_accounts">Max accounts of the space</label>
<span class="supporting">Unlimited if set to 0</span>
@include('parts.errors', ['name' => 'max_accounts'])
</div>
<div>
<input name="expire_at" id="expire_at" type="date" @if ($space->expire_at) value="{{ $space->expire_at->toDateString() }}" @endif min="{{ \Carbon\Carbon::now()->toDateString() }}">
<label for="expire_at">Expire at</label>
<span class="supporting">Clear to never expire</span>
</div>
<div class="large">
@include('parts.form.toggle', ['object' => $space, 'key' => 'super', 'label' => 'Super space', 'supporting' => 'All the admins in this Space will be Super Admins'])
</div>
<div class="large">
<input class="btn" type="submit" value="Update">
</div>
</form>
@endsection

View file

@ -0,0 +1,82 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.spaces.index') }}">Spaces</a>
</li>
<li class="breadcrumb-item">{{ $space->host }}</li>
<li class="breadcrumb-item active" aria-current="page">Information</li>
@endsection
@section('content')
<header>
<h1><i class="ph">globe-hemisphere-west</i> {{ $space->host }}</h1>
<a class="btn btn-tertiary oppose" href="{{ route('admin.spaces.delete', $space->id) }}">
<i class="ph">trash</i>
Delete
</a>
<a class="btn btn-secondary" @if ($space->isFull())disabled @endif href="{{ route('admin.account.create', ['domain' => $space->domain]) }}">
<i class="ph">user-plus</i> New Account
</a>
</header>
@include('admin.space.tabs')
<div class="grid third ">
<div class="card">
<span class="icon"><i class="ph">users</i></span>
<h3>Accounts</h3>
@if ($space->max_accounts > 0)
<progress max="100" value="{{ $space->accountsPercentage }}"
class="{{ $space->accountsPercentageClass }}"></progress>
@endif
<p>
{{ $space->accounts()->count() }}
/
@if ($space->max_accounts > 0){{ $space->max_accounts }} @else <i class="ph">infinity</i>@endif
</p>
</div>
<div class="card">
<span class="icon"><i class="ph">clock</i></span>
<h3>Expiration</h3>
@if ($space->isExpired())
<p>Expired</p>
@elseif ($space->expire_at)
<p>In {{ $space->daysLeft }} days ({{ $space->expire_at->toDateString() }})</p>
@else
<p>Never expire</p>
@endif
</div>
</div>
<a class="btn btn-secondary oppose small" @if ($space->isFull())disabled @endif href="{{ route('admin.account.create', ['admin' => true, 'domain' => $space->domain]) }}"><i class="ph">user-plus</i> New Admin</a>
<h2>Admins</h2>
<table>
<thead>
<tr>
@include('parts.column_sort', ['uriParams' => ['space' => $space], 'key' => 'username', 'title' => 'Identifier'])
@include('parts.column_sort', ['uriParams' => ['space' => $space], 'key' => 'updated_at', 'title' => 'Updated'])
</tr>
</thead>
<tbody>
@if ($space->admins->isEmpty())
<tr class="empty">
<td colspan="4">No Admins</td>
</tr>
@endif
@foreach ($space->admins as $admin)
<tr>
<td>
<a href="{{ route('admin.account.edit', $admin->id) }}">
{{ $admin->identifier }}
</a>
</td>
<td>{{ $admin->updated_at }}</td>
</tr>
@endforeach
</tbody>
</table>
@endsection

View file

@ -0,0 +1,14 @@
@php
$items = [
route('admin.spaces.show', $space->id) => 'Information',
route('admin.spaces.edit', $space->id) => 'Configuration'
];
if (auth()->user()->superAdmin) {
$items[route('admin.spaces.parameters', $space->id)] = 'Parameters';
}
@endphp
@include('parts.tabs', [
'items' => $items
])

View file

@ -26,6 +26,10 @@ The endpoints are accessible using three different models:
- <span class="badge badge-warning">Admin</span> the endpoint can be only be accessed by an authenticated admin user
- <span class="badge badge-error">Super Admin</span> the endpoint can be only be accessed by an authenticated super admin user
### Space expiration
<span class="badge badge-error">Super Admin</span> can configure and expiration date on Spaces (`expire_at`). If the Space is expired all the authenticated endpoint of the API will return `403`.
### Localization
You can add an [`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) header to your request to translate the responses, and especially errors messages, in a specific language.
@ -128,28 +132,28 @@ An `account_creation_request_token` is a unique token that can be validated and
Create and return an `account_creation_request_token` that should then be validated to be used.
## SIP Domains
## Spaces
Manage the list of allowed `sip_domains`. The admin accounts declared with a `domain` that is a `super` `sip_domain` will become <span class="badge badge-error">Super Admin</span>.
Manage the list of allowed `spaces`. The admin accounts declared with a `domain` that is a `super` `sip_domain` will become <span class="badge badge-error">Super Admin</span>.
### `GET /sip_domains`
### `GET /spaces`
<span class="badge badge-error">Super Admin</span>
Get the list of declared SIP Domains.
Get the list of declared Spaces.
### `GET /sip_domains/{domain}`
### `GET /spaces/{domain}`
<span class="badge badge-error">Super Admin</span>
Get a SIP Domain.
Get a Space.
### `POST /sip_domains`
### `POST /spaces`
<span class="badge badge-error">Super Admin</span>
Create a new `sip_domain`.
JSON parameters:
* `domain` **required**, the domain to use, must be unique
* `domain` **required**, the SIP domain to use, must be unique
* `super` **required**, boolean, set the domain as a Super Domain
* `disable_chat_feature` boolean, disable the chat feature, default to `false`
* `disable_meetings_feature` boolean, disable the meeting feature, default to `false`
@ -162,8 +166,10 @@ JSON parameters:
* `assistant_disable_qr_code` boolean, disable the QR code feature in the assistant, default to `false`
* `assistant_hide_third_party_account` boolean, disable the call recording feature, default to `false`
* `max_account` integer, the maximum number of accounts configurable in the app, default to `0` (infinite)
* `max_accounts` integer, the maximum number of accounts that can be created in the space, default to `0` (infinite), cannot be less than the actual amount of accounts
* `expire_at` date, the moment the space is expiring, default to `null` (never expire)
### `PUT /sip_domains/{domain}`
### `PUT /spaces/{domain}`
<span class="badge badge-error">Super Admin</span>
Update an existing `sip_domain`.
@ -182,8 +188,10 @@ JSON parameters:
* `assistant_disable_qr_code` **required**, boolean
* `assistant_hide_third_party_account` **required**, boolean
* `max_account` **required**, integer
* `max_accounts` **required**,integer, the maximum number of accounts that can be created in the space, default to `0` (infinite), cannot be less than the actual amount of accounts
* `expire_at` **required**, date, the moment the space is expiring, set to `null` to never expire
### `DELETE /sip_domains/{domain}`
### `DELETE /spaces/{domain}`
<span class="badge badge-error">Super Admin</span>
Delete a domain, **be careful, all the related accounts will also be destroyed**.
@ -272,8 +280,11 @@ JSON parameters:
<span class="badge badge-success">Public</span>
Create an account using an `account_creation_token`.
Return `422` if the parameters are invalid or if the token is expired.
Return `403` if the `max_accounts` limit of the corresponding Space is reached.
JSON parameters:
* `username` unique username, minimum 6 characters
@ -397,12 +408,14 @@ JSON parameters:
To create an account directly from the API. <span class="badge badge-message">Deprecated</span> If `activated` is set to `false` a random generated `confirmation_key` and `provisioning_token` will be returned to allow further activation using the public endpoints and provision the account. Check `confirmation_key_expires` to also set an expiration date on that `confirmation_key`.
Return `403` if the `max_accounts` limit of the corresponding Space is reached.
JSON parameters:
* `username` unique username, minimum 6 characters
* `password` **required** minimum 6 characters
* `algorithm` **required**, values can be `SHA-256` or `MD5`
* `domain` **not configurable by default**. Only configurable if the admin is a super admin. Otherwise `APP_SIP_DOMAIN` is used. If the domain is not available in the `sip_domains` list, it will be created automatically.
* `domain` **not configurable by default**, must exist in one of the configured Spaces. Only configurable if the admin is a super admin. Otherwise the SIP domain of the corresponding space is used.
* `activated` optional, a boolean, set to `false` by default
* `display_name` optional, string
* `email` optional, must be an email, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true`
@ -420,7 +433,7 @@ Update an existing account. Ensure to resend all the parameters to not reset the
JSON parameters:
* `username` unique username, minimum 6 characters
* `domain` **not configurable by default**. Only configurable if the admin is a super admin. Otherwise `APP_SIP_DOMAIN` is used.
* `domain` **not configurable by default**, must exist in one of the configured Spaces. Only configurable if the admin is a super admin. Otherwise the SIP domain of the corresponding space is used.
* `password` **required** minimum 6 characters
* `algorithm` **required**, values can be `SHA-256` or `MD5`
* `display_name` optional, string

View file

@ -0,0 +1,5 @@
@extends('errors::minimal')
@section('title', __('Payment Required'))
@section('code', '402')
@section('message', __('Payment Required'))

View file

@ -2,4 +2,4 @@
@section('title', __('Service Unavailable'))
@section('code', '503')
@section('message', __('Service Unavailable'))
@section('message', $exception->getMessage())

View file

@ -1,486 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>@yield('title')</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet">
<!-- Styles -->
<style>
html {
line-height: 1.15;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
header,
nav,
section {
display: block;
}
figcaption,
main {
display: block;
}
a {
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
strong {
font-weight: inherit;
}
strong {
font-weight: bolder;
}
code {
font-family: monospace, monospace;
font-size: 1em;
}
dfn {
font-style: italic;
}
svg:not(:root) {
overflow: hidden;
}
button,
input {
font-family: sans-serif;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button {
text-transform: none;
}
button,
html [type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
legend {
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
[type="checkbox"],
[type="radio"] {
-webkit-box-sizing: border-box;
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
menu {
display: block;
}
canvas {
display: inline-block;
}
template {
display: none;
}
[hidden] {
display: none;
}
html {
-webkit-box-sizing: border-box;
box-sizing: border-box;
font-family: sans-serif;
}
*,
*::before,
*::after {
-webkit-box-sizing: inherit;
box-sizing: inherit;
}
p {
margin: 0;
}
button {
background: transparent;
padding: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
*,
*::before,
*::after {
border-width: 0;
border-style: solid;
border-color: #dae1e7;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
border-radius: 0;
}
button,
input {
font-family: inherit;
}
input::-webkit-input-placeholder {
color: inherit;
opacity: .5;
}
input:-ms-input-placeholder {
color: inherit;
opacity: .5;
}
input::-ms-input-placeholder {
color: inherit;
opacity: .5;
}
input::placeholder {
color: inherit;
opacity: .5;
}
button,
[role=button] {
cursor: pointer;
}
.bg-transparent {
background-color: transparent;
}
.bg-white {
background-color: #fff;
}
.bg-teal-light {
background-color: #64d5ca;
}
.bg-blue-dark {
background-color: #2779bd;
}
.bg-indigo-light {
background-color: #7886d7;
}
.bg-purple-light {
background-color: #a779e9;
}
.bg-no-repeat {
background-repeat: no-repeat;
}
.bg-cover {
background-size: cover;
}
.border-grey-light {
border-color: #dae1e7;
}
.hover\:border-grey:hover {
border-color: #b8c2cc;
}
.rounded-lg {
border-radius: .5rem;
}
.border-2 {
border-width: 2px;
}
.hidden {
display: none;
}
.flex {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.items-center {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.justify-center {
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.font-sans {
font-family: Nunito, sans-serif;
}
.font-light {
font-weight: 300;
}
.font-bold {
font-weight: 700;
}
.font-black {
font-weight: 900;
}
.h-1 {
height: .25rem;
}
.leading-normal {
line-height: 1.5;
}
.m-8 {
margin: 2rem;
}
.my-3 {
margin-top: .75rem;
margin-bottom: .75rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.max-w-sm {
max-width: 30rem;
}
.min-h-screen {
min-height: 100vh;
}
.py-3 {
padding-top: .75rem;
padding-bottom: .75rem;
}
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.pb-full {
padding-bottom: 100%;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.pin {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.text-black {
color: #22292f;
}
.text-grey-darkest {
color: #3d4852;
}
.text-grey-darker {
color: #606f7b;
}
.text-2xl {
font-size: 1.5rem;
}
.text-5xl {
font-size: 3rem;
}
.uppercase {
text-transform: uppercase;
}
.antialiased {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.tracking-wide {
letter-spacing: .05em;
}
.w-16 {
width: 4rem;
}
.w-full {
width: 100%;
}
@media (min-width: 768px) {
.md\:bg-left {
background-position: left;
}
.md\:bg-right {
background-position: right;
}
.md\:flex {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.md\:my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.md\:min-h-screen {
min-height: 100vh;
}
.md\:pb-0 {
padding-bottom: 0;
}
.md\:text-3xl {
font-size: 1.875rem;
}
.md\:text-15xl {
font-size: 9rem;
}
.md\:w-1\/2 {
width: 50%;
}
}
@media (min-width: 992px) {
.lg\:bg-center {
background-position: center;
}
}
</style>
</head>
<body class="antialiased font-sans">
<div class="md:flex min-h-screen">
<div class="w-full md:w-1/2 bg-white flex items-center justify-center">
<div class="max-w-sm m-8">
<div class="text-black text-5xl md:text-15xl font-black">
@yield('code', __('Oh no'))
</div>
<div class="w-16 h-1 bg-purple-light my-3 md:my-6"></div>
<p class="text-grey-darker text-2xl md:text-3xl font-light mb-8 leading-normal">
@yield('message')
</p>
<a href="{{ app('router')->has('home') ? route('home') : url('/') }}">
<button class="bg-transparent text-grey-darkest font-bold uppercase tracking-wide py-3 px-6 border-2 border-grey-light hover:border-grey rounded-lg">
{{ __('Go Home') }}
</button>
</a>
</div>
</div>
<div class="relative pb-full md:flex md:pb-0 md:min-h-screen w-full md:w-1/2">
@yield('image')
</div>
</div>
</body>
</html>

View file

@ -1,57 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title')</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito&display=swap" rel="stylesheet">
<!-- Styles -->
<style>
html, body {
background-color: #fff;
color: #636b6f;
font-family: 'Nunito', sans-serif;
font-weight: 100;
height: 100vh;
margin: 0;
}
.full-height {
height: 100vh;
}
.flex-center {
align-items: center;
display: flex;
justify-content: center;
}
.position-ref {
position: relative;
}
.content {
text-align: center;
}
.title {
font-size: 36px;
padding: 20px;
}
</style>
</head>
<body>
<div class="flex-center position-ref full-height">
<div class="content">
<div class="title">
@yield('message')
</div>
</div>
</div>
</body>
</html>

View file

@ -2,7 +2,7 @@
@section('content')
<h2 class="text-center pt-5">@yield('code') - @yield('title')</h2>
<h2>@yield('code') - @yield('title')</h2>
<p class="text-center">
@yield('message')

View file

@ -13,7 +13,7 @@
<link rel="stylesheet" type="text/css" href="{{ asset('css/' . config('app.env') . '.style.css') }}">
@endif
<script src="{{ asset('scripts/utils.js') }}""></script>
<script src="{{ asset('scripts/utils.js') }}"></script>
<script src="{{ asset('scripts/chart.js') }}"></script>
<script src="{{ asset('scripts/chartjs-plugin-datalabels@2.0.0') }}"></script>
</head>
@ -63,7 +63,7 @@
@hasSection('breadcrumb')
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('account.dashboard') }}">Dashboard</a></li>
<li class="breadcrumb-item"><a href="{{ route('account.dashboard') }}">My Account</a></li>
@yield('breadcrumb')
</ol>
</nav>

View file

@ -1,3 +1,4 @@
@if (isset($errors) && isset($name) && count($errors->get($name)) > 0)
@foreach ($errors->get($name) as $error)
<small class="error">

View file

@ -1,13 +1,10 @@
<div class="checkbox">
<input id="{{ $key }}" type="checkbox" @if ($object->$key || (isset($reversed) && $reversed && !$object->$key)) checked @endif name="{{ $key }}">
<label for="{{ $key }}"></label>
<div>
@if (!isset($reverse) || !$reverse)
<input name="{{ $key }}" value="true" type="radio" @if ($object->$key) checked @endif>
@if (isset($reverse) && $reverse)<p>Disabled</p>@else<p>Enabled</p>@endif
@endif
<input name="{{ $key }}" value="false" type="radio" @if (!$object->$key) checked @endif>
@if (isset($reverse) && $reverse)<p>Enabled</p>@else<p>Disabled</p>@endif
<label>{{ $label }}</label>
@if (isset($reverse) && $reverse)
<input name="{{ $key }}" value="true" type="radio" @if ($object->$key) checked @endif>
@if (isset($reverse) && $reverse)<p>Disabled</p>@else<p>Enabled</p>@endif
<p>{{ $label }}</p>
@if (isset($supporting))
<span class="supporting">{{ $supporting }}</span>
@endif
</div>
</div>

View file

@ -1,18 +1,18 @@
<nav>
@php
$items = [
'account.dashboard' => ['title' => 'Dashboard', 'icon' => 'gauge'],
];
$items = [];
if (auth()->user() && auth()->user()->admin) {
if (auth()->user()->superAdmin) {
$items['admin.spaces.index'] = ['title' => 'Spaces', 'icon' => 'globe-hemisphere-west'];
$items['admin.phone_countries.index'] = ['title' => 'Phone Countries', 'icon' => 'flag'];
} elseif (auth()->user()->admin) {
$items['admin.spaces.me'] = ['title' => 'My Space', 'icon' => 'globe-hemisphere-west'];
}
$items['admin.account.index'] = ['title' => 'Accounts', 'icon' => 'users'];
$items['admin.contacts_lists.index'] = ['title' => 'Contacts Lists', 'icon' => 'user-rectangle'];
$items['admin.statistics.show'] = ['title' => 'Statistics', 'icon' => 'chart-donut'];
if (auth()->user()->superAdmin) {
$items['admin.sip_domains.index'] = ['title' => 'SIP Domains', 'icon' => 'hard-drivers'];
$items['admin.phone_countries.index'] = ['title' => 'Phone Countries', 'icon' => 'flag'];
}
}
@endphp

View file

@ -24,7 +24,7 @@ use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Api\Admin\AccountDictionaryController;
use App\Http\Controllers\Api\Admin\AccountTypeController;
use App\Http\Controllers\Api\Admin\ContactsListController;
use App\Http\Controllers\Api\Admin\SipDomainController;
use App\Http\Controllers\Api\Admin\SpaceController;
use App\Http\Controllers\Api\Admin\VcardsStorageController as AdminVcardsStorageController;
use App\Http\Controllers\Api\StatisticsMessageController;
use App\Http\Controllers\Api\StatisticsCallController;
@ -61,7 +61,7 @@ Route::get('accounts/me/api_key/{auth_token}', 'Api\Account\ApiKeyController@gen
Route::get('phone_countries', 'Api\PhoneCountryController@index');
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blocked']], function () {
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blocked', 'space.expired']], function () {
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\Account\AuthTokenController@attach');
Route::post('account_creation_tokens/consume', 'Api\Account\CreationTokenController@consume');
@ -98,7 +98,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
// Super admin
Route::group(['middleware' => ['auth.super_admin']], function () {
Route::prefix('sip_domains')->controller(SipDomainController::class)->group(function () {
Route::prefix('spaces')->controller(SpaceController::class)->group(function () {
Route::get('/', 'index');
Route::get('{domain}', 'show');
Route::post('/', 'store');

View file

@ -39,7 +39,7 @@ use App\Http\Controllers\Admin\AccountStatisticsController;
use App\Http\Controllers\Admin\ContactsListController;
use App\Http\Controllers\Admin\ContactsListContactController;
use App\Http\Controllers\Admin\PhoneCountryController;
use App\Http\Controllers\Admin\SipDomainController;
use App\Http\Controllers\Admin\SpaceController;
use App\Http\Controllers\Admin\StatisticsController;
use Illuminate\Support\Facades\Route;
@ -81,7 +81,7 @@ Route::name('provisioning.')->prefix('provisioning')->controller(ProvisioningCon
Route::get('/', 'show')->name('show');
});
Route::group(['middleware' => 'web_panel_enabled'], function () {
Route::middleware(['web_panel_enabled', 'space.expired'])->group(function () {
if (config('app.public_registration')) {
Route::redirect('register', 'register/email')->name('account.register');
@ -154,10 +154,14 @@ Route::group(['middleware' => 'web_panel_enabled'], function () {
Route::get('auth_tokens/auth/{token}', 'Account\AuthTokenController@auth')->name('auth_tokens.auth');
Route::name('admin.')->prefix('admin')->middleware(['auth.admin', 'auth.check_blocked'])->group(function () {
Route::get('space', 'Admin\SpaceController@me')->name('spaces.me');
Route::middleware(['auth.super_admin'])->group(function () {
Route::resource('sip_domains', SipDomainController::class);
Route::get('sip_domains/delete/{id}', 'Admin\SipDomainController@delete')->name('sip_domains.delete');
Route::resource('spaces', SpaceController::class);
Route::get('spaces/delete/{id}', 'Admin\SpaceController@delete')->name('spaces.delete');
Route::get('spaces/{space}/parameters', 'Admin\SpaceController@parameters')->name('spaces.parameters');
Route::put('spaces/{space}/parameters', 'Admin\SpaceController@parametersUpdate')->name('spaces.parameters.update');
Route::name('phone_countries.')->controller(PhoneCountryController::class)->prefix('phone_countries')->group(function () {
Route::get('/', 'index')->name('index');

2
flexiapi/storage/debugbar/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -23,7 +23,7 @@ use App\Account;
use App\AuthToken;
use App\Password;
use App\ProvisioningToken;
use App\SipDomain;
use App\Space;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -128,7 +128,7 @@ class AccountProvisioningTest extends TestCase
public function testUiSectionProvisioning()
{
$secondDomain = SipDomain::factory()->create();
$secondDomain = Space::factory()->create();
$password = Password::factory()->create();
$password->account->generateApiKey();

View file

@ -20,6 +20,7 @@
namespace Tests\Feature;
use App\Account;
use App\Space;
use App\AccountCreationRequestToken;
use App\AccountCreationToken;
use App\Http\Middleware\ValidateJSON;
@ -131,6 +132,7 @@ class ApiAccountCreationTokenTest extends TestCase
public function testInvalidToken()
{
$token = AccountCreationToken::factory()->create();
Space::factory()->create();
// Invalid token
$this->json($this->method, $this->accountRoute, [
@ -165,6 +167,7 @@ class ApiAccountCreationTokenTest extends TestCase
public function testTokenExpiration()
{
$token = AccountCreationToken::factory()->expired()->create();
Space::factory()->create();
config()->set('app.account_creation_token_expiration_minutes', 10);
@ -180,6 +183,7 @@ class ApiAccountCreationTokenTest extends TestCase
public function testBlacklistedUsername()
{
$token = AccountCreationToken::factory()->create();
Space::factory()->create();
config()->set('app.account_blacklisted_usernames', 'foobar,blacklisted,username-.*');

View file

@ -24,7 +24,7 @@ use App\AccountCreationToken;
use App\AccountTombstone;
use App\ActivationExpiration;
use App\Password;
use App\SipDomain;
use App\Space;
use Carbon\Carbon;
use Tests\TestCase;
@ -91,7 +91,7 @@ class ApiAccountTest extends TestCase
$account->generateApiKey();
$username = '+33612121212';
$domain = SipDomain::first()->domain;
$domain = Space::first()->domain;
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
@ -120,7 +120,7 @@ class ApiAccountTest extends TestCase
$password->account->generateApiKey();
$username = 'blabla🔥';
$domain = SipDomain::first()->domain;
$domain = Space::first()->domain;
$this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
@ -160,7 +160,7 @@ class ApiAccountTest extends TestCase
$password = Password::factory()->admin()->create();
$username = 'foobar';
$domain = SipDomain::first()->domain;
$domain = Space::first()->domain;
config()->set('app.admins_manage_multi_domains', false);
@ -196,8 +196,8 @@ class ApiAccountTest extends TestCase
$account->save();
$username = 'foobar';
$domain1 = SipDomain::first()->domain;
$domain2 = SipDomain::factory()->secondDomain()->create()->domain;
$domain1 = Space::first()->domain;
$domain2 = Space::factory()->secondDomain()->create()->domain;
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
@ -273,7 +273,7 @@ class ApiAccountTest extends TestCase
->assertStatus(200);
}
public function testCreateDomainAsSuperAdmin()
/*public function testCreateDomainAsSuperAdmin()
{
$superAdmin = Account::factory()->superAdmin()->create();
$superAdmin->generateApiKey();
@ -296,11 +296,10 @@ class ApiAccountTest extends TestCase
'domain' => $newDomain
]);
$this->assertDatabaseHas('sip_domains', [
$this->assertDatabaseHas('spaces', [
'domain' => $newDomain
]);
}
}*/
public function testDomainInTestDeployment()
{
@ -408,7 +407,7 @@ class ApiAccountTest extends TestCase
$entryValue = 'bar';
$entryNewKey = 'new_key';
$entryNewValue = 'new_value';
$domain = SipDomain::first()->domain;
$domain = Space::first()->domain;
$result = $this->keyAuthenticated($admin)
->json($this->method, $this->route, [
@ -722,7 +721,7 @@ class ApiAccountTest extends TestCase
$password->account->generateApiKey();
$username = 'username';
$domain = SipDomain::first()->domain;
$domain = Space::first()->domain;
$response = $this->generateFirstResponse($password, $this->method, $this->route);
$this->generateSecondResponse($password, $response)

View file

@ -21,7 +21,7 @@ namespace Tests\Feature;
use App\Account;
use App\PhoneCountry;
use App\SipDomain;
use App\Space;
use Tests\TestCase;
class ApiPhoneCountryTest extends TestCase

View file

@ -20,24 +20,27 @@
namespace Tests\Feature;
use App\Account;
use App\SipDomain;
use App\Space;
use Carbon\Carbon;
use Tests\TestCase;
class ApiSipDomainTest extends TestCase
class ApiSpaceTest extends TestCase
{
protected $route = '/api/sip_domains';
protected $method = 'POST';
protected $route = '/api/spaces';
protected $accountRoute = '/api/accounts';
public function testBaseAdmin()
{
$admin = Account::factory()->admin()->create();
$admin->generateApiKey();
$secondDomain = SipDomain::factory()->secondDomain()->create();
$secondDomain = Space::factory()->secondDomain()->create();
$username = 'foo';
// Admin domain
$this->keyAuthenticated($admin)
->json('POST', '/api/accounts', [
->json($this->method, $this->accountRoute, [
'username' => $username,
'domain' => $admin->domain,
'algorithm' => 'SHA-256',
@ -47,7 +50,7 @@ class ApiSipDomainTest extends TestCase
// Second domain
$this->keyAuthenticated($admin)
->json('POST', '/api/accounts', [
->json($this->method, $this->accountRoute, [
'username' => $username,
// The domain is ignored there, to fallback on the admin one
'domain' => $secondDomain->domain,
@ -57,10 +60,10 @@ class ApiSipDomainTest extends TestCase
->assertJsonValidationErrors(['username']);
// Admin domain is now a super domain
SipDomain::where('domain', $admin->domain)->update(['super' => true]);
Space::where('domain', $admin->domain)->update(['super' => true]);
$this->keyAuthenticated($admin)
->json('POST', '/api/accounts', [
->json($this->method, $this->accountRoute, [
'username' => $username,
'domain' => $secondDomain->domain,
'algorithm' => 'SHA-256',
@ -77,8 +80,9 @@ class ApiSipDomainTest extends TestCase
$thirdDomain = 'third.domain';
$response = $this->keyAuthenticated($admin)
-> json('POST', $this->route, [
-> json($this->method, $this->route, [
'domain' => $thirdDomain,
'host' => $thirdDomain,
'super' => false
])
->assertStatus(201);
@ -87,6 +91,7 @@ class ApiSipDomainTest extends TestCase
->json('GET', $this->route)
->assertJsonFragment([
'domain' => $thirdDomain,
'host' => $thirdDomain,
'super' => false
])
->assertStatus(200);
@ -105,6 +110,7 @@ class ApiSipDomainTest extends TestCase
->json('PUT', $this->route . '/' . $thirdDomain, $json)
->assertJsonFragment([
'domain' => $thirdDomain,
'host' => $thirdDomain,
'super' => true,
'hide_settings' => true
])
@ -119,8 +125,51 @@ class ApiSipDomainTest extends TestCase
->json('GET', $this->route)
->assertJsonFragment([
'domain' => $admin->domain,
'super' => true
'host' => $admin->domain,
'super' => true,
'max_accounts' => 0,
'expire_at' => null
])
->assertStatus(200);
}
public function testUserCreation()
{
$admin = Account::factory()->superAdmin()->create();
$admin->generateApiKey();
$domain = 'domain.com';
$this->keyAuthenticated($admin)
->json($this->method, $this->accountRoute, [
'username' => 'first',
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(403);
$this->keyAuthenticated($admin)
-> json($this->method, $this->route, [
'domain' => $domain,
'host' => $domain,
'super' => false,
'max_accounts' => 1
])->assertStatus(201);
$this->keyAuthenticated($admin)
->json($this->method, $this->accountRoute, [
'username' => 'first',
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(200);
$this->keyAuthenticated($admin)
->json($this->method, $this->accountRoute, [
'username' => 'second',
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(403);
}
}

View file

@ -0,0 +1,72 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests\Feature;
use App\Account;
use App\Space;
use Carbon\Carbon;
use Tests\TestCaseWithSpaceMiddleware;
class ApiSpaceWithMiddlewareTest extends TestCaseWithSpaceMiddleware
{
protected $method = 'POST';
protected $route = '/api/spaces';
protected $accountRoute = '/api/accounts';
public function testExpiredSpace()
{
$superAdmin = Account::factory()->superAdmin()->create();
$superAdmin->generateApiKey();
$username = 'username';
$space = Space::factory()->secondDomain()->expired()->create();
$admin = Account::factory()->fromSpace($space)->admin()->create();
// Try to create a new user as an admin
$admin->generateApiKey();
config()->set('app.root_domain', $admin->domain);
$this->keyAuthenticated($admin)
->json($this->method, 'http://' . $admin->domain . $this->accountRoute, [
'username' => 'new',
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(403);
// Unexpire the space and try again
$space = $this->keyAuthenticated($superAdmin)
->get($this->route . '/' . $admin->domain)
->json();
$space['expire_at'] = Carbon::tomorrow()->toDateTimeString();
$this->keyAuthenticated($superAdmin)
->json('PUT', $this->route . '/' . $admin->domain, $space)
->assertStatus(200);
$this->keyAuthenticated($admin)
->json($this->method, $this->accountRoute, [
'username' => 'new',
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(200);
}
}

View file

@ -19,9 +19,8 @@
namespace Tests;
use App\Password;
use App\Account;
use App\PhoneCountry;
use App\Http\Middleware\Space;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -29,6 +28,7 @@ abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use RefreshDatabase;
use TestUtilsTrait;
protected $route = '/api/accounts/me';
protected $method = 'GET';
@ -37,85 +37,10 @@ abstract class TestCase extends BaseTestCase
{
parent::setUp();
$this->withoutMiddleware([Space::class]);
PhoneCountry::truncate();
PhoneCountry::factory()->france()->activated()->create();
PhoneCountry::factory()->netherlands()->create();
}
protected function disableBlockingService()
{
config()->set('app.blocking_amount_events_authorized_during_period', 0);
}
protected function keyAuthenticated(Account $account)
{
return $this->withHeaders([
'x-api-key' => $account->apiKey->key,
]);
}
protected function generateFirstResponse(Password $password, ?string $method = null, ?string $route = null)
{
return $this->withHeaders([
'From' => 'sip:' . $password->account->identifier
])->json($method ?? $this->method, $route ?? $this->route);
}
protected function generateSecondResponse(Password $password, $firstResponse)
{
return $this->withHeaders([
'From' => 'sip:' . $password->account->identifier,
'Authorization' => $this->generateDigest($password, $firstResponse),
]);
}
protected function generateDigest(Password $password, $response, $hash = 'md5', $nc = '00000001')
{
$challenge = \substr($response->headers->get('www-authenticate'), 7);
$extractedChallenge = $this->extractAuthenticateHeader($challenge);
$cnonce = generateNonce();
$a1 = $password->password;
$a2 = hash($hash, $this->method . ':' . $this->route);
$response = hash(
$hash,
sprintf(
'%s:%s:%s:%s:%s:%s',
$a1,
$extractedChallenge['nonce'],
$nc,
$cnonce,
$extractedChallenge['qop'],
$a2
)
);
$digest = \sprintf(
'username="%s",realm="%s",nonce="%s",nc=%s,cnonce="%s",uri="%s",qop=%s,response="%s",opaque="%s",algorithm=%s',
\strstr($password->account->identifier, '@', true),
$extractedChallenge['realm'],
$extractedChallenge['nonce'],
$nc,
$cnonce,
$this->route,
$extractedChallenge['qop'],
$response,
$extractedChallenge['opaque'],
array_flip(passwordAlgorithms())[$hash],
);
return 'Digest ' . $digest;
}
protected function extractAuthenticateHeader(string $string): array
{
preg_match_all(
'@(realm|nonce|qop|opaque|algorithm)=[\'"]?([^\'",]+)@',
$string,
$array
);
return array_combine($array[1], $array[2]);
}
}

View file

@ -0,0 +1,31 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests;
use App\Http\Middleware\Space;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
abstract class TestCaseWithSpaceMiddleware extends BaseTestCase
{
use CreatesApplication;
use RefreshDatabase;
use TestUtilsTrait;
}

View file

@ -0,0 +1,103 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests;
use App\Password;
use App\Account;
trait TestUtilsTrait
{
protected function disableBlockingService()
{
config()->set('app.blocking_amount_events_authorized_during_period', 0);
}
protected function keyAuthenticated(Account $account)
{
return $this->withHeaders([
'x-api-key' => $account->apiKey->key,
]);
}
protected function generateFirstResponse(Password $password, ?string $method = null, ?string $route = null)
{
return $this->withHeaders([
'From' => 'sip:' . $password->account->identifier
])->json($method ?? $this->method, $route ?? $this->route);
}
protected function generateSecondResponse(Password $password, $firstResponse)
{
return $this->withHeaders([
'From' => 'sip:' . $password->account->identifier,
'Authorization' => $this->generateDigest($password, $firstResponse),
]);
}
protected function generateDigest(Password $password, $response, $hash = 'md5', $nc = '00000001')
{
$challenge = \substr($response->headers->get('www-authenticate'), 7);
$extractedChallenge = $this->extractAuthenticateHeader($challenge);
$cnonce = generateNonce();
$a1 = $password->password;
$a2 = hash($hash, $this->method . ':' . $this->route);
$response = hash(
$hash,
sprintf(
'%s:%s:%s:%s:%s:%s',
$a1,
$extractedChallenge['nonce'],
$nc,
$cnonce,
$extractedChallenge['qop'],
$a2
)
);
$digest = \sprintf(
'username="%s",realm="%s",nonce="%s",nc=%s,cnonce="%s",uri="%s",qop=%s,response="%s",opaque="%s",algorithm=%s',
\strstr($password->account->identifier, '@', true),
$extractedChallenge['realm'],
$extractedChallenge['nonce'],
$nc,
$cnonce,
$this->route,
$extractedChallenge['qop'],
$response,
$extractedChallenge['opaque'],
array_flip(passwordAlgorithms())[$hash],
);
return 'Digest ' . $digest;
}
protected function extractAuthenticateHeader(string $string): array
{
preg_match_all(
'@(realm|nonce|qop|opaque|algorithm)=[\'"]?([^\'",]+)@',
$string,
$array
);
return array_combine($array[1], $array[2]);
}
}

View file

@ -33,7 +33,7 @@ License: GPL
URL: http://www.linphone.org
Source0: flexisip-account-manager.tar.gz
Requires: php >= 8.0, php-gd, php-pdo, php-redis, php-mysqlnd, php-mbstring
Requires: php >= 8.1, php-gd, php-pdo, php-redis, php-mysqlnd, php-mbstring
%description
PHP server for Linphone and Flexisip providing module for account creation.