Fix FLEXIAPI-164 Add vcards-storage endpoints

This commit is contained in:
Timothée Jaussoin 2024-04-24 13:41:43 +00:00
parent faf33f5ac3
commit debf668e77
13 changed files with 681 additions and 18 deletions

View file

@ -2,6 +2,7 @@
v1.5
----
- Fix FLEXIAPI-164 Add vcards-storage endpoints
- Fix FLEXIAPI-162 Drop the aliases table and migrate the data to the phone column
- Fix FLEXIAPI-161 Complete the Dictionary tests to cover the collection accessor
- Fix FLEXIAPI-158 Restrict the phone number change API endpoint to return 403 if the account doesn't have a validated Account Creation Token

View file

@ -130,6 +130,11 @@ class Account extends Authenticatable
return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id');
}
public function vcardsStorage()
{
return $this->hasMany(VcardStorage::class);
}
public function contactsLists()
{
return $this->belongsToMany(ContactsList::class, 'account_contacts_list', 'account_id', 'contacts_list_id');

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class VcardsStorageController extends Controller
{
public function index(Request $request)
{
if ($this->vcardRequested($request)) {
$vcards = '';
foreach ($request->user()->vcardsStorage()->get() as $vcard) {
$vcards .= $vcard->vcard . "\n";
}
return $vcards;
}
abort(404);
}
public function show(Request $request, string $uuid)
{
return ($this->vcardRequested($request))
? $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail()->vcard
: abort(404);
}
private function vcardRequested(Request $request): bool
{
return $request->hasHeader('content-type') == 'text/vcard'
&& $request->hasHeader('accept') == 'text/vcard';
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use App\Rules\Vcard;
use App\VcardStorage;
use Illuminate\Http\Request;
use Sabre\VObject;
class VcardsStorageController extends Controller
{
public function index(Request $request)
{
return $request->user()->vcardsStorage()->get()->keyBy('uuid');
}
public function show(Request $request, string $uuid)
{
return $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail();
}
public function store(Request $request)
{
$request->validate([
'vcard' => ['required', new Vcard()]
]);
$vcardo = VObject\Reader::read($request->get('vcard'));
if ($request->user()->vcardsStorage()->where('uuid', $vcardo->UID)->first()) {
abort(409, 'Vcard already exists');
}
$vcard = new VcardStorage();
$vcard->account_id = $request->user()->id;
$vcard->uuid = $vcardo->UID;
$vcard->vcard = preg_replace('/\r\n?/', "\n", $vcardo->serialize());
$vcard->save();
return $vcard->vcard;
}
public function update(Request $request, string $uuid)
{
$request->validate([
'vcard' => ['required', new Vcard()]
]);
$vcardo = VObject\Reader::read($request->get('vcard'));
if ($vcardo->UID != $uuid) {
abort(422, 'UUID should be the same');
}
$vcard = $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail();
$vcard->vcard = preg_replace('/\r\n?/', "\n", $vcardo->serialize());
$vcard->save();
return $vcard->vcard;
}
public function destroy(Request $request, string $uuid)
{
$vcard = $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail();
return $vcard->delete();
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Rules;
use Sabre\VObject;
use Illuminate\Contracts\Validation\Rule;
class Vcard implements Rule
{
private $message = null;
public function __construct()
{
//
}
public function passes($attribute, $value): bool
{
try {
$vcard = VObject\Reader::read($value);
if (!empty($vcard->validate())) return false;
if ($vcard->UID == null) return false;
return true;
} catch (\Throwable $th) {
return false;
}
}
public function message()
{
return 'Invalid vcard passed';
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class VcardStorage extends Model
{
use HasFactory;
protected $table = 'vcards_storage';
protected $hidden = ['id', 'account_id', 'uuid'];
public function account()
{
return $this->belongsTo(Account::class);
}
}

View file

@ -22,6 +22,7 @@
"phpunit/phpunit": "^9.6",
"react/socket": "^1.14",
"respect/validation": "^2.2",
"sabre/vobject": "^4.5",
"scyllaly/hcaptcha": "^4.4"
},
"require-dev": {

263
flexiapi/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "daf1c028cc9a6eb9b5d28ba1bd25cec5",
"content-hash": "e9d79f33a899fd2dcde3de88c243e800",
"packages": [
{
"name": "awobaz/compoships",
@ -2716,16 +2716,16 @@
},
{
"name": "monolog/monolog",
"version": "2.9.2",
"version": "2.9.3",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f"
"reference": "a30bfe2e142720dfa990d0a7e573997f5d884215"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/437cb3628f4cf6042cc10ae97fc2b8472e48ca1f",
"reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/a30bfe2e142720dfa990d0a7e573997f5d884215",
"reference": "a30bfe2e142720dfa990d0a7e573997f5d884215",
"shasum": ""
},
"require": {
@ -2746,8 +2746,8 @@
"mongodb/mongodb": "^1.8",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"phpspec/prophecy": "^1.15",
"phpstan/phpstan": "^0.12.91",
"phpunit/phpunit": "^8.5.14",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.5.38 || ^9.6.19",
"predis/predis": "^1.1 || ^2.0",
"rollbar/rollbar": "^1.3 || ^2 || ^3",
"ruflin/elastica": "^7",
@ -2802,7 +2802,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/2.9.2"
"source": "https://github.com/Seldaek/monolog/tree/2.9.3"
},
"funding": [
{
@ -2814,7 +2814,7 @@
"type": "tidelift"
}
],
"time": "2023-10-27T15:25:26+00:00"
"time": "2024-04-12T20:52:51+00:00"
},
{
"name": "myclabs/deep-copy",
@ -5400,6 +5400,239 @@
},
"time": "2023-02-15T01:05:24+00:00"
},
{
"name": "sabre/uri",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/uri.git",
"reference": "1774043c843f1db7654ecc93368a98be29b07544"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/uri/zipball/1774043c843f1db7654ecc93368a98be29b07544",
"reference": "1774043c843f1db7654ecc93368a98be29b07544",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.17",
"phpstan/extension-installer": "^1.3",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-strict-rules": "^1.5",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/functions.php"
],
"psr-4": {
"Sabre\\Uri\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
}
],
"description": "Functions for making sense out of URIs.",
"homepage": "http://sabre.io/uri/",
"keywords": [
"rfc3986",
"uri",
"url"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/uri/issues",
"source": "https://github.com/fruux/sabre-uri"
},
"time": "2023-06-09T07:04:02+00:00"
},
{
"name": "sabre/vobject",
"version": "4.5.4",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/vobject.git",
"reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772",
"reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2.17.1",
"phpstan/phpstan": "^0.12",
"phpunit/php-invoker": "^2.0 || ^3.1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.0"
},
"suggest": {
"hoa/bench": "If you would like to run the benchmark scripts"
},
"bin": [
"bin/vobject",
"bin/generate_vcards"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabre\\VObject\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Dominik Tobschall",
"email": "dominik@fruux.com",
"homepage": "http://tobschall.de/",
"role": "Developer"
},
{
"name": "Ivan Enderlin",
"email": "ivan.enderlin@hoa-project.net",
"homepage": "http://mnt.io/",
"role": "Developer"
}
],
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
"homepage": "http://sabre.io/vobject/",
"keywords": [
"availability",
"freebusy",
"iCalendar",
"ical",
"ics",
"jCal",
"jCard",
"recurrence",
"rfc2425",
"rfc2426",
"rfc2739",
"rfc4770",
"rfc5545",
"rfc5546",
"rfc6321",
"rfc6350",
"rfc6351",
"rfc6474",
"rfc6638",
"rfc6715",
"rfc6868",
"vCalendar",
"vCard",
"vcf",
"xCal",
"xCard"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject"
},
"time": "2023-11-09T12:54:37+00:00"
},
{
"name": "sabre/xml",
"version": "4.0.5",
"source": {
"type": "git",
"url": "https://github.com/sabre-io/xml.git",
"reference": "c29e49fcf9ca8ca058b1e350ee9abe4205c0de89"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sabre-io/xml/zipball/c29e49fcf9ca8ca058b1e350ee9abe4205c0de89",
"reference": "c29e49fcf9ca8ca058b1e350ee9abe4205c0de89",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"lib-libxml": ">=2.6.20",
"php": "^7.4 || ^8.0",
"sabre/uri": ">=2.0,<4.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.51",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"autoload": {
"files": [
"lib/Deserializer/functions.php",
"lib/Serializer/functions.php"
],
"psr-4": {
"Sabre\\Xml\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage": "http://evertpot.com/",
"role": "Developer"
},
{
"name": "Markus Staab",
"email": "markus.staab@redaxo.de",
"role": "Developer"
}
],
"description": "sabre/xml is an XML library that you may not hate.",
"homepage": "https://sabre.io/xml/",
"keywords": [
"XMLReader",
"XMLWriter",
"dom",
"xml"
],
"support": {
"forum": "https://groups.google.com/group/sabredav-discuss",
"issues": "https://github.com/sabre-io/xml/issues",
"source": "https://github.com/fruux/sabre-xml"
},
"time": "2024-04-18T10:44:25+00:00"
},
{
"name": "scyllaly/hcaptcha",
"version": "4.4.5",
@ -9014,16 +9247,16 @@
"packages-dev": [
{
"name": "barryvdh/laravel-debugbar",
"version": "v3.13.3",
"version": "v3.13.4",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git",
"reference": "241e9bddb04ab42a04a5fe8b2b9654374c864229"
"reference": "00201bcd1eaf9b1d3debddcdc13c219e4835fb61"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/241e9bddb04ab42a04a5fe8b2b9654374c864229",
"reference": "241e9bddb04ab42a04a5fe8b2b9654374c864229",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/00201bcd1eaf9b1d3debddcdc13c219e4835fb61",
"reference": "00201bcd1eaf9b1d3debddcdc13c219e4835fb61",
"shasum": ""
},
"require": {
@ -9082,7 +9315,7 @@
],
"support": {
"issues": "https://github.com/barryvdh/laravel-debugbar/issues",
"source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.13.3"
"source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.13.4"
},
"funding": [
{
@ -9094,7 +9327,7 @@
"type": "github"
}
],
"time": "2024-04-04T02:42:49+00:00"
"time": "2024-04-10T09:15:45+00:00"
},
{
"name": "composer/pcre",

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('vcards_storage', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->index();
$table->text('vcard');
$table->integer('account_id')->unsigned();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('cascade');
$table->unique(['account_id', 'uuid']);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('vcards_storage');
}
};

View file

@ -436,6 +436,41 @@ Return the user contacts.
Return a user contact.
## Account vCards storage
### `POST /accounts/me/vcards-storage`
<span class="badge badge-info">User</span>
Store a vCard.
JSON parameters:
* `vcard`, mandatory, a valid vCard having a mandatory `UID` parameter that is uniquelly identifying it. This `UID` parameter will then be used to manipulate the vcard through the following endpoints as `uuid`.
### `PUT /accounts/me/vcards-storage/{uuid}`
<span class="badge badge-info">User</span>
Update a vCard.
JSON parameters:
* `vcard`, mandatory, a valid vCard having a mandatory `UID` parameter that is uniquelly identifying it and is the same as the `uuid` parameter.
### `GET /accounts/me/vcards-storage`
<span class="badge badge-info">User</span>
Return the list of stored vCards
### `GET /accounts/me/vcards-storage/{uuid}`
<span class="badge badge-info">User</span>
Return a stored vCard
### `DELETE /accounts/me/vcards-storage/{uuid}`
<span class="badge badge-info">User</span>
Delete a stored vCard
## Contacts
### `GET /accounts/{id}/contacts`
@ -724,4 +759,24 @@ END:VCARD
### `GET /contacts/vcard/{sip}`
<span class="badge badge-info">User</span>
Return a specific user authenticated contact, in [vCard 4.0 format](https://datatracker.ietf.org/doc/html/rfc6350).
Return a specific user authenticated contact, in [vCard 4.0 format](https://datatracker.ietf.org/doc/html/rfc6350).
## vCards Storage
The following headers are mandatory to access the following endpoints:
```
> content-type: text/vcard
> accept: text/vcard
```
### `GET /vcards-storage`
<span class="badge badge-info">User</span>
Return the list of stored vCards
### `GET /vcards-storage/{uuid}`
<span class="badge badge-info">User</span>
Return a stored vCard

View file

@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
use App\Http\Controllers\Api\Account\VcardsStorageController;
use App\Http\Controllers\Api\Admin\AccountActionController;
use App\Http\Controllers\Api\Admin\AccountContactController;
use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
@ -78,6 +79,8 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::get('contacts/{sip}', 'Api\Account\ContactController@show');
Route::get('contacts', 'Api\Account\ContactController@index');
Route::apiResource('vcards-storage', VcardsStorageController::class);
});
Route::group(['middleware' => ['auth.admin']], function () {

View file

@ -39,7 +39,6 @@ use App\Http\Controllers\Admin\AccountStatisticsController;
use App\Http\Controllers\Admin\ContactsListController;
use App\Http\Controllers\Admin\ContactsListContactController;
use App\Http\Controllers\Admin\StatisticsController;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Support\Facades\Route;
Route::redirect('/', 'login')->name('account.home');
@ -60,9 +59,13 @@ Route::group(['middleware' => 'web_panel_enabled'], function () {
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key']], function () {
Route::get('provisioning/me', 'Account\ProvisioningController@me')->name('provisioning.me');
// Vcard 4.0
// vCard 4.0
Route::get('contacts/vcard/{sip}', 'Account\ContactVcardController@show')->name('account.contacts.vcard.show');
Route::get('contacts/vcard', 'Account\ContactVcardController@index')->name('account.contacts.vcard.index');
// vCards Storage
Route::get('vcards-storage/{uuid}', 'Account\VcardsStorageController@show')->name('account.vcards-storage.show');
Route::get('vcards-storage/', 'Account\VcardsStorageController@index')->name('account.vcards-storage.index');
});
Route::name('provisioning.')->prefix('provisioning')->controller(ProvisioningController::class)->group(function () {

View file

@ -0,0 +1,171 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2024 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 Tests\TestCase;
class ApiAccountVcardsStorageTest extends TestCase
{
protected $route = '/api/accounts/me/vcards-storage';
protected $method = 'POST';
public function testCrud()
{
$account = Account::factory()->create();
$account->generateApiKey();
$uid = 'urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6';
$lastVcard =
'BEGIN:VCARD
VERSION:4.0
FN:Simone Perreault
UID:' . $uid . '
END:VCARD
';
$uid2 = 'urn:uuid: a5b33443-687c-4d19-bdd0-b30cf76bf96d';
$secondVcard =
'BEGIN:VCARD
VERSION:4.0
FN:Simone Perreault
UID:' . $uid2 . '
END:VCARD
';
// Missing vcard
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'foo' => 'bar'
])
->assertJsonValidationErrors(['vcard']);
// Missing UID
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'vcard' =>
'BEGIN:VCARD
VERSION:4.0
FN:Simon Perreault
END:VCARD'
])->assertJsonValidationErrors(['vcard']);
// Create
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'vcard' =>
'BEGIN:VCARD
VERSION:4.0
FN:Simon Perreault
UID:' . $uid . '
END:VCARD'
])->assertStatus(200);
// Again...
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'vcard' =>
'BEGIN:VCARD
VERSION:4.0
FN:Simon Perreault
UID:' . $uid . '
END:VCARD'
])->assertStatus(409);
$this->keyAuthenticated($account)
->json($this->method, $this->route, [
'vcard' => $secondVcard
])->assertStatus(200);
$this->assertDatabaseHas('vcards_storage', [
'uuid' => $uid
]);
$this->assertDatabaseHas('vcards_storage', [
'uuid' => $uid2
]);
// Update
$this->keyAuthenticated($account)
->json('PUT', $this->route . '/' . $uid, [
'vcard' => $lastVcard
])->assertStatus(200);
// Update with wrong UID
$this->keyAuthenticated($account)
->json('PUT', $this->route . '/' . $uid, [
'vcard' =>
'BEGIN:VCARD
VERSION:4.0
FN:Simone Perreault
UID:123
END:VCARD'
])->assertStatus(422);
// Index
$this->keyAuthenticated($account)
->get($this->route)
->assertStatus(200)
->assertJson([
$uid => ['vcard' => $lastVcard],
$uid2 => ['vcard' => $secondVcard]
]);
// Get
$this->keyAuthenticated($account)
->get($this->route . '/' . $uid)
->assertStatus(200)
->assertJson([
'vcard' => $lastVcard
]);
// Vcard format endpoints
$this->keyAuthenticated($account)
->get('vcards-storage')
->assertStatus(404);
$this->keyAuthenticated($account)
->withHeaders([
'content-type' => 'text/vcard',
'accept' => 'text/vcard',
])
->get('vcards-storage')
->assertStatus(200)
->assertSee($lastVcard)
->assertSee($secondVcard);
$this->keyAuthenticated($account)
->withHeaders([
'content-type' => 'text/vcard',
'accept' => 'text/vcard',
])
->get('vcards-storage/' . $uid)
->assertStatus(200)
->assertSee($lastVcard);
// Delete
$this->keyAuthenticated($account)
->delete($this->route . '/' . $uid)
->assertStatus(200);
$this->keyAuthenticated($account)
->get($this->route . '/' . $uid)
->assertStatus(404);
}
}