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') }}
+
+
+
+
+ | {{ __('Created') }} |
+ |
+
+
+
+ @if ($account->uploadedVoicemails->isEmpty())
+
+ | {{ __('Empty') }} |
+
+ @endif
+ @foreach ($account->uploadedVoicemails as $voicemail)
+
+
+ {{ $voicemail->created_at }}
+ @if ($voicemail->url)
+
+
+
+ @endif
+ @if ($voicemail->sip_from)
+
+ {{ $voicemail->sip_from }}
+ @endif
+ |
+
+ @if ($voicemail->url)
+
+
+
+
+ @endif
+ |
+
+ @endforeach
+
+
+
+
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