From 4d601c4a9c23e6b208f7b6a6c41143fe4eed9622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Jaussoin?= Date: Thu, 11 Dec 2025 16:27:32 +0000 Subject: [PATCH] Fix FLEXIAPI-366 Add voicemails endpoints --- cron/flexiapi.cron | 1 + cron/flexiapi.debian | 7 +- cron/flexiapi.redhat | 7 +- flexiapi/app/Account.php | 17 ++ flexiapi/app/AccountFile.php | 74 ++++++++ .../Accounts/ClearAccountsTombstones.php | 5 - .../Console/Commands/Accounts/ClearFiles.php | 35 ++++ .../Commands/Accounts/ClearUnconfirmed.php | 5 - .../Commands/Accounts/CreateAdminTest.php | 5 - .../app/Console/Commands/Accounts/Seed.php | 5 - .../Accounts/SendVoicemailsEmails.php | 40 ++++ .../Console/Commands/Accounts/SetAdmin.php | 5 - flexiapi/app/Helpers/Utils.php | 17 +- .../Controllers/Account/FileController.php | 32 ++++ .../Admin/Account/FileController.php | 32 ++++ .../Api/Account/FileController.php | 42 +++++ .../Api/Account/VoicemailController.php | 30 +++ .../Api/Admin/Account/VoicemailController.php | 49 +++++ flexiapi/app/Mail/ExpiringSpace.php | 1 - flexiapi/app/Mail/NewsletterRegistration.php | 1 - flexiapi/app/Mail/Provisioning.php | 1 - flexiapi/app/Mail/RecoverByCode.php | 1 - flexiapi/app/Mail/ResetPassword.php | 2 - flexiapi/app/Mail/Voicemail.php | 44 +++++ flexiapi/app/Rules/AudioMime.php | 38 ++++ flexiapi/app/Rules/IsNotPhoneNumber.php | 1 - ...0_20_093414_create_account_files_table.php | 32 ++++ flexiapi/lang/fr.json | 4 + flexiapi/phpmd.xml | 1 - flexiapi/public/css/style.css | 5 + .../views/admin/account/show.blade.php | 50 ++++- .../accounts/voicemail.blade.php | 57 ++++++ .../api/documentation_markdown.blade.php | 17 ++ .../resources/views/mails/voicemail.blade.php | 14 ++ flexiapi/routes/api.php | 7 + flexiapi/routes/console.php | 6 +- flexiapi/routes/web.php | 13 ++ flexiapi/tests/Feature/ApiSpaceTest.php | 1 - flexiapi/tests/Feature/ApiVoicemailTest.php | 175 ++++++++++++++++++ flexisip-account-manager.spec | 9 + 40 files changed, 841 insertions(+), 47 deletions(-) create mode 100644 cron/flexiapi.cron create mode 100644 flexiapi/app/AccountFile.php create mode 100644 flexiapi/app/Console/Commands/Accounts/ClearFiles.php create mode 100644 flexiapi/app/Console/Commands/Accounts/SendVoicemailsEmails.php create mode 100644 flexiapi/app/Http/Controllers/Account/FileController.php create mode 100644 flexiapi/app/Http/Controllers/Admin/Account/FileController.php create mode 100644 flexiapi/app/Http/Controllers/Api/Account/FileController.php create mode 100644 flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php create mode 100644 flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php create mode 100644 flexiapi/app/Mail/Voicemail.php create mode 100644 flexiapi/app/Rules/AudioMime.php create mode 100644 flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php create mode 100644 flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php create mode 100644 flexiapi/resources/views/mails/voicemail.blade.php create mode 100644 flexiapi/tests/Feature/ApiVoicemailTest.php diff --git a/cron/flexiapi.cron b/cron/flexiapi.cron new file mode 100644 index 0000000..a11ed25 --- /dev/null +++ b/cron/flexiapi.cron @@ -0,0 +1 @@ +* * * * * apache /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/artisan schedule:run >> /dev/null 2>&1 \ No newline at end of file diff --git a/cron/flexiapi.debian b/cron/flexiapi.debian index cb26719..25a4362 100644 --- a/cron/flexiapi.debian +++ b/cron/flexiapi.debian @@ -1,9 +1,10 @@ #!/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 spaces:expiration-emails sudo -su www-data && php artisan app:clear-statistics 30 --apply +sudo -su www-data && php artisan digest:clear-nonces 60 +sudo -su www-data && php artisan spaces:expiration-emails diff --git a/cron/flexiapi.redhat b/cron/flexiapi.redhat index f8d5fc4..d869664 100644 --- a/cron/flexiapi.redhat +++ b/cron/flexiapi.redhat @@ -1,9 +1,10 @@ #!/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 app:clear-statistics 30 --apply +php artisan digest:clear-nonces 60 php artisan spaces:expiration-emails -php artisan app:clear-statistics 30 --apply \ No newline at end of file diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php index 1181840..4bfca3e 100644 --- a/flexiapi/app/Account.php +++ b/flexiapi/app/Account.php @@ -134,6 +134,23 @@ 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 uploadedVoicemails() + { + return $this->voicemails()->whereNotNull('name'); + } + public function vcardsStorage() { return $this->hasMany(VcardStorage::class); diff --git a/flexiapi/app/AccountFile.php b/flexiapi/app/AccountFile.php new file mode 100644 index 0000000..e8c81a7 --- /dev/null +++ b/flexiapi/app/AccountFile.php @@ -0,0 +1,74 @@ + 'datetime', + ]; + + protected static function booted() + { + static::deleting(function (AccountFile $accountFile) { + Storage::delete($accountFile->getPathAttribute()); + }); + } + + public function account() + { + return $this->belongsTo(Account::class)->withoutGlobalScopes(); + } + + 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 getUrlAttribute(): ?string + { + return !empty($this->attributes['name']) + && !empty($this->attributes['id']) + ? replaceHost( + route('file.show', ['uuid' => $this->attributes['id'], 'name' => $this->attributes['name']]), + $this->account->space->host + ) + : null; + } + + public function getDownloadUrlAttribute(): ?string + { + return !empty($this->attributes['name']) + && !empty($this->attributes['id']) + ? replaceHost(route( + 'file.download', + ['uuid' => $this->attributes['id'], 'name' => $this->attributes['name']] + ), $this->account->space->host) + : null; + } + + public function isVoicemailAudio(): bool + { + return in_array($this->attributes['content_type'], self::VOICEMAIL_CONTENTTYPES); + } +} diff --git a/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php b/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php index 4493e08..1fed49d 100644 --- a/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php +++ b/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php @@ -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( diff --git a/flexiapi/app/Console/Commands/Accounts/ClearFiles.php b/flexiapi/app/Console/Commands/Accounts/ClearFiles.php new file mode 100644 index 0000000..e6a7ac5 --- /dev/null +++ b/flexiapi/app/Console/Commands/Accounts/ClearFiles.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php b/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php index 50d333e..f1d2a1e 100644 --- a/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php +++ b/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php @@ -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( diff --git a/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php b/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php index a188138..c94c82f 100644 --- a/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php +++ b/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php @@ -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'; diff --git a/flexiapi/app/Console/Commands/Accounts/Seed.php b/flexiapi/app/Console/Commands/Accounts/Seed.php index f54cbf6..889ebf4 100644 --- a/flexiapi/app/Console/Commands/Accounts/Seed.php +++ b/flexiapi/app/Console/Commands/Accounts/Seed.php @@ -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'); diff --git a/flexiapi/app/Console/Commands/Accounts/SendVoicemailsEmails.php b/flexiapi/app/Console/Commands/Accounts/SendVoicemailsEmails.php new file mode 100644 index 0000000..a5ba5ad --- /dev/null +++ b/flexiapi/app/Console/Commands/Accounts/SendVoicemailsEmails.php @@ -0,0 +1,40 @@ +whereNull('sent_by_mail_at') + ->where('sending_by_mail_tryouts', '<', is_int($this->option('tryout')) + ? $this->option('tryout') + : 3) + ->get(); + + foreach ($voicemails as $voicemail) { + $voicemail->sending_by_mail_at = Carbon::now(); + $voicemail->save(); + + if (Mail::to(users: $voicemail->account)->send(new Voicemail($voicemail))) { + $voicemail->sent_by_mail_at = Carbon::now(); + $this->info('Voicemail sent to ' . $voicemail->account->identifier); + } else { + $voicemail->sending_by_mail_tryouts++; + $this->info('Error sending voicemail to ' . $voicemail->account->identifier); + } + + $voicemail->save(); + } + } +} diff --git a/flexiapi/app/Console/Commands/Accounts/SetAdmin.php b/flexiapi/app/Console/Commands/Accounts/SetAdmin.php index 157cf46..d6d0540 100644 --- a/flexiapi/app/Console/Commands/Accounts/SetAdmin.php +++ b/flexiapi/app/Console/Commands/Accounts/SetAdmin.php @@ -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(); diff --git a/flexiapi/app/Helpers/Utils.php b/flexiapi/app/Helpers/Utils.php index 1105201..351cbef 100644 --- a/flexiapi/app/Helpers/Utils.php +++ b/flexiapi/app/Helpers/Utils.php @@ -17,6 +17,7 @@ along with this program. If not, see . */ +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,14 @@ function resolveDomain(Request $request): string : $request->space->domain; } +function maxUploadSize(): int +{ + return min( + ini_parse_quantity(ini_get('upload_max_filesize')), + ini_parse_quantity(ini_get('post_max_size')) + ); +} + function captchaConfigured(): bool { return env('HCAPTCHA_SECRET', false) != false || env('HCAPTCHA_SITEKEY', false) != false; @@ -205,7 +214,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); } /** diff --git a/flexiapi/app/Http/Controllers/Account/FileController.php b/flexiapi/app/Http/Controllers/Account/FileController.php new file mode 100644 index 0000000..a596887 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Account/FileController.php @@ -0,0 +1,32 @@ +name != $name) { + abort(404); + } + + return Storage::get($file->path); + } + + public function download(string $uuid, string $name) + { + $file = AccountFile::findOrFail($uuid); + + if ($file->name != $name) { + abort(404); + } + + return Storage::download($file->path); + } +} diff --git a/flexiapi/app/Http/Controllers/Admin/Account/FileController.php b/flexiapi/app/Http/Controllers/Admin/Account/FileController.php new file mode 100644 index 0000000..26d55eb --- /dev/null +++ b/flexiapi/app/Http/Controllers/Admin/Account/FileController.php @@ -0,0 +1,32 @@ +files()->where('id', $fileId)->firstOrFail(); + + return view('admin.account.file.delete', [ + 'account' => $account, + 'file' => $file + ]); + } + + public function destroy(Request $request, int $accountId, string $fileId) + { + $account = Account::findOrFail($accountId); + $accountFile = $account->files() + ->where('id', $fileId) + ->firstOrFail(); + $accountFile->delete(); + + return redirect()->route('admin.account.show', $account)->withFragment('#files'); + } +} diff --git a/flexiapi/app/Http/Controllers/Api/Account/FileController.php b/flexiapi/app/Http/Controllers/Api/Account/FileController.php new file mode 100644 index 0000000..bcab52d --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Account/FileController.php @@ -0,0 +1,42 @@ +name)) { + abort(404); + } + + $request->validate(['file' => 'required|file']); + + if ($file->isVoicemailAudio()) { + $request->validate(['file' => [new AudioMime($file)]]); + } + + $uploadedFile = $request->file('file'); + $name = Str::random(8) . '_' . $uploadedFile->getClientOriginalName(); + + if ($uploadedFile->storeAs(AccountFile::FILES_PATH, $name)) { + $file->name = $name; + $file->size = $uploadedFile->getSize(); + $file->uploaded_at = Carbon::now(); + $file->save(); + + return $file; + } + + abort(503); + } +} diff --git a/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php b/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php new file mode 100644 index 0000000..5039121 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php b/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php new file mode 100644 index 0000000..42bf3b2 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php @@ -0,0 +1,49 @@ +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(); + } +} diff --git a/flexiapi/app/Mail/ExpiringSpace.php b/flexiapi/app/Mail/ExpiringSpace.php index b540810..c315a54 100644 --- a/flexiapi/app/Mail/ExpiringSpace.php +++ b/flexiapi/app/Mail/ExpiringSpace.php @@ -5,7 +5,6 @@ namespace App\Mail; use App\Space; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/flexiapi/app/Mail/NewsletterRegistration.php b/flexiapi/app/Mail/NewsletterRegistration.php index bcd590d..b55c8d7 100644 --- a/flexiapi/app/Mail/NewsletterRegistration.php +++ b/flexiapi/app/Mail/NewsletterRegistration.php @@ -22,7 +22,6 @@ namespace App\Mail; use App\Account; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/flexiapi/app/Mail/Provisioning.php b/flexiapi/app/Mail/Provisioning.php index 097dd67..10995ef 100644 --- a/flexiapi/app/Mail/Provisioning.php +++ b/flexiapi/app/Mail/Provisioning.php @@ -22,7 +22,6 @@ namespace App\Mail; use App\Account; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/flexiapi/app/Mail/RecoverByCode.php b/flexiapi/app/Mail/RecoverByCode.php index 16f58ca..5aa0354 100644 --- a/flexiapi/app/Mail/RecoverByCode.php +++ b/flexiapi/app/Mail/RecoverByCode.php @@ -22,7 +22,6 @@ namespace App\Mail; use App\Account; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/flexiapi/app/Mail/ResetPassword.php b/flexiapi/app/Mail/ResetPassword.php index 4abc43a..e045bd7 100644 --- a/flexiapi/app/Mail/ResetPassword.php +++ b/flexiapi/app/Mail/ResetPassword.php @@ -20,10 +20,8 @@ namespace App\Mail; use App\Account; -use App\ResetPasswordEmailToken; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; diff --git a/flexiapi/app/Mail/Voicemail.php b/flexiapi/app/Mail/Voicemail.php new file mode 100644 index 0000000..56e217c --- /dev/null +++ b/flexiapi/app/Mail/Voicemail.php @@ -0,0 +1,44 @@ +accountFile->account->space->name . + ': ' . + __('New voice message from :sipfrom', ['sipfrom' => $this->accountFile->sip_from]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mails.voicemail', + ); + } + + public function attachments(): array + { + return [ + Attachment::fromStorage($this->accountFile->path) + ->withMime($this->accountFile->content_type) + ]; + } +} diff --git a/flexiapi/app/Rules/AudioMime.php b/flexiapi/app/Rules/AudioMime.php new file mode 100644 index 0000000..b1d10da --- /dev/null +++ b/flexiapi/app/Rules/AudioMime.php @@ -0,0 +1,38 @@ +getMimeType()) { + case 'audio/opus': + $mimeType = 'audio/opus'; + break; + + case 'audio/vnd.wave': + case 'audio/wav': + case 'audio/wave': + case 'audio/x-wav': + case 'audio/x-pn-wav': + $mimeType = 'audio/wav'; + break; + } + + return $this->accountFile->content_type == $mimeType; + } + + public function message() + { + return __('The file should have the declared mime-type'); + } +} diff --git a/flexiapi/app/Rules/IsNotPhoneNumber.php b/flexiapi/app/Rules/IsNotPhoneNumber.php index cd27155..c06e1c8 100644 --- a/flexiapi/app/Rules/IsNotPhoneNumber.php +++ b/flexiapi/app/Rules/IsNotPhoneNumber.php @@ -3,7 +3,6 @@ namespace App\Rules; use Illuminate\Contracts\Validation\Rule; -use Respect\Validation\Validator; use Propaganistas\LaravelPhone\PhoneNumber; class IsNotPhoneNumber implements Rule diff --git a/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php b/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php new file mode 100644 index 0000000..cedde59 --- /dev/null +++ b/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php @@ -0,0 +1,32 @@ +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->dateTime('sending_by_mail_at')->nullable(); + $table->integer('sending_by_mail_tryouts')->default(0); + $table->dateTime('sent_by_mail_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_files'); + } +}; diff --git a/flexiapi/lang/fr.json b/flexiapi/lang/fr.json index 47a70d5..ab99cfd 100644 --- a/flexiapi/lang/fr.json +++ b/flexiapi/lang/fr.json @@ -101,6 +101,7 @@ "Features": "Fonctionnalités", "Fill the related columns if you want to add an external account as well": "Remplissez également les colonnes suivantes si vous souhaitez ajouter un compte externe", "Fill to change": "Remplir pour changer", + "Files": "Fichiers", "From": "Depuis", "Hello":"Bonjour", "Host": "Hôte", @@ -145,6 +146,7 @@ "New newsletter subscription": "Nouvelle inscription à votre newsletter", "New Space": "Nouvel Espace", "New user": "Nouvel utilisateur", + "New voice message from :sipfrom": "Nouveau message vocal de :sipfrom", "Newsletter registration email address": "Addresse email d'inscription à la liste de diffusion", "Next": "Suivant", "No account yet?": "Pas encore de compte ?", @@ -170,6 +172,7 @@ "QR Code scanning": "Scan de QR Code", "Realm": "Royaume", "Recover your account using your email": "Récupérer votre compte avec votre email", + "Recorded at": "Enregistré le", "Register": "Inscription", "Registrar": "Registrar", "Registration confirmed": "Confirmation de l'inscription", @@ -246,6 +249,7 @@ "Verify": "Vérifier", "Via": "Via", "Visit our user guide" : "Consulter notre guide utilisateur", + "Voicemails": "Messages vocaux", "We are pleased to inform you that your account has been successfully created.":"Nous avons le plaisir de vous informer que votre compte a été créé avec succès.", "We inform you that this space will expire on :date, in accordance with the expiration date defined in your subscription.":"Nous vous informons que l'espace expira le :date, conformément à la date d’expiration définie dans votre abonnement.", "We received a request to recover your account on :space": "Nous avons reçu une demande de récupération de votre compte sur :space", diff --git a/flexiapi/phpmd.xml b/flexiapi/phpmd.xml index 953c29e..0b70f75 100644 --- a/flexiapi/phpmd.xml +++ b/flexiapi/phpmd.xml @@ -10,7 +10,6 @@ The Clean Code ruleset contains rules that enforce a clean code base. This includes rules from SOLID and object calisthenics. - diff --git a/flexiapi/public/css/style.css b/flexiapi/public/css/style.css index ac81b65..429ce8c 100644 --- a/flexiapi/public/css/style.css +++ b/flexiapi/public/css/style.css @@ -190,6 +190,11 @@ body.welcome content { } } +audio { + border-radius: 1rem; + max-width: 12rem; +} + hr { border-bottom: 1px solid var(--grey-3); margin: 2rem 0; diff --git a/flexiapi/resources/views/admin/account/show.blade.php b/flexiapi/resources/views/admin/account/show.blade.php index 6a3a36b..027e4ef 100644 --- a/flexiapi/resources/views/admin/account/show.blade.php +++ b/flexiapi/resources/views/admin/account/show.blade.php @@ -177,10 +177,9 @@ - @endif -
+

