mirror of
https://github.com/firefly-iii/firefly-iii.git
synced 2025-09-30 10:33:30 +00:00
Initial code to get providers from Spectre.
This commit is contained in:
77
app/Jobs/GetSpectreProviders.php
Normal file
77
app/Jobs/GetSpectreProviders.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace FireflyIII\Jobs;
|
||||||
|
|
||||||
|
use FireflyIII\Models\Configuration;
|
||||||
|
use FireflyIII\Models\SpectreProvider;
|
||||||
|
use FireflyIII\Services\Spectre\Request\ListProvidersRequest;
|
||||||
|
use FireflyIII\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Log;
|
||||||
|
|
||||||
|
class GetSpectreProviders implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
protected $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(User $user)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
Log::debug('Constructed job GetSpectreProviders');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
/** @var Configuration $configValue */
|
||||||
|
$configValue = app('fireflyconfig')->get('spectre_provider_download', 0);
|
||||||
|
$now = time();
|
||||||
|
if ($now - intval($configValue->data) < 86400) {
|
||||||
|
Log::debug(sprintf('Difference is %d, so will NOT execute job.', ($now - intval($configValue->data))));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log::debug(sprintf('Difference is %d, so will execute job.', ($now - intval($configValue->data))));
|
||||||
|
|
||||||
|
// get user
|
||||||
|
|
||||||
|
// fire away!
|
||||||
|
$request = new ListProvidersRequest($this->user);
|
||||||
|
$request->call();
|
||||||
|
|
||||||
|
// store all providers:
|
||||||
|
$providers = $request->getProviders();
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
// find provider?
|
||||||
|
$dbProvider = SpectreProvider::where('spectre_id', $provider['id'])->first();
|
||||||
|
if (is_null($dbProvider)) {
|
||||||
|
$dbProvider = new SpectreProvider;
|
||||||
|
}
|
||||||
|
// update fields:
|
||||||
|
$dbProvider->spectre_id = $provider['id'];
|
||||||
|
$dbProvider->code = $provider['code'];
|
||||||
|
$dbProvider->mode = $provider['mode'];
|
||||||
|
$dbProvider->status = $provider['status'];
|
||||||
|
$dbProvider->interactive = $provider['interactive'] === 1;
|
||||||
|
$dbProvider->automatic_fetch = $provider['automatic_fetch'] === 1;
|
||||||
|
$dbProvider->country_code = $provider['country_code'];
|
||||||
|
$dbProvider->data = $provider;
|
||||||
|
$dbProvider->save();
|
||||||
|
Log::debug(sprintf('Stored provider #%d under ID #%d', $provider['id'], $dbProvider->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
app('fireflyconfig')->set('spectre_provider_download', time());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
32
app/Models/SpectreProvider.php
Normal file
32
app/Models/SpectreProvider.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Models;
|
||||||
|
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SpectreProvider
|
||||||
|
*/
|
||||||
|
class SpectreProvider extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that should be casted to native types.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $casts
|
||||||
|
= [
|
||||||
|
'spectre_id' => 'int',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
'deleted_at' => 'datetime',
|
||||||
|
'interactive' => 'boolean',
|
||||||
|
'automatic_fetch' => 'boolean',
|
||||||
|
'data' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = ['spectre_id', 'code', 'mode', 'name', 'status', 'interactive', 'automatic_fetch', 'country_code', 'data'];
|
||||||
|
|
||||||
|
}
|
@@ -160,6 +160,9 @@ abstract class BunqRequest
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
protected function getDefaultHeaders(): array
|
protected function getDefaultHeaders(): array
|
||||||
{
|
{
|
||||||
$userAgent = sprintf('FireflyIII v%s', config('firefly.version'));
|
$userAgent = sprintf('FireflyIII v%s', config('firefly.version'));
|
||||||
|
80
app/Services/Spectre/Request/ListProvidersRequest.php
Normal file
80
app/Services/Spectre/Request/ListProvidersRequest.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ListUserRequest.php
|
||||||
|
* Copyright (c) 2017 thegrumpydictator@gmail.com
|
||||||
|
*
|
||||||
|
* This file is part of Firefly III.
|
||||||
|
*
|
||||||
|
* Firefly III is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Firefly III 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 General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Services\Spectre\Request;
|
||||||
|
|
||||||
|
use Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ListUserRequest.
|
||||||
|
*/
|
||||||
|
class ListProvidersRequest extends SpectreRequest
|
||||||
|
{
|
||||||
|
protected $providers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public function call(): void
|
||||||
|
{
|
||||||
|
$hasNextPage = true;
|
||||||
|
$nextId = 0;
|
||||||
|
while ($hasNextPage) {
|
||||||
|
Log::debug(sprintf('Now calling for next_id %d', $nextId));
|
||||||
|
$parameters = ['include_fake_providers' => 'true', 'include_provider_fields' => 'true', 'from_id' => $nextId];
|
||||||
|
$uri = '/api/v3/providers?' . http_build_query($parameters);
|
||||||
|
$response = $this->sendSignedSpectreGet($uri, []);
|
||||||
|
|
||||||
|
// count entries:
|
||||||
|
Log::debug(sprintf('Found %d entries in data-array', count($response['data'])));
|
||||||
|
|
||||||
|
// extract next ID
|
||||||
|
$hasNextPage = false;
|
||||||
|
if (isset($response['meta']['next_id']) && intval($response['meta']['next_id']) > $nextId) {
|
||||||
|
$hasNextPage = true;
|
||||||
|
$nextId = $response['meta']['next_id'];
|
||||||
|
Log::debug(sprintf('Next ID is now %d.', $nextId));
|
||||||
|
} else {
|
||||||
|
Log::debug('No next page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// store providers:
|
||||||
|
foreach ($response['data'] as $providerArray) {
|
||||||
|
$providerId = $providerArray['id'];
|
||||||
|
$this->providers[$providerId] = $providerArray;
|
||||||
|
Log::debug(sprintf('Stored provider #%d', $providerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getProviders(): array
|
||||||
|
{
|
||||||
|
return $this->providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
378
app/Services/Spectre/Request/SpectreRequest.php
Normal file
378
app/Services/Spectre/Request/SpectreRequest.php
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BunqRequest.php
|
||||||
|
* Copyright (c) 2017 thegrumpydictator@gmail.com
|
||||||
|
*
|
||||||
|
* This file is part of Firefly III.
|
||||||
|
*
|
||||||
|
* Firefly III is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Firefly III 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 General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Services\Spectre\Request;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use FireflyIII\Exceptions\FireflyException;
|
||||||
|
use FireflyIII\User;
|
||||||
|
use Log;
|
||||||
|
use Requests;
|
||||||
|
use Requests_Exception;
|
||||||
|
|
||||||
|
//use FireflyIII\Services\Bunq\Object\ServerPublicKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BunqRequest.
|
||||||
|
*/
|
||||||
|
abstract class SpectreRequest
|
||||||
|
{
|
||||||
|
/** @var string */
|
||||||
|
protected $clientId = '';
|
||||||
|
protected $expiresAt = 0;
|
||||||
|
/** @var ServerPublicKey */
|
||||||
|
protected $serverPublicKey;
|
||||||
|
/** @var string */
|
||||||
|
protected $serviceSecret = '';
|
||||||
|
/** @var string */
|
||||||
|
private $privateKey = '';
|
||||||
|
/** @var string */
|
||||||
|
private $server = '';
|
||||||
|
/** @var User */
|
||||||
|
private $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpectreRequest constructor.
|
||||||
|
*/
|
||||||
|
public function __construct(User $user)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->server = config('firefly.spectre.server');
|
||||||
|
$this->expiresAt = time() + 180;
|
||||||
|
$privateKey = app('preferences')->get('spectre_private_key', null);
|
||||||
|
$this->privateKey = $privateKey->data;
|
||||||
|
|
||||||
|
// set client ID
|
||||||
|
$clientId = app('preferences')->get('spectre_client_id', null);
|
||||||
|
$this->clientId = $clientId->data;
|
||||||
|
|
||||||
|
// set service secret
|
||||||
|
$serviceSecret = app('preferences')->get('spectre_service_secret', null);
|
||||||
|
$this->serviceSecret = $serviceSecret->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
abstract public function call(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getClientId(): string
|
||||||
|
{
|
||||||
|
return $this->clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $clientId
|
||||||
|
*/
|
||||||
|
public function setClientId(string $clientId): void
|
||||||
|
{
|
||||||
|
$this->clientId = $clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getServer(): string
|
||||||
|
{
|
||||||
|
return $this->server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ServerPublicKey
|
||||||
|
*/
|
||||||
|
public function getServerPublicKey(): ServerPublicKey
|
||||||
|
{
|
||||||
|
return $this->serverPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ServerPublicKey $serverPublicKey
|
||||||
|
*/
|
||||||
|
public function setServerPublicKey(ServerPublicKey $serverPublicKey)
|
||||||
|
{
|
||||||
|
$this->serverPublicKey = $serverPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getServiceSecret(): string
|
||||||
|
{
|
||||||
|
return $this->serviceSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $serviceSecret
|
||||||
|
*/
|
||||||
|
public function setServiceSecret(string $serviceSecret): void
|
||||||
|
{
|
||||||
|
$this->serviceSecret = $serviceSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $privateKey
|
||||||
|
*/
|
||||||
|
public function setPrivateKey(string $privateKey)
|
||||||
|
{
|
||||||
|
$this->privateKey = $privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $secret
|
||||||
|
*/
|
||||||
|
public function setSecret(string $secret)
|
||||||
|
{
|
||||||
|
$this->secret = $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $method
|
||||||
|
* @param string $uri
|
||||||
|
* @param string $data
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* @throws FireflyException
|
||||||
|
*/
|
||||||
|
protected function generateSignature(string $method, string $uri, string $data): string
|
||||||
|
{
|
||||||
|
if (0 === strlen($this->privateKey)) {
|
||||||
|
throw new FireflyException('No private key present.');
|
||||||
|
}
|
||||||
|
if ('get' === strtolower($method) || 'delete' === strtolower($method)) {
|
||||||
|
$data = '';
|
||||||
|
}
|
||||||
|
// base64(sha1_signature(private_key, "Expires-at|request_method|original_url|post_body|md5_of_uploaded_file|")))
|
||||||
|
// Prepare the signature
|
||||||
|
$toSign = $this->expiresAt . '|' . strtoupper($method) . '|' . $uri . '|' . $data . ''; // no file so no content there.
|
||||||
|
Log::debug(sprintf('String to sign: %s', $toSign));
|
||||||
|
$signature = '';
|
||||||
|
|
||||||
|
// Sign the data
|
||||||
|
openssl_sign($toSign, $signature, $this->privateKey, OPENSSL_ALGO_SHA1);
|
||||||
|
$signature = base64_encode($signature);
|
||||||
|
|
||||||
|
return $signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function getDefaultHeaders(): array
|
||||||
|
{
|
||||||
|
$userAgent = sprintf('FireflyIII v%s', config('firefly.version'));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Client-Id' => $this->getClientId(),
|
||||||
|
'Service-Secret' => $this->getServiceSecret(),
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'Content-type' => 'application/json',
|
||||||
|
'Cache-Control' => 'no-cache',
|
||||||
|
'User-Agent' => $userAgent,
|
||||||
|
'Expires-at' => $this->expiresAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $uri
|
||||||
|
* @param array $headers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function sendSignedBunqDelete(string $uri, array $headers): array
|
||||||
|
{
|
||||||
|
if (0 === strlen($this->server)) {
|
||||||
|
throw new FireflyException('No bunq server defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullUri = $this->server . $uri;
|
||||||
|
$signature = $this->generateSignature('delete', $uri, $headers, '');
|
||||||
|
$headers['X-Bunq-Client-Signature'] = $signature;
|
||||||
|
try {
|
||||||
|
$response = Requests::delete($fullUri, $headers);
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->body;
|
||||||
|
$array = json_decode($body, true);
|
||||||
|
$responseHeaders = $response->headers->getAll();
|
||||||
|
$statusCode = intval($response->status_code);
|
||||||
|
$array['ResponseHeaders'] = $responseHeaders;
|
||||||
|
$array['ResponseStatusCode'] = $statusCode;
|
||||||
|
|
||||||
|
Log::debug(sprintf('Response to DELETE %s is %s', $fullUri, $body));
|
||||||
|
if ($this->isErrorResponse($array)) {
|
||||||
|
$this->throwResponseError($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->verifyServerSignature($body, $responseHeaders, $statusCode)) {
|
||||||
|
throw new FireflyException(sprintf('Could not verify signature for request to "%s"', $uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $uri
|
||||||
|
* @param array $data
|
||||||
|
* @param array $headers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function sendSignedBunqPost(string $uri, array $data, array $headers): array
|
||||||
|
{
|
||||||
|
$body = json_encode($data);
|
||||||
|
$fullUri = $this->server . $uri;
|
||||||
|
$signature = $this->generateSignature('post', $uri, $headers, $body);
|
||||||
|
$headers['X-Bunq-Client-Signature'] = $signature;
|
||||||
|
try {
|
||||||
|
$response = Requests::post($fullUri, $headers, $body);
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->body;
|
||||||
|
$array = json_decode($body, true);
|
||||||
|
$responseHeaders = $response->headers->getAll();
|
||||||
|
$statusCode = intval($response->status_code);
|
||||||
|
$array['ResponseHeaders'] = $responseHeaders;
|
||||||
|
$array['ResponseStatusCode'] = $statusCode;
|
||||||
|
|
||||||
|
if ($this->isErrorResponse($array)) {
|
||||||
|
$this->throwResponseError($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->verifyServerSignature($body, $responseHeaders, $statusCode)) {
|
||||||
|
throw new FireflyException(sprintf('Could not verify signature for request to "%s"', $uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $uri
|
||||||
|
* @param array $data
|
||||||
|
* @param array $headers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
protected function sendSignedSpectreGet(string $uri, array $data): array
|
||||||
|
{
|
||||||
|
if (0 === strlen($this->server)) {
|
||||||
|
throw new FireflyException('No Spectre server defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = $this->getDefaultHeaders();
|
||||||
|
$body = json_encode($data);
|
||||||
|
$fullUri = $this->server . $uri;
|
||||||
|
$signature = $this->generateSignature('get', $fullUri, $body);
|
||||||
|
$headers['Signature'] = $signature;
|
||||||
|
|
||||||
|
Log::debug('Final headers for spectre signed get request:', $headers);
|
||||||
|
try {
|
||||||
|
$response = Requests::get($fullUri, $headers);
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
throw new FireflyException(sprintf('Request Exception: %s', $e->getMessage()));
|
||||||
|
}
|
||||||
|
$statusCode = intval($response->status_code);
|
||||||
|
|
||||||
|
if ($statusCode !== 200) {
|
||||||
|
throw new FireflyException(sprintf('Status code %d: %s', $statusCode, $response->body));
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->body;
|
||||||
|
$array = json_decode($body, true);
|
||||||
|
$responseHeaders = $response->headers->getAll();
|
||||||
|
$array['ResponseHeaders'] = $responseHeaders;
|
||||||
|
$array['ResponseStatusCode'] = $statusCode;
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $uri
|
||||||
|
* @param array $headers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function sendUnsignedBunqDelete(string $uri, array $headers): array
|
||||||
|
{
|
||||||
|
$fullUri = $this->server . $uri;
|
||||||
|
try {
|
||||||
|
$response = Requests::delete($fullUri, $headers);
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]];
|
||||||
|
}
|
||||||
|
$body = $response->body;
|
||||||
|
$array = json_decode($body, true);
|
||||||
|
$responseHeaders = $response->headers->getAll();
|
||||||
|
$statusCode = $response->status_code;
|
||||||
|
$array['ResponseHeaders'] = $responseHeaders;
|
||||||
|
$array['ResponseStatusCode'] = $statusCode;
|
||||||
|
|
||||||
|
if ($this->isErrorResponse($array)) {
|
||||||
|
$this->throwResponseError($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $uri
|
||||||
|
* @param array $data
|
||||||
|
* @param array $headers
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function sendUnsignedBunqPost(string $uri, array $data, array $headers): array
|
||||||
|
{
|
||||||
|
$body = json_encode($data);
|
||||||
|
$fullUri = $this->server . $uri;
|
||||||
|
try {
|
||||||
|
$response = Requests::post($fullUri, $headers, $body);
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
return ['Error' => [0 => ['error_description' => $e->getMessage(), 'error_description_translated' => $e->getMessage()]]];
|
||||||
|
}
|
||||||
|
$body = $response->body;
|
||||||
|
$array = json_decode($body, true);
|
||||||
|
$responseHeaders = $response->headers->getAll();
|
||||||
|
$statusCode = $response->status_code;
|
||||||
|
$array['ResponseHeaders'] = $responseHeaders;
|
||||||
|
$array['ResponseStatusCode'] = $statusCode;
|
||||||
|
|
||||||
|
if ($this->isErrorResponse($array)) {
|
||||||
|
$this->throwResponseError($array);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $array;
|
||||||
|
}
|
||||||
|
}
|
229
app/Support/Import/Prerequisites/SpectrePrerequisites.php
Normal file
229
app/Support/Import/Prerequisites/SpectrePrerequisites.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BunqPrerequisites.php
|
||||||
|
* Copyright (c) 2017 thegrumpydictator@gmail.com
|
||||||
|
*
|
||||||
|
* This file is part of Firefly III.
|
||||||
|
*
|
||||||
|
* Firefly III is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Firefly III 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 General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FireflyIII\Support\Import\Prerequisites;
|
||||||
|
|
||||||
|
use FireflyIII\Exceptions\FireflyException;
|
||||||
|
use FireflyIII\Jobs\GetSpectreProviders;
|
||||||
|
use FireflyIII\Models\Configuration;
|
||||||
|
use FireflyIII\Models\Preference;
|
||||||
|
use FireflyIII\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\MessageBag;
|
||||||
|
use Log;
|
||||||
|
use Preferences;
|
||||||
|
use Requests;
|
||||||
|
use Requests_Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class contains all the routines necessary to connect to Bunq.
|
||||||
|
*/
|
||||||
|
class SpectrePrerequisites implements PrerequisitesInterface
|
||||||
|
{
|
||||||
|
/** @var User */
|
||||||
|
private $user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns view name that allows user to fill in prerequisites. Currently asks for the API key.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getView(): string
|
||||||
|
{
|
||||||
|
return 'import.spectre.prerequisites';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns any values required for the prerequisites-view.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getViewParameters(): array
|
||||||
|
{
|
||||||
|
$publicKey = $this->getPublicKey();
|
||||||
|
$subTitle = strval(trans('bank.spectre_title'));
|
||||||
|
$subTitleIcon = 'fa-archive';
|
||||||
|
|
||||||
|
return compact('publicKey', 'subTitle', 'subTitleIcon');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if this import method has any special prerequisites such as config
|
||||||
|
* variables or other things. The only thing we verify is the presence of the API key. Everything else
|
||||||
|
* tumbles into place: no installation token? Will be requested. No device server? Will be created. Etc.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasPrerequisites(): bool
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
Preferences::getForUser($this->user, 'spectre_client_id', false),
|
||||||
|
Preferences::getForUser($this->user, 'spectre_app_secret', false),
|
||||||
|
Preferences::getForUser($this->user, 'spectre_service_secret', false),
|
||||||
|
];
|
||||||
|
/** @var Preference $value */
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (false === $value->data || null === $value->data) {
|
||||||
|
Log::info(sprintf('Config var "%s" is missing.', $value->name));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log::debug('All prerequisites are here!');
|
||||||
|
|
||||||
|
// at this point, check if all providers are present. Providers are shared amongst
|
||||||
|
// users in a multi-user environment.
|
||||||
|
GetSpectreProviders::dispatch($this->user);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the user for this Prerequisites-routine. Class is expected to implement and save this.
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
*/
|
||||||
|
public function setUser(User $user): void
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method responds to the user's submission of an API key. It tries to register this instance as a new Firefly III device.
|
||||||
|
* If this fails, the error is returned in a message bag and the user is notified (this is fairly friendly).
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
*
|
||||||
|
* @return MessageBag
|
||||||
|
*/
|
||||||
|
public function storePrerequisites(Request $request): MessageBag
|
||||||
|
{
|
||||||
|
Log::debug('Storing Spectre API keys..');
|
||||||
|
Preferences::setForUser($this->user, 'spectre_client_id', $request->get('client_id'));
|
||||||
|
Preferences::setForUser($this->user, 'spectre_app_secret', $request->get('app_secret'));
|
||||||
|
Preferences::setForUser($this->user, 'spectre_service_secret', $request->get('service_secret'));
|
||||||
|
Log::debug('Done!');
|
||||||
|
|
||||||
|
return new MessageBag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method creates a new public/private keypair for the user. This isn't really secure, since the key is generated on the fly with
|
||||||
|
* no regards for HSM's, smart cards or other things. It would require some low level programming to get this right. But the private key
|
||||||
|
* is stored encrypted in the database so it's something.
|
||||||
|
*/
|
||||||
|
private function createKeyPair(): void
|
||||||
|
{
|
||||||
|
Log::debug('Generate new Spectre key pair for user.');
|
||||||
|
$keyConfig = [
|
||||||
|
'digest_alg' => 'sha512',
|
||||||
|
'private_key_bits' => 2048,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
];
|
||||||
|
// Create the private and public key
|
||||||
|
$res = openssl_pkey_new($keyConfig);
|
||||||
|
|
||||||
|
// Extract the private key from $res to $privKey
|
||||||
|
$privKey = '';
|
||||||
|
openssl_pkey_export($res, $privKey);
|
||||||
|
|
||||||
|
// Extract the public key from $res to $pubKey
|
||||||
|
$pubKey = openssl_pkey_get_details($res);
|
||||||
|
|
||||||
|
Preferences::setForUser($this->user, 'spectre_private_key', $privKey);
|
||||||
|
Preferences::setForUser($this->user, 'spectre_public_key', $pubKey['key']);
|
||||||
|
Log::debug('Created key pair');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the private key from the users preferences.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getPrivateKey(): string
|
||||||
|
{
|
||||||
|
Log::debug('get private key');
|
||||||
|
$preference = Preferences::getForUser($this->user, 'spectre_private_key', null);
|
||||||
|
if (null === $preference) {
|
||||||
|
Log::debug('private key is null');
|
||||||
|
// create key pair
|
||||||
|
$this->createKeyPair();
|
||||||
|
}
|
||||||
|
$preference = Preferences::getForUser($this->user, 'spectre_private_key', null);
|
||||||
|
Log::debug('Return private key for user');
|
||||||
|
|
||||||
|
return $preference->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a public key from the users preferences.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getPublicKey(): string
|
||||||
|
{
|
||||||
|
Log::debug('get public key');
|
||||||
|
$preference = Preferences::getForUser($this->user, 'spectre_public_key', null);
|
||||||
|
if (null === $preference) {
|
||||||
|
Log::debug('public key is null');
|
||||||
|
// create key pair
|
||||||
|
$this->createKeyPair();
|
||||||
|
}
|
||||||
|
$preference = Preferences::getForUser($this->user, 'spectre_public_key', null);
|
||||||
|
Log::debug('Return public key for user');
|
||||||
|
|
||||||
|
return $preference->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request users server remote IP. Let's assume this value will not change any time soon.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*
|
||||||
|
* @throws FireflyException
|
||||||
|
*/
|
||||||
|
private function getRemoteIp(): string
|
||||||
|
{
|
||||||
|
$preference = Preferences::getForUser($this->user, 'external_ip', null);
|
||||||
|
if (null === $preference) {
|
||||||
|
try {
|
||||||
|
$response = Requests::get('https://api.ipify.org');
|
||||||
|
} catch (Requests_Exception $e) {
|
||||||
|
throw new FireflyException(sprintf('Could not retrieve external IP: %s', $e->getMessage()));
|
||||||
|
}
|
||||||
|
if (200 !== $response->status_code) {
|
||||||
|
throw new FireflyException(sprintf('Could not retrieve external IP: %d %s', $response->status_code, $response->body));
|
||||||
|
}
|
||||||
|
$serverIp = $response->body;
|
||||||
|
Preferences::setForUser($this->user, 'external_ip', $serverIp);
|
||||||
|
|
||||||
|
return $serverIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $preference->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -51,16 +51,26 @@ return [
|
|||||||
],
|
],
|
||||||
'import_pre' => [
|
'import_pre' => [
|
||||||
'bunq' => 'FireflyIII\Support\Import\Prerequisites\BunqPrerequisites',
|
'bunq' => 'FireflyIII\Support\Import\Prerequisites\BunqPrerequisites',
|
||||||
|
'spectre' => 'FireflyIII\Support\Import\Prerequisites\SpectrePrerequisites',
|
||||||
|
'plaid' => 'FireflyIII\Support\Import\Prerequisites\PlairPrerequisites',
|
||||||
],
|
],
|
||||||
'import_info' => [
|
'import_info' => [
|
||||||
'bunq' => 'FireflyIII\Support\Import\Information\BunqInformation',
|
'bunq' => 'FireflyIII\Support\Import\Information\BunqInformation',
|
||||||
|
'spectre' => 'FireflyIII\Support\Import\Information\SpectreInformation',
|
||||||
|
'plaid' => 'FireflyIII\Support\Import\Information\PlaidInformation',
|
||||||
],
|
],
|
||||||
'import_transactions' => [
|
'import_transactions' => [
|
||||||
'bunq' => 'FireflyIII\Support\Import\Transactions\BunqTransactions',
|
'bunq' => 'FireflyIII\Support\Import\Transactions\BunqTransactions',
|
||||||
|
'spectre' => 'FireflyIII\Support\Import\Transactions\SpectreTransactions',
|
||||||
|
'plaid' => 'FireflyIII\Support\Import\Transactions\PlaidTransactions',
|
||||||
],
|
],
|
||||||
'bunq' => [
|
'bunq' => [
|
||||||
'server' => 'https://sandbox.public.api.bunq.com',
|
'server' => 'https://sandbox.public.api.bunq.com',
|
||||||
],
|
],
|
||||||
|
'spectre' => [
|
||||||
|
'server' => 'https://www.saltedge.com',
|
||||||
|
],
|
||||||
|
|
||||||
'default_export_format' => 'csv',
|
'default_export_format' => 'csv',
|
||||||
'default_import_format' => 'csv',
|
'default_import_format' => 'csv',
|
||||||
'bill_periods' => ['weekly', 'monthly', 'quarterly', 'half-year', 'yearly'],
|
'bill_periods' => ['weekly', 'monthly', 'quarterly', 'half-year', 'yearly'],
|
||||||
|
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class ChangesForSpectre extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
// create provider table:
|
||||||
|
if (!Schema::hasTable('spectre_providers')) {
|
||||||
|
Schema::create(
|
||||||
|
'spectre_providers',
|
||||||
|
function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
//'spectre_id', 'code', 'mode', 'name', 'status', 'interactive', 'automatic_fetch', 'country_code', 'data'
|
||||||
|
$table->integer('spectre_id', false, true);
|
||||||
|
$table->string('code', 100);
|
||||||
|
$table->string('mode', 20);
|
||||||
|
$table->string('status', 20);
|
||||||
|
$table->boolean('interactive')->default(0);
|
||||||
|
$table->boolean('automatic_fetch')->default(0);
|
||||||
|
$table->string('country_code', 3);
|
||||||
|
$table->text('data');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
public/images/logos/csv.png
Normal file
BIN
public/images/logos/csv.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
BIN
public/images/logos/plaid.png
Normal file
BIN
public/images/logos/plaid.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.1 KiB |
BIN
public/images/logos/spectre.png
Normal file
BIN
public/images/logos/spectre.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
@@ -5,4 +5,10 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
'bunq_prerequisites_title' => 'Prerequisites for an import from bunq',
|
'bunq_prerequisites_title' => 'Prerequisites for an import from bunq',
|
||||||
'bunq_prerequisites_text' => 'In order to import from bunq, you need to obtain an API key. You can do this through the app.',
|
'bunq_prerequisites_text' => 'In order to import from bunq, you need to obtain an API key. You can do this through the app.',
|
||||||
|
|
||||||
|
// Spectre:
|
||||||
|
'spectre_title' => 'Import using Spectre',
|
||||||
|
'spectre_prerequisites_title' => 'Prerequisites for an import using Spectre',
|
||||||
|
'spectre_prerequisites_text' => 'In order to import data using the Spectre API, you need to prove some secrets. They can be found on the <a href="https://www.saltedge.com/clients/profile/secrets">secrets page</a>.',
|
||||||
|
'spectre_enter_pub_key' => 'The import will only work when you enter this public key on your <a href="https://www.saltedge.com/clients/security/edit">security page</a>.',
|
||||||
];
|
];
|
||||||
|
@@ -186,6 +186,10 @@ return [
|
|||||||
'csv_delimiter' => 'CSV field delimiter',
|
'csv_delimiter' => 'CSV field delimiter',
|
||||||
'csv_import_account' => 'Default import account',
|
'csv_import_account' => 'Default import account',
|
||||||
'csv_config' => 'CSV import configuration',
|
'csv_config' => 'CSV import configuration',
|
||||||
|
'client_id' => 'Client ID',
|
||||||
|
'service_secret' => 'Service secret',
|
||||||
|
'app_secret' => 'App secret',
|
||||||
|
'public_key' => 'Public key',
|
||||||
|
|
||||||
|
|
||||||
'due_date' => 'Due date',
|
'due_date' => 'Due date',
|
||||||
|
@@ -20,21 +20,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-1 text-center">
|
||||||
<p>
|
|
||||||
{# file import #}
|
{# file import #}
|
||||||
<a href="{{ route('import.file.index') }}" class="btn btn-app">
|
<a href="{{ route('import.file.index') }}">
|
||||||
<i class="fa fa-file-text-o"></i>
|
<img src="images/logos/csv.png" alt="bunq"/><br />
|
||||||
{{ 'import_general_index_csv_file'|_ }}
|
{{ 'import_general_index_csv_file'|_ }}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-1 text-center">
|
||||||
{# bunq import #}
|
{# bunq import #}
|
||||||
{#
|
<a href="{{ route('import.bank.prerequisites', ['bunq']) }}">
|
||||||
<a href="{{ route('import.bank.prerequisites', ['bunq']) }}" class="btn btn-app">
|
|
||||||
<img src="images/logos/bunq.png" alt="bunq"/><br />
|
<img src="images/logos/bunq.png" alt="bunq"/><br />
|
||||||
Import from bunq
|
Import from bunq
|
||||||
</a>
|
</a>
|
||||||
#}
|
</div>
|
||||||
</p>
|
<div class="col-lg-1 text-center">
|
||||||
|
{# import from Spectre #}
|
||||||
|
<a href="{{ route('import.bank.prerequisites', ['spectre']) }}">
|
||||||
|
<img src="images/logos/spectre.png" alt="Spectre"/><br />
|
||||||
|
Import using Spectre
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-1 text-center">
|
||||||
|
{# import from Plaid #}
|
||||||
|
<a href="{{ route('import.bank.prerequisites', ['plaid']) }}">
|
||||||
|
<img src="images/logos/plaid.png" alt="Plaid"/><br />
|
||||||
|
Import using Plaid
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
58
resources/views/import/spectre/prerequisites.twig
Normal file
58
resources/views/import/spectre/prerequisites.twig
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "./layout/default" %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{{ Breadcrumbs.renderIfExists }}
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<form class="form-horizontal" action="{{ route('import.bank.prerequisites.post',['spectre']) }}" method="post">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="col-lg-12 col-md-12 col-sm-12">
|
||||||
|
<div class="box box-default">
|
||||||
|
<div class="box-header with-border">
|
||||||
|
<h3 class="box-title">{{ trans('bank.spectre_prerequisites_title') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<p>
|
||||||
|
{{ trans('bank.spectre_prerequisites_text')|raw }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
{{ ExpandedForm.text('client_id') }}
|
||||||
|
{{ ExpandedForm.text('service_secret') }}
|
||||||
|
{{ ExpandedForm.text('app_secret') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<p>{{ trans('bank.spectre_enter_pub_key')|raw }}</p>
|
||||||
|
<div class="form-group" id="pub_key_holder">
|
||||||
|
<label for="ffInput_pub_key_holder" class="col-sm-4 control-label">{{ trans('form.public_key') }}</label>
|
||||||
|
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<textarea class="form-control"
|
||||||
|
rows="10"
|
||||||
|
id="ffInput_pub_key_holder" name="pub_key_holder" contenteditable="false">{{ publicKey }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="box-footer">
|
||||||
|
<button type="submit" class="btn pull-right btn-success">
|
||||||
|
{{ ('submit')|_ }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block styles %}
|
||||||
|
{% endblock %}
|
Reference in New Issue
Block a user