Fix FLEXIAPI-366 Add voicemails endpoints

This commit is contained in:
Timothée Jaussoin 2025-10-09 15:06:30 +02:00
parent 98d9d76225
commit c954b21dd2
20 changed files with 520 additions and 34 deletions

View file

@ -1,8 +1,9 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
sudo -su www-data && php artisan digest:clear-nonces 60
sudo -su www-data && php artisan accounts:clear-api-keys 60
sudo -su www-data && php artisan accounts:clear-accounts-tombstones 7 --apply
sudo -su www-data && php artisan accounts:clear-api-keys 60
sudo -su www-data && php artisan accounts:clear-files 30 --apply
sudo -su www-data && php artisan accounts:clear-unconfirmed 30 --apply
sudo -su www-data && php artisan digest:clear-nonces 60
sudo -su www-data && php artisan spaces:expiration-emails

View file

@ -1,8 +1,9 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
php artisan digest:clear-nonces 60
php artisan accounts:clear-api-keys 60
php artisan accounts:clear-accounts-tombstones 7 --apply
php artisan accounts:clear-api-keys 60
php artisan accounts:clear-files 30 --apply
php artisan accounts:clear-unconfirmed 30 --apply
php artisan digest:clear-nonces 60
php artisan spaces:expiration-emails

View file