{{ __('Devices') }}

@@ -212,6 +211,53 @@
+
+

+ {{ __('Voicemails') }} +

+ + + + + + + + + @if ($account->uploadedVoicemails->isEmpty()) + + + + @endif + @foreach ($account->uploadedVoicemails as $voicemail) + + + + + @endforeach + +
{{ __('Created') }}
{{ __('Empty') }}
+ {{ $voicemail->created_at }} + @if ($voicemail->url) + + + + @endif + @if ($voicemail->sip_from) +
+ {{ $voicemail->sip_from }} + @endif +
+ @if ($voicemail->url) + + + + + @endif +
+
+
diff --git a/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php b/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php new file mode 100644 index 0000000..7e4f505 --- /dev/null +++ b/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php @@ -0,0 +1,57 @@ +## Voicemails + +### `GET /accounts/{id/me}/voicemails` +Admin +User + +Return the currently stored voicemails + +### `GET /accounts/{id/me}/voicemails/{uuid}` +Admin +User + +``` +{ + 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` +Admin +User + +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}` +Admin +User + +Delete a stored voicemail, if the file is managed by the platform it will be deleted as well \ No newline at end of file diff --git a/flexiapi/resources/views/api/documentation_markdown.blade.php b/flexiapi/resources/views/api/documentation_markdown.blade.php index 3a2ca99..0373ae1 100644 --- a/flexiapi/resources/views/api/documentation_markdown.blade.php +++ b/flexiapi/resources/views/api/documentation_markdown.blade.php @@ -39,6 +39,23 @@ Returns `pong` @include('api.documentation.accounts.vcards_storage') +@include('api.documentation.accounts.voicemail') + +## File Upload + +### `POST /files/{uuid}` +User + +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` diff --git a/flexiapi/resources/views/mails/voicemail.blade.php b/flexiapi/resources/views/mails/voicemail.blade.php new file mode 100644 index 0000000..3bd7487 --- /dev/null +++ b/flexiapi/resources/views/mails/voicemail.blade.php @@ -0,0 +1,14 @@ +@extends('mails.layout') + +@section('content') +# {{ __('New voice message from :sipfrom', ['sipfrom' => $accountFile->sip_from]) }} + +{{ __('New voice message') }} + +{{ __('From') }}: {{ $accountFile->sip_from }} + +{{ __('To') }}: {{ $accountFile->account->identifier }} + +{{ __('Recorded at') }}: {{ $accountFile->created_at->toDateTimeString() }} (UTC) + +@endsection diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php index 8427df4..5ed31f0 100644 --- a/flexiapi/routes/api.php +++ b/flexiapi/routes/api.php @@ -25,17 +25,20 @@ use App\Http\Controllers\Api\Account\CreationRequestToken; use App\Http\Controllers\Api\Account\CreationTokenController; use App\Http\Controllers\Api\Account\DeviceController; use App\Http\Controllers\Api\Account\EmailController; +use App\Http\Controllers\Api\Account\FileController; use App\Http\Controllers\Api\Account\PasswordController; use App\Http\Controllers\Api\Account\PhoneController; use App\Http\Controllers\Api\Account\PushNotificationController; use App\Http\Controllers\Api\Account\RecoveryTokenController; 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 as AdminContactController; use App\Http\Controllers\Api\Admin\Account\CreationTokenController as AdminCreationTokenController; 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\ExternalAccountController; use App\Http\Controllers\Api\Admin\MessageController; @@ -76,6 +79,8 @@ Route::get('accounts/me/api_key/{auth_token}', [ApiKeyController::class, 'genera Route::get('phone_countries', [PhoneCountryController::class, 'index']); Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blocked']], function () { + Route::post('files/{uuid}', [FileController::class, 'upload'])->name('file.upload'); + Route::get('accounts/auth_token/{auth_token}/attach', [AuthTokenController::class, 'attach']); Route::post('account_creation_tokens/consume', [CreationTokenController::class, 'consume']); @@ -105,6 +110,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo Route::get('contacts', [ContactController::class, 'index']); Route::apiResource('vcards-storage', VcardsStorageController::class); + Route::apiResource('voicemails', VoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]); }); Route::group(['middleware' => ['auth.admin']], function () { @@ -174,6 +180,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/{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 () { diff --git a/flexiapi/routes/console.php b/flexiapi/routes/console.php index def2870..e91e885 100644 --- a/flexiapi/routes/console.php +++ b/flexiapi/routes/console.php @@ -1,4 +1,8 @@ daily(); +Schedule::command(SendVoicemailsEmails::class)->everyMinute(); diff --git a/flexiapi/routes/web.php b/flexiapi/routes/web.php index f3303d2..a2af9ad 100644 --- a/flexiapi/routes/web.php +++ b/flexiapi/routes/web.php @@ -26,6 +26,7 @@ use App\Http\Controllers\Account\ContactVcardController; use App\Http\Controllers\Account\CreationRequestTokenController; use App\Http\Controllers\Account\DeviceController; use App\Http\Controllers\Account\EmailController; +use App\Http\Controllers\Account\FileController; use App\Http\Controllers\Account\PasswordController; use App\Http\Controllers\Account\PhoneController; use App\Http\Controllers\Account\ProvisioningController; @@ -39,6 +40,7 @@ use App\Http\Controllers\Admin\Account\CardDavCredentialsController; use App\Http\Controllers\Admin\Account\ContactController; use App\Http\Controllers\Admin\Account\DeviceController as AdminAccountDeviceController; use App\Http\Controllers\Admin\Account\DictionaryController; +use App\Http\Controllers\Admin\Account\FileController as AdminFileController; use App\Http\Controllers\Admin\Account\ImportController; use App\Http\Controllers\Admin\Account\StatisticsController as AdminAccountStatisticsController; use App\Http\Controllers\Admin\Account\TypeController; @@ -76,7 +78,13 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () { }); }); +Route::name('file.')->prefix('files')->controller(FileController::class)->group(function () { + Route::get('{uuid}/{name}', 'show')->name('show'); + Route::get('{uuid}/{name}/download', 'download')->name('download'); +}); + Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key']], function () { + Route::get('provisioning/me', [ProvisioningController::class, 'me'])->name('provisioning.me'); // vCard 4.0 @@ -338,6 +346,11 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () { Route::get('call_logs', 'showCallLogs')->name('show_call_logs'); Route::post('/', 'edit')->name('edit'); }); + + Route::name('file.')->prefix('{account}/files')->controller(AdminFileController::class)->group(function () { + Route::get('{file_id}/delete', 'delete')->name('delete'); + Route::delete('{file_id}', 'destroy')->name('destroy'); + }); }); }); }); diff --git a/flexiapi/tests/Feature/ApiSpaceTest.php b/flexiapi/tests/Feature/ApiSpaceTest.php index 4300988..4b840be 100644 --- a/flexiapi/tests/Feature/ApiSpaceTest.php +++ b/flexiapi/tests/Feature/ApiSpaceTest.php @@ -21,7 +21,6 @@ namespace Tests\Feature; use App\Account; use App\Space; -use Carbon\Carbon; use Tests\TestCase; class ApiSpaceTest extends TestCase diff --git a/flexiapi/tests/Feature/ApiVoicemailTest.php b/flexiapi/tests/Feature/ApiVoicemailTest.php new file mode 100644 index 0000000..6723372 --- /dev/null +++ b/flexiapi/tests/Feature/ApiVoicemailTest.php @@ -0,0 +1,175 @@ +. +*/ + +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'])->dump()->assertOk(); + + // Delete the file + $this->keyAuthenticated($account) + ->delete($this->route . '/' . $file->json()['id']) + ->assertOk(); + + $this->head($file->json()['download_url'])->assertNotFound(); + + /* To try out with a real file + $accountFile = $this->keyAuthenticated($account) + ->json('POST', $this->route, [ + 'content_type' => 'audio/wav' + ])->assertCreated(); + + $uuid = $accountFile->json()['id']; + + $this->keyAuthenticated($account) + ->json('POST', $this->uploadRoute . $uuid, data: [ + 'file' => new UploadedFile( + storage_path("audio.wav"), + 'audio.wav', + test: true, + ) + ])->assertOk(); + */ + } +} diff --git a/flexisip-account-manager.spec b/flexisip-account-manager.spec index b3ae4fa..4df2d0e 100644 --- a/flexisip-account-manager.spec +++ b/flexisip-account-manager.spec @@ -54,6 +54,7 @@ cp flexiapi/composer.json "$RPM_BUILD_ROOT%{opt_dir}/flexiapi" cp README* "$RPM_BUILD_ROOT%{opt_dir}/" cp INSTALL* "$RPM_BUILD_ROOT%{opt_dir}/" mkdir -p $RPM_BUILD_ROOT/etc/cron.daily +mkdir -p $RPM_BUILD_ROOT/etc/cron.d mkdir -p $RPM_BUILD_ROOT%{apache_conf_path} cp httpd/flexisip-account-manager.conf "$RPM_BUILD_ROOT%{apache_conf_path}/" @@ -66,6 +67,13 @@ cp httpd/flexisip-account-manager.conf "$RPM_BUILD_ROOT%{apache_conf_path}/" chmod +x "$RPM_BUILD_ROOT/etc/cron.daily/flexiapi.redhat" %endif +cp cron/flexiapi.cron "$RPM_BUILD_ROOT/etc/cron.d/flexiapi" +chmod +x "$RPM_BUILD_ROOT/etc/cron.d/flexiapi" + +%if %{with deb} + sed 's/apache/www-data/g' "$RPM_BUILD_ROOT/etc/cron.d/flexiapi" +%endif + # POST INSTALLATION %posttrans @@ -161,6 +169,7 @@ fi %exclude %{opt_dir}/flexiapi/storage/ +%config /etc/cron.d/flexiapi %config(noreplace) %{apache_conf_path}/flexisip-account-manager.conf %if %{with deb} %config(noreplace) /etc/cron.daily/flexiapi.debian