Fix FLEXIAPI-366 Add voicemails endpoints

This commit is contained in:
Timothée Jaussoin 2025-12-11 16:27:32 +00:00
parent 09d386a303
commit 4d601c4a9c
40 changed files with 841 additions and 47 deletions

1
cron/flexiapi.cron Normal file
View file

@ -0,0 +1 @@
* * * * * apache /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/artisan schedule:run >> /dev/null 2>&1

View file

@ -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

View file

@ -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 spaces:expiration-emails
php artisan app:clear-statistics 30 --apply
php artisan digest:clear-nonces 60
php artisan spaces:expiration-emails

View file

@ -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);

View file

@ -0,0 +1,74 @@
<?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', 'sending_by_mail_at', 'sent_by_mail_at', 'sending_by_mail_tryouts'];
protected $appends = ['download_url'];
protected $casts = [
'uploaded_at' => '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);
}
}

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

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands\Accounts;
use App\AccountFile;
use App\Mail\Voicemail;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendVoicemailsEmails extends Command
{
protected $signature = 'accounts:send-voicemails-emails {--tryout}';
protected $description = 'Send the voicemail emails';
public function handle()
{
$voicemails = AccountFile::whereNotNull('uploaded_at')
->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();
}
}
}

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,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);
}
/**

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Account;
use App\AccountFile;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller
{
public function show(string $uuid, string $name)
{
$file = AccountFile::findOrFail($uuid);
if ($file->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);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Admin\Account;
use App\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class FileController extends Controller
{
public function delete(int $accountId, string $fileId)
{
$account = Account::findOrFail($accountId);
$file = $account->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');
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\AccountFile;
use App\Http\Controllers\Controller;
use App\Rules\AudioMime;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class FileController extends Controller
{
public function upload(Request $request, string $uuid)
{
$file = AccountFile::findOrFail($uuid);
if (!empty($file->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);
}
}

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,49 @@
<?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

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\AccountFile;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Mail\Mailables\Attachment;
class Voicemail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public AccountFile $accountFile)
{
}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->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)
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Rules;
use App\AccountFile;
use Illuminate\Contracts\Validation\Rule;
class AudioMime implements Rule
{
public function __construct(private AccountFile $accountFile)
{
}
public function passes($attribute, $file): bool
{
$mimeType = null;
switch ($file->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');
}
}

View file

@ -3,7 +3,6 @@
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Respect\Validation\Validator;
use Propaganistas\LaravelPhone\PhoneNumber;
class IsNotPhoneNumber implements Rule

View file

@ -0,0 +1,32 @@
<?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->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');
}
};

View file

@ -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 dexpiration 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",

View file

@ -10,7 +10,6 @@
The Clean Code ruleset contains rules that enforce a clean code base. This includes rules from SOLID and object calisthenics.
</description>
<rule ref="rulesets/cleancode.xml/ElseExpression" />
<rule ref="rulesets/controversial.xml/CamelCaseClassName" />
<rule ref="rulesets/controversial.xml/CamelCasePropertyName" />
<rule ref="rulesets/controversial.xml/CamelCaseMethodName" />

View file

@ -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;

View file

@ -177,10 +177,9 @@
</tbody>
</table>
</div>
@endif
<div class="card large">
<div class="card">
<h3>
{{ __('Devices') }}
</h3>
@ -212,6 +211,53 @@
</table>
</div>
<div class="card">
<h3>
{{ __('Voicemails') }}
</h3>
<table>
<thead>
<tr>
<th>{{ __('Created') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@if ($account->uploadedVoicemails->isEmpty())
<tr class="empty">
<td colspan="2">{{ __('Empty') }}</td>
</tr>
@endif
@foreach ($account->uploadedVoicemails as $voicemail)
<tr>
<td>
{{ $voicemail->created_at }}
@if ($voicemail->url)
<a style="margin-left: 1rem;" href="{{ $voicemail->download_url }}" download>
<i class="ph ph-download"></i>
</a>
@endif
@if ($voicemail->sip_from)
<br/>
<small>{{ $voicemail->sip_from }}</small>
@endif
</td>
<td>
@if ($voicemail->url)
<audio class="oppose" controls src="{{ $voicemail->url }}"></audio>
<a type="button"
class="oppose btn tertiary"
href="{{ route('admin.account.file.delete', [$account, $voicemail->id]) }}">
<i class="ph ph-trash"></i>
</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="card large">
<a class="btn small oppose" href="{{ route('admin.account.dictionary.create', $account) }}">
<i class="ph ph-plus"></i>

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

@ -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

View file

@ -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 () {

View file

@ -1,4 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use App\Console\Commands\Accounts\ClearFiles;
use App\Console\Commands\Accounts\SendVoicemailsEmails;
use Illuminate\Support\Facades\Schedule;
Schedule::command(ClearFiles::class, [7, '--apply'])->daily();
Schedule::command(SendVoicemailsEmails::class)->everyMinute();

View file

@ -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');
});
});
});
});

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,175 @@
<?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'])->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();
*/
}
}

View file

@ -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