@ -135,6 +135,18 @@ class Account extends Authenticatable
return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id');
}
public function files()
{
return $this->hasMany(AccountFile::class)->latest();
}
public function voicemails()
{
return $this->hasMany(AccountFile::class)
->whereIn('content_type', AccountFile::VOICEMAIL_CONTENTTYPES)
->latest();
}
public function vcardsStorage()
{
return $this->hasMany(VcardStorage::class);

View file

@ -0,0 +1,52 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Support\Facades\Storage;
class AccountFile extends Model
{
use HasUuids;
public const VOICEMAIL_CONTENTTYPES = ['audio/opus', 'audio/wav'];
public const FILES_PATH = 'files';
protected $hidden = ['account_id', 'updated_at'];
protected $appends = ['download_url'];
protected static function booted()
{
static::deleting(function ($category) {
Storage::delete($this->getPathAttribute());
});
}
public function account()
{
return $this->belongsTo(Account::class);
}
public function getMaxUploadSizeAttribute(): ?int
{
return maxUploadSize();
}
public function getUploadUrlAttribute(): ?string
{
return route('file.upload', $this->attributes['id']);
}
public function getPathAttribute(): string
{
return self::FILES_PATH . '/' . $this->attributes['name'];
}
public function getDownloadUrlAttribute(): ?string
{
return !empty($this->attributes['name'])
&& !empty($this->attributes['id'])
? route('file.show', [$this->attributes['id'], $this->attributes['name']])
: null;
}
}

View file

@ -29,11 +29,6 @@ class ClearAccountsTombstones extends Command
protected $signature = 'accounts:clear-accounts-tombstones {days} {--apply}';
protected $description = 'Clear deleted accounts tombstones after n days';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$tombstones = AccountTombstone::where(

View file

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands\Accounts;
use App\AccountFile;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ClearFiles extends Command
{
protected $signature = 'accounts:clear-files {days} {--apply}';
protected $description = 'Remove the uploaded files after n days';
public function handle(): int
{
$files = AccountFile::where(
'created_at',
'<',
Carbon::now()->subDays($this->argument('days'))->toDateTimeString()
);
$count = $files->count();
if ($this->option('apply')) {
$this->info($count . ' files in deletion…');
$files->delete();
$this->info($count . ' files deleted');
return Command::SUCCESS;
}
$this->info($count . ' files to delete');
return Command::SUCCESS;
}
}

View file

@ -29,11 +29,6 @@ class ClearUnconfirmed extends Command
protected $signature = 'accounts:clear-unconfirmed {days} {--apply} {--and-confirmed}';
protected $description = 'Clear unconfirmed accounts after n days';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$accounts = Account::where(

View file

@ -30,11 +30,6 @@ class CreateAdminTest extends Command
protected $signature = 'accounts:create-admin-test';
protected $description = 'Create a test admin account, only for tests purpose';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$username = 'admin_test';

View file

@ -28,11 +28,6 @@ class Seed extends Command
protected $signature = 'accounts:seed {json-file-path}';
protected $description = 'Seed some accounts from a JSON file';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$file = $this->argument('json-file-path');

View file

@ -28,11 +28,6 @@ class SetAdmin extends Command
protected $signature = 'accounts:set-admin {id}';
protected $description = 'Give the admin role to an account';
public function __construct()
{
parent::__construct();
}
public function handle()
{
$account = Account::withoutGlobalScopes()->where('id', $this->argument('id'))->first();

View file

@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
use Illuminate\Support\Facades\App;
use Illuminate\Support\Str;
use App\Account;
@ -30,13 +31,13 @@ use Illuminate\Support\Facades\DB;
function space(): ?Space
{
return is_object(request()->space) ? request()->space :null;
return is_object(request()->space) ? request()->space : null;
}
function passwordAlgorithms(): array
{
return [
'MD5' => 'md5',
'MD5' => 'md5',
'SHA-256' => 'sha256',
];
}
@ -100,7 +101,7 @@ function markdownDocumentationView(string $view): string
$converter->getEnvironment()->addExtension(new TableOfContentsExtension());
return (string) $converter->convert(
(string)view($view, [
(string) view($view, [
'app_name' => space()->name
])->render()
);
@ -161,6 +162,19 @@ function resolveDomain(Request $request): string
: $request->space->domain;
}
function maxUploadSize(): ?int
{
$uploadMaxSizeInBytes = ini_parse_quantity(ini_get('upload_max_filesize'));
if ($uploadMaxSizeInBytes > 0) {
return $uploadMaxSizeInBytes / 1024;
}
$postMaxSizeInBytes = ini_parse_quantity(ini_get('post_max_size'));
if ($postMaxSizeInBytes > 0) {
return $postMaxSizeInBytes / 1024;
}
}
function captchaConfigured(): bool
{
return env('HCAPTCHA_SECRET', false) != false || env('HCAPTCHA_SITEKEY', false) != false;
@ -205,7 +219,7 @@ function validateIsoDate($attribute, $value, $parameters, $validator): bool
// Regex from https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/
: '/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/';
return (bool)preg_match($regex, $value);
return (bool) preg_match($regex, $value);
}
/**

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\AccountFile;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileController extends Controller
{
public function show(string $uuid, string $name)
{
$file = AccountFile::findOrFail($uuid);
if ($file->name != $name) {
abort(404);
}
return Storage::download($file->path);
}
public function upload(Request $request, string $uuid)
{
$file = AccountFile::findOrFail($uuid);
if (!empty($file->name)) {
abort(404);
}
$request->validate([
'file' => 'required|file|mimetypes:' . $file->content_type
]);
$uploadedFile = $request->file('file');
$name = Str::random(8) . '_' . $uploadedFile->getClientOriginalName();
if ($uploadedFile->storeAs('files', $name)) {
$file->name = $name;
$file->size = $uploadedFile->getSize();
$file->uploaded_at = Carbon::now();
$file->save();
return $file;
}
abort(503);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Api\Admin\Account\VoicemailController as AdminVoicemailController;
use Illuminate\Http\Request;
class VoicemailController extends Controller
{
public function index(Request $request)
{
return (new AdminVoicemailController)->index($request, $request->user()->id);
}
public function store(Request $request)
{
return (new AdminVoicemailController)->store($request, $request->user()->id);
}
public function show(Request $request, string $uuid)
{
return (new AdminVoicemailController)->show($request, $request->user()->id, $uuid);
}
public function destroy(Request $request, string $uuid)
{
return (new AdminVoicemailController)->destroy($request, $request->user()->id, $uuid);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\Admin\Account;
use App\Account;
use App\AccountFile;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class VoicemailController extends Controller
{
public function index(Request $request, int $accountId)
{
return Account::findOrFail($accountId)->voicemails;
}
public function store(Request $request, int $accountId)
{
$account = Account::findOrFail($accountId);
$request->validate([
'sip_from' => 'nullable|starts_with:sip',
'content_type' => [
'required',
Rule::in(AccountFile::VOICEMAIL_CONTENTTYPES),
]
]);
$voicemail = new AccountFile;
$voicemail->account_id = $account->id;
$voicemail->sip_from = $request->get('sip_from');
$voicemail->content_type = $request->get('content_type');
$voicemail->save();
$voicemail->append(['upload_url', 'max_upload_size']);
return $voicemail;
}
public function show(Request $request, int $accountId, string $uuid)
{
return Account::findOrFail($accountId)->voicemails()->where('id', $uuid)->firstOrFail();
}
public function destroy(Request $request, int $accountId, string $uuid)
{
return Account::findOrFail($accountId)->voicemails()->where('id', $uuid)->delete();
}
}

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('account_files', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->integer('account_id')->unsigned()->nullable();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('cascade');
$table->string('name')->nullable();
$table->integer('size')->nullable();
$table->string('content_type')->index();
$table->text('sip_from')->nullable();
$table->dateTime('uploaded_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('account_files');
}
};

View file

@ -0,0 +1,57 @@
## Voicemails
### `GET /accounts/{id/me}/voicemails`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span>
Return the currently stored voicemails
### `GET /accounts/{id/me}/voicemails/{uuid}`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span>
```
{
id: '{uuid}',
sip_from: '{sip_address}',
get_url: 'https://{the file_url}',
file_size: 2451400, // the file size, in bytes
content_type: 'audio/{format}',
created_at: '2025-10-09T12:59:32Z',
uploaded_at: '2025-10-09T12:59:40Z'
}
```
Return a stored voicemail
### `POST /accounts/{id/me}/voicemails`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span>
Create a new voicemail slot
JSON parameters:
* `sip_from`, mandatory, a valid SIP address
* `content_type`, mandatory, the content type of the audio file to upload, must be `audio/opus` or `audio/wav`
This endpoint will return the following JSON:
```
{
id: '{uuid}',
sip_from: '{sip_address}',
upload_url: 'https://{upload_service_unique_url}', // unique URL generated to upload the audio file
download_url: 'https://{download_service_unique_url}', // unique URL generated to download the audio file, null before upload
max_upload_size: 3000000, // here 3MB file size limit, in bytes
content_type: 'audio/{format}',
created_at: '2025-10-09T12:59:32Z', // time of the slot creation
uploaded_at: null // time when the slot was filled with the audio file
}
```
### `DELETE /accounts/{id/me}/voicemails/{uuid}`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span>
Delete a stored voicemail, if the file is managed by the platform it will be deleted as well

View file

@ -39,6 +39,23 @@ Returns `pong`
@include('api.documentation.accounts.vcards_storage')
@include('api.documentation.accounts.voicemail')
## File Upload
### `POST /files/{uuid}`
<span class="badge badge-info">User</span>
Upload a file to a previously created slot. This endpoint will directly be returned when creating the upload slot in the `upload_url` parameter.
Related endpoints:
* [Voicemails](#voicemails)
HTTP [Form-Data](https://developer.mozilla.org/fr/docs/Web/API/FormData) parameters:
* `file` **required**, the file to upload, must have the same `content_type` as requested in the slot
## Messages
### `POST /messages`

View file

@ -18,11 +18,13 @@
*/
use App\Http\Controllers\Api\Account\VcardsStorageController;
use App\Http\Controllers\Api\Account\VoicemailController;
use App\Http\Controllers\Api\Admin\Account\ActionController;
use App\Http\Controllers\Api\Admin\Account\CardDavCredentialsController;
use App\Http\Controllers\Api\Admin\Account\ContactController;
use App\Http\Controllers\Api\Admin\Account\DictionaryController;
use App\Http\Controllers\Api\Admin\Account\TypeController;
use App\Http\Controllers\Api\Admin\Account\VoicemailController as AdminVoicemailController;
use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Api\Admin\ContactsListController;
use App\Http\Controllers\Api\Admin\ExternalAccountController;
@ -55,10 +57,12 @@ Route::post('accounts/auth_token', 'Api\Account\AuthTokenController@store');
Route::get('accounts/me/api_key/{auth_token}', 'Api\Account\ApiKeyController@generateFromToken')->middleware('cookie', 'cookie.encrypt');
Route::get('phone_countries', 'Api\PhoneCountryController@index');
Route::get('files/{uuid}/{name}', 'Api\Account\FileController@show')->name('file.show');
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blocked']], function () {
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\Account\AuthTokenController@attach');
Route::post('account_creation_tokens/consume', 'Api\Account\CreationTokenController@consume');
Route::post('files/{uuid}', 'Api\Account\FileController@upload')->name('file.upload');
Route::post('push_notification', 'Api\Account\PushNotificationController@push');
@ -86,6 +90,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::get('contacts', 'Api\Account\ContactController@index');
Route::apiResource('vcards-storage', VcardsStorageController::class);
Route::apiResource('voicemails', VoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
});
Route::group(['middleware' => ['auth.admin']], function () {
@ -152,6 +157,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::apiResource('accounts/{id}/actions', ActionController::class);
Route::apiResource('account_types', TypeController::class);
Route::apiResource('accounts/{account_id}/vcards-storage', AdminVcardsStorageController::class);
Route::apiResource('accounts/{id}/voicemails', AdminVoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
Route::apiResource('contacts_lists', ContactsListController::class);
Route::prefix('contacts_lists')->controller(ContactsListController::class)->group(function () {

View file

@ -21,7 +21,6 @@ namespace Tests\Feature;
use App\Account;
use App\Space;
use Carbon\Carbon;
use Tests\TestCase;
class ApiSpaceTest extends TestCase

View file

@ -0,0 +1,157 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2025 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 Illuminate\Http\UploadedFile;
use Tests\TestCase;
class ApiVoicemailTest extends TestCase
{
protected $route = '/api/accounts/me/voicemails';
protected $uploadRoute = '/api/files/';
public function testAccount()
{
$account = Account::factory()->create();
$account->generateUserApiKey();
$this->keyAuthenticated($account)
->json('POST', $this->route, [])
->assertJsonValidationErrors(['content_type']);
$this->keyAuthenticated($account)
->json('POST', $this->route, [
'content_type' => 'image/jpg'
])
->assertJsonValidationErrors(['content_type']);
$accountFile = $this->keyAuthenticated($account)
->json('POST', $this->route, [
'content_type' => 'audio/opus'
])->assertCreated();
$uuid = $accountFile->json()['id'];
$this->keyAuthenticated($account)
->get($this->route)
->assertJsonFragment(['id' => $uuid]);
$this->keyAuthenticated($account)
->get($this->route . '/' . $uuid)
->assertJsonFragment([
'id' => $uuid,
'name' => null,
'size' => null,
'sip_from' => null,
'uploaded_at' => null,
]);
$this->keyAuthenticated($account)
->delete($this->route . '/' . $uuid)
->assertOk();
$this->keyAuthenticated($account)
->get($this->route . '/' . $uuid)
->assertNotFound();
}
public function testAdmin()
{
$admin = Account::factory()->admin()->create();
$admin->generateUserApiKey();
$account = Account::factory()->create();
$account->generateUserApiKey();
$adminRoute = '/api/accounts/' . $account->id . '/voicemails';
$this->keyAuthenticated($account)
->json('POST', $adminRoute, [])
->assertForbidden();
$accountFile = $this->keyAuthenticated($admin)
->json('POST', $adminRoute, [
'content_type' => 'audio/opus'
])->assertCreated();
$uuid = $accountFile->json()['id'];
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertJsonFragment(['id' => $uuid]);
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertJsonFragment([
'id' => $uuid
]);
$this->keyAuthenticated($admin)
->delete($adminRoute . '/' . $uuid)
->assertOk();
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertNotFound();
}
public function testUpload()
{
$account = Account::factory()->create();
$account->generateUserApiKey();
$accountFile = $this->keyAuthenticated($account)
->json('POST', $this->route, [
'content_type' => 'audio/opus'
])->assertCreated();
$uuid = $accountFile->json()['id'];
$this->keyAuthenticated($account)
->json('POST', $this->uploadRoute . $uuid, [
'file' => UploadedFile::fake()->image('photo.jpg')
])->assertJsonValidationErrors(['file']);
$this->keyAuthenticated($account)
->json('POST', $this->uploadRoute . $uuid, [
'file' => UploadedFile::fake()->create('audio.wav', 500, 'audio/wav')
])->assertJsonValidationErrors(['file']);
$file = $this->keyAuthenticated($account)
->json('POST', $this->uploadRoute . $uuid, data: [
'file' => UploadedFile::fake()->create('audio.opus', 500, 'audio/opus')
])->assertOk();
$this->keyAuthenticated($account)
->json('POST', $this->uploadRoute . $uuid, data: [
'file' => UploadedFile::fake()->create('audio.opus', 500, 'audio/opus')
])->assertNotFound();
$this->head($file->json()['download_url'])->assertOk();
// Delete the file
$this->keyAuthenticated($account)
->delete($this->route . '/' . $file->json()['id'])
->assertOk();
$this->head($file->json()['download_url'])->assertNotFound();
}
}