Move integration credentials into encrypted dashboard settings
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m42s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m42s
This commit is contained in:
@@ -20,6 +20,6 @@ class BambuddyController extends Controller
|
||||
$integrationError = 'BamBuddy is unavailable. Check its API key and connection.';
|
||||
}
|
||||
|
||||
return view('printers.index', compact('printers', 'integrationError'));
|
||||
return view('printers.index', compact('printers', 'integrationError') + ['bambuddyUrl' => $bambuddy->url()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\EtsyConnection;
|
||||
use App\Services\Bambuddy;
|
||||
use App\Services\DryerClient;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\IntegrationSettings;
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -14,7 +15,7 @@ use Throwable;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer): View
|
||||
public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer, IntegrationSettings $settings): View
|
||||
{
|
||||
$isAdmin = $request->user()->role === 'admin';
|
||||
$etsyStatus = $isAdmin ? 'Not connected' : null;
|
||||
@@ -42,7 +43,8 @@ class DashboardController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($isAdmin && filled(config('services.parcel2go.client_id')) && filled(config('services.parcel2go.client_secret'))) {
|
||||
$shippingConfig = $settings->for('parcel2go');
|
||||
if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) {
|
||||
try {
|
||||
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
|
||||
$shippingStatus = 'Live account';
|
||||
@@ -85,7 +87,7 @@ class DashboardController extends Controller
|
||||
});
|
||||
|
||||
$dryerState = 'Not configured';
|
||||
if (filled(config('services.dryer.api_key'))) {
|
||||
if ($dryer->configured()) {
|
||||
try {
|
||||
$dryerState = ucfirst((string) ($dryer->status()['state'] ?? 'Unknown'));
|
||||
} catch (Throwable $exception) {
|
||||
|
||||
@@ -10,9 +10,9 @@ use Throwable;
|
||||
|
||||
class DryerController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
public function index(DryerClient $dryer): View
|
||||
{
|
||||
return view('dryer');
|
||||
return view('dryer', ['dryerUrl' => $dryer->dashboardUrl()]);
|
||||
}
|
||||
|
||||
public function status(DryerClient $dryer): JsonResponse
|
||||
|
||||
@@ -4,21 +4,49 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EtsyAppCredential;
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Models\IntegrationSetting;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\IntegrationSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EtsySettingsController extends Controller
|
||||
{
|
||||
public function index(Request $request, Etsy $etsy): View
|
||||
public function index(Request $request, Etsy $etsy, IntegrationSettings $settings): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
$credentials = EtsyAppCredential::find(1);
|
||||
$storedIntegrations = IntegrationSetting::query()->whereIn('provider', ['parcel2go', 'bambuddy', 'dryer'])
|
||||
->pluck('provider')->all();
|
||||
$parcel2go = $settings->for('parcel2go');
|
||||
$bambuddy = $settings->for('bambuddy');
|
||||
$dryer = $settings->for('dryer');
|
||||
|
||||
return view('settings.index', [
|
||||
'storedCredentials' => EtsyAppCredential::find(1) !== null,
|
||||
'storedCredentials' => $credentials !== null,
|
||||
'configured' => $etsy->configured(),
|
||||
'redirectUri' => $etsy->redirectUri(),
|
||||
'shopName' => $credentials?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'),
|
||||
'integrations' => [
|
||||
'parcel2go' => [
|
||||
'stored' => in_array('parcel2go', $storedIntegrations, true),
|
||||
'configured' => filled($parcel2go['client_id'] ?? null) && filled($parcel2go['client_secret'] ?? null) && filled($parcel2go['auth_url'] ?? null),
|
||||
'auth_url' => $parcel2go['auth_url'] ?? '',
|
||||
'base_url' => $parcel2go['base_url'] ?? '',
|
||||
],
|
||||
'bambuddy' => [
|
||||
'stored' => in_array('bambuddy', $storedIntegrations, true),
|
||||
'configured' => filled($bambuddy['url'] ?? null) && filled($bambuddy['api_key'] ?? null),
|
||||
'url' => $bambuddy['url'] ?? '',
|
||||
],
|
||||
'dryer' => [
|
||||
'stored' => in_array('dryer', $storedIntegrations, true),
|
||||
'configured' => filled($dryer['url'] ?? null) && filled($dryer['api_key'] ?? null),
|
||||
'url' => $dryer['url'] ?? '',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,15 +55,26 @@ class EtsySettingsController extends Controller
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
$credentials = EtsyAppCredential::find(1);
|
||||
$requireCredentials = $credentials === null ? 'required' : 'nullable';
|
||||
$requireKeystring = $credentials === null && blank(config('services.etsy.keystring')) ? 'required' : 'nullable';
|
||||
$requireSecret = $credentials === null && blank(config('services.etsy.shared_secret')) ? 'required' : 'nullable';
|
||||
$validated = $request->validate([
|
||||
'keystring' => [$requireCredentials, 'string', 'max:255'],
|
||||
'shared_secret' => [$requireCredentials, 'string', 'max:255'],
|
||||
'keystring' => [$requireKeystring, 'string', 'max:255'],
|
||||
'shared_secret' => [$requireSecret, 'string', 'max:255'],
|
||||
'shop_name' => ['nullable', 'string', 'max:255'],
|
||||
'redirect_uri' => ['nullable', 'url:https', 'max:2048'],
|
||||
]);
|
||||
|
||||
$previousKeystring = $credentials?->keystring ?: config('services.etsy.keystring');
|
||||
$credentials ??= new EtsyAppCredential;
|
||||
$credentials->id = 1;
|
||||
if (! $credentials->exists) {
|
||||
$credentials->fill([
|
||||
'keystring' => config('services.etsy.keystring'),
|
||||
'shared_secret' => config('services.etsy.shared_secret'),
|
||||
'shop_name' => config('services.etsy.shop_name', 'HickLab3D'),
|
||||
'redirect_uri' => config('services.etsy.redirect_uri'),
|
||||
]);
|
||||
}
|
||||
$credentials->fill(array_filter($validated, fn (?string $value): bool => filled($value)));
|
||||
$credentials->save();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\IntegrationSetting;
|
||||
use App\Services\IntegrationSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class IntegrationSettingsController extends Controller
|
||||
{
|
||||
public function update(Request $request, string $provider, IntegrationSettings $settings): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
abort_unless(in_array($provider, ['parcel2go', 'bambuddy', 'dryer'], true), 404);
|
||||
|
||||
$rules = match ($provider) {
|
||||
'parcel2go' => [
|
||||
'client_id' => ['nullable', 'string', 'max:255'],
|
||||
'client_secret' => ['nullable', 'string', 'max:255'],
|
||||
'auth_url' => ['nullable', 'url:https', 'max:2048'],
|
||||
'base_url' => ['nullable', 'url:https', 'max:2048'],
|
||||
],
|
||||
default => [
|
||||
'url' => ['nullable', 'url:https', 'max:2048'],
|
||||
'api_key' => ['nullable', 'string', 'max:512'],
|
||||
],
|
||||
};
|
||||
$submitted = array_filter($request->validate($rules), fn (?string $value): bool => filled($value));
|
||||
$effective = $settings->for($provider);
|
||||
if ($submitted === [] && ! collect($effective)->contains(fn ($value): bool => filled($value))) {
|
||||
return redirect()->route('settings.index')->with('status', 'No integration settings changed.');
|
||||
}
|
||||
|
||||
$record = IntegrationSetting::find($provider) ?? new IntegrationSetting(['provider' => $provider]);
|
||||
$record->settings = array_replace($effective, $submitted);
|
||||
$record->save();
|
||||
|
||||
return redirect()->route('settings.index')->with('status', ucfirst($provider).' settings saved.');
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class EtsyAppCredential extends Model
|
||||
/** @use HasFactory<EtsyAppCredentialFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['keystring', 'shared_secret'];
|
||||
protected $fillable = ['keystring', 'shared_secret', 'shop_name', 'redirect_uri'];
|
||||
|
||||
protected $hidden = ['keystring', 'shared_secret'];
|
||||
|
||||
@@ -20,6 +20,8 @@ class EtsyAppCredential extends Model
|
||||
return [
|
||||
'keystring' => 'encrypted',
|
||||
'shared_secret' => 'encrypted',
|
||||
'shop_name' => 'encrypted',
|
||||
'redirect_uri' => 'encrypted',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\IntegrationSettingFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IntegrationSetting extends Model
|
||||
{
|
||||
/** @use HasFactory<IntegrationSettingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $primaryKey = 'provider';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['provider', 'settings'];
|
||||
|
||||
protected $hidden = ['settings'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['settings' => 'encrypted:array'];
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@ use Throwable;
|
||||
|
||||
class Bambuddy
|
||||
{
|
||||
public function __construct(private IntegrationSettings $settings) {}
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return (string) ($this->settings->for('bambuddy')['url'] ?? '');
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return filled(config('services.bambuddy.url')) && filled(config('services.bambuddy.api_key'));
|
||||
return filled($this->url()) && filled($this->settings->for('bambuddy')['api_key'] ?? null);
|
||||
}
|
||||
|
||||
public function printers(): array
|
||||
@@ -19,17 +26,18 @@ class Bambuddy
|
||||
throw new RuntimeException('BamBuddy API key is not configured.');
|
||||
}
|
||||
|
||||
$url = rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers';
|
||||
$url = rtrim($this->url(), '/').'/api/v1/printers';
|
||||
$apiKey = $this->settings->for('bambuddy')['api_key'];
|
||||
|
||||
$printers = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')])
|
||||
$printers = Http::withHeaders(['X-API-Key' => $apiKey])
|
||||
->acceptJson()->connectTimeout(3)->timeout(8)->get($url)->throw()->json() ?? [];
|
||||
|
||||
return collect($printers)->filter(fn ($printer) => is_array($printer) && isset($printer['id']))
|
||||
->take(20)->map(function (array $printer): array {
|
||||
->take(20)->map(function (array $printer) use ($apiKey): array {
|
||||
try {
|
||||
$status = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')])
|
||||
$status = Http::withHeaders(['X-API-Key' => $apiKey])
|
||||
->acceptJson()->connectTimeout(3)->timeout(5)
|
||||
->get(rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers/'.(int) $printer['id'].'/status')
|
||||
->get(rtrim($this->url(), '/').'/api/v1/printers/'.(int) $printer['id'].'/status')
|
||||
->throw()->json() ?? [];
|
||||
|
||||
return $printer + ['live' => $status];
|
||||
|
||||
@@ -7,10 +7,31 @@ use RuntimeException;
|
||||
|
||||
class DryerClient
|
||||
{
|
||||
public function __construct(private IntegrationSettings $settings) {}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
$settings = $this->settings->for('dryer');
|
||||
|
||||
return filled($settings['url'] ?? null) && filled($settings['api_key'] ?? null);
|
||||
}
|
||||
|
||||
public function dashboardUrl(): string
|
||||
{
|
||||
$url = rtrim((string) ($this->settings->for('dryer')['url'] ?? ''), '/');
|
||||
|
||||
return preg_replace('#/api/v1$#', '', $url) ?? $url;
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return (string) ($this->settings->for('dryer')['api_key'] ?? '');
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
$base = config('services.dryer.url');
|
||||
if (! $base || ! config('services.dryer.api_key')) {
|
||||
$base = $this->settings->for('dryer')['url'] ?? null;
|
||||
if (! $this->configured()) {
|
||||
throw new RuntimeException('Dryer URL and API key must be configured.');
|
||||
}
|
||||
|
||||
@@ -24,13 +45,13 @@ class DryerClient
|
||||
|
||||
public function status(): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
public function history(): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
@@ -53,7 +74,7 @@ class DryerClient
|
||||
|
||||
private function command(string $path, array $payload = []): array
|
||||
{
|
||||
return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson()
|
||||
return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson()
|
||||
->connectTimeout(3)->timeout(7)->post($this->url($path), [
|
||||
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
||||
])->throw()->json() ?? [];
|
||||
|
||||
@@ -11,7 +11,7 @@ class Etsy
|
||||
{
|
||||
private const API_URL = 'https://api.etsy.com/v3';
|
||||
|
||||
/** @var array{keystring: ?string, shared_secret: ?string}|null */
|
||||
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
|
||||
private ?array $credentials = null;
|
||||
|
||||
public function configured(): bool
|
||||
@@ -51,7 +51,7 @@ class Etsy
|
||||
if (empty($shop['shop_id'])) {
|
||||
throw new RuntimeException('This Etsy account does not own a shop.');
|
||||
}
|
||||
if (strcasecmp($shop['shop_name'] ?? '', config('services.etsy.shop_name', 'HickLab3D')) !== 0) {
|
||||
if (strcasecmp($shop['shop_name'] ?? '', $this->credentials()['shop_name']) !== 0) {
|
||||
throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.');
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ class Etsy
|
||||
|
||||
public function redirectUri(): string
|
||||
{
|
||||
return config('services.etsy.redirect_uri') ?: route('etsy.callback');
|
||||
return $this->credentials()['redirect_uri'] ?: route('etsy.callback');
|
||||
}
|
||||
|
||||
/** @return array{keystring: ?string, shared_secret: ?string} */
|
||||
/** @return array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string} */
|
||||
private function credentials(): array
|
||||
{
|
||||
if ($this->credentials !== null) {
|
||||
@@ -123,6 +123,8 @@ class Etsy
|
||||
return $this->credentials = [
|
||||
'keystring' => $stored?->keystring ?: config('services.etsy.keystring'),
|
||||
'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'),
|
||||
'shop_name' => $stored?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'),
|
||||
'redirect_uri' => $stored?->redirect_uri ?: config('services.etsy.redirect_uri'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\IntegrationSetting;
|
||||
|
||||
class IntegrationSettings
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> */
|
||||
private array $resolved = [];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function for(string $provider): array
|
||||
{
|
||||
if (! isset($this->resolved[$provider])) {
|
||||
$this->resolved[$provider] = array_replace(
|
||||
config('services.'.$provider, []),
|
||||
IntegrationSetting::find($provider)?->settings ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->resolved[$provider];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use RuntimeException;
|
||||
|
||||
class Parcel2Go
|
||||
{
|
||||
public function __construct(private IntegrationSettings $settings) {}
|
||||
|
||||
public function fetch(string $path): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
@@ -90,12 +92,12 @@ class Parcel2Go
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return rtrim(config('services.parcel2go.base_url'), '/').'/'.ltrim($path, '/');
|
||||
return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
$settings = config('services.parcel2go');
|
||||
$settings = $this->settings->for('parcel2go');
|
||||
if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) {
|
||||
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user