diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php new file mode 100644 index 0000000..ec16bd0 --- /dev/null +++ b/app/Http/Controllers/EtsySettingsController.php @@ -0,0 +1,51 @@ +user()->role === 'admin', 403); + + return view('settings.index', [ + 'storedCredentials' => EtsyAppCredential::find(1) !== null, + 'configured' => $etsy->configured(), + 'redirectUri' => $etsy->redirectUri(), + ]); + } + + public function update(Request $request): RedirectResponse + { + abort_unless($request->user()->role === 'admin', 403); + + $credentials = EtsyAppCredential::find(1); + $requireCredentials = $credentials === null ? 'required' : 'nullable'; + $validated = $request->validate([ + 'keystring' => [$requireCredentials, 'string', 'max:255'], + 'shared_secret' => [$requireCredentials, 'string', 'max:255'], + ]); + + $previousKeystring = $credentials?->keystring ?: config('services.etsy.keystring'); + $credentials ??= new EtsyAppCredential; + $credentials->id = 1; + $credentials->fill(array_filter($validated, fn (?string $value): bool => filled($value))); + $credentials->save(); + + $keystringChanged = $previousKeystring !== $credentials->keystring; + if ($keystringChanged) { + EtsyConnection::query()->delete(); + } + + return redirect()->route('settings.index')->with('status', $keystringChanged + ? 'Etsy credentials saved. Reconnect your shop with the new app key.' + : 'Etsy credentials saved.'); + } +} diff --git a/app/Models/EtsyAppCredential.php b/app/Models/EtsyAppCredential.php new file mode 100644 index 0000000..8c6ff7e --- /dev/null +++ b/app/Models/EtsyAppCredential.php @@ -0,0 +1,25 @@ + */ + use HasFactory; + + protected $fillable = ['keystring', 'shared_secret']; + + protected $hidden = ['keystring', 'shared_secret']; + + protected function casts(): array + { + return [ + 'keystring' => 'encrypted', + 'shared_secret' => 'encrypted', + ]; + } +} diff --git a/app/Models/EtsyConnection.php b/app/Models/EtsyConnection.php index 8bfaf09..fc52590 100644 --- a/app/Models/EtsyConnection.php +++ b/app/Models/EtsyConnection.php @@ -8,6 +8,8 @@ class EtsyConnection extends Model { protected $fillable = ['shop_id', 'shop_name', 'access_token', 'refresh_token', 'expires_at']; + protected $hidden = ['access_token', 'refresh_token']; + protected function casts(): array { return [ diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index 5bc51bf..5411bd9 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\EtsyAppCredential; use App\Models\EtsyConnection; use Illuminate\Support\Facades\Http; use RuntimeException; @@ -10,9 +11,14 @@ class Etsy { private const API_URL = 'https://api.etsy.com/v3'; + /** @var array{keystring: ?string, shared_secret: ?string}|null */ + private ?array $credentials = null; + public function configured(): bool { - return filled(config('services.etsy.keystring')) && filled(config('services.etsy.shared_secret')); + $credentials = $this->credentials(); + + return filled($credentials['keystring']) && filled($credentials['shared_secret']); } public function authorizationUrl(string $state, string $verifier): string @@ -24,7 +30,7 @@ class Etsy return 'https://www.etsy.com/oauth/connect?'.http_build_query([ 'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), - 'scope' => 'transactions_r listings_r', 'client_id' => config('services.etsy.keystring'), + 'scope' => 'transactions_r listings_r', 'client_id' => $this->credentials()['keystring'], 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ], '', '&', PHP_QUERY_RFC3986); } @@ -33,7 +39,7 @@ class Etsy { $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) ->post(self::API_URL.'/public/oauth/token', [ - 'grant_type' => 'authorization_code', 'client_id' => config('services.etsy.keystring'), + 'grant_type' => 'authorization_code', 'client_id' => $this->credentials()['keystring'], 'redirect_uri' => $this->redirectUri(), 'code' => $code, 'code_verifier' => $verifier, ])->throw()->json(); $userId = explode('.', $tokens['access_token'] ?? '')[0]; @@ -77,7 +83,7 @@ class Etsy } $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) ->post(self::API_URL.'/public/oauth/token', [ - 'grant_type' => 'refresh_token', 'client_id' => config('services.etsy.keystring'), + 'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'], 'refresh_token' => $connection->refresh_token, ])->throw()->json(); if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) { @@ -93,13 +99,30 @@ class Etsy private function get(string $path, string $accessToken, array $query = []): array { - return Http::withHeaders(['x-api-key' => config('services.etsy.keystring').':'.config('services.etsy.shared_secret')]) + $credentials = $this->credentials(); + + return Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']]) ->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(10) ->get(self::API_URL.'/'.$path, $query)->throw()->json() ?? []; } - private function redirectUri(): string + public function redirectUri(): string { return config('services.etsy.redirect_uri') ?: route('etsy.callback'); } + + /** @return array{keystring: ?string, shared_secret: ?string} */ + private function credentials(): array + { + if ($this->credentials !== null) { + return $this->credentials; + } + + $stored = EtsyAppCredential::find(1); + + return $this->credentials = [ + 'keystring' => $stored?->keystring ?: config('services.etsy.keystring'), + 'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'), + ]; + } } diff --git a/database/factories/EtsyAppCredentialFactory.php b/database/factories/EtsyAppCredentialFactory.php new file mode 100644 index 0000000..4f096c3 --- /dev/null +++ b/database/factories/EtsyAppCredentialFactory.php @@ -0,0 +1,25 @@ + + */ +class EtsyAppCredentialFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'keystring' => fake()->bothify('etsy-key-????????'), + 'shared_secret' => fake()->sha256(), + ]; + } +} diff --git a/database/migrations/2026_10_10_180546_create_etsy_app_credentials_table.php b/database/migrations/2026_10_10_180546_create_etsy_app_credentials_table.php new file mode 100644 index 0000000..578a6c0 --- /dev/null +++ b/database/migrations/2026_10_10_180546_create_etsy_app_credentials_table.php @@ -0,0 +1,29 @@ +id(); + $table->text('keystring'); + $table->text('shared_secret'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('etsy_app_credentials'); + } +}; diff --git a/database/seeders/EtsyAppCredentialSeeder.php b/database/seeders/EtsyAppCredentialSeeder.php new file mode 100644 index 0000000..73ca198 --- /dev/null +++ b/database/seeders/EtsyAppCredentialSeeder.php @@ -0,0 +1,28 @@ +id = 1; + $credentials->keystring = $keystring; + $credentials->shared_secret = $sharedSecret; + $credentials->save(); + } + } + } +} diff --git a/resources/views/etsy.blade.php b/resources/views/etsy.blade.php index 122b21c..639ddd7 100644 --- a/resources/views/etsy.blade.php +++ b/resources/views/etsy.blade.php @@ -1,8 +1,8 @@ @extends('layouts.app') @section('title', 'Etsy shop') @section('content') -

Sales

{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}

Paid orders awaiting dispatch and active listings.

@csrf
-@if(! $configured)@endif +

Sales

{{ $connection?->shop_name ?? 'Connect your Etsy shop' }}

Paid orders awaiting dispatch and active listings.

@if($configured)
@csrf
@elseSet up Etsy@endif
+@if(! $configured)@endif @if($integrationError)@endif @if($connection)

Awaiting dispatch

{{ count($receipts) }}

Active listings shown

{{ count($listings) }}

diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index b993aec..d901e89 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -27,6 +27,7 @@ @endif @endforeach @if(auth()->user()->role === 'admin') + request()->routeIs('settings.*'), 'text-slate-400' => !request()->routeIs('settings.*')])>Settings request()->routeIs('users.*'), 'text-slate-400' => !request()->routeIs('users.*')])>Users @endif diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php new file mode 100644 index 0000000..a2b6c6b --- /dev/null +++ b/resources/views/settings/index.blade.php @@ -0,0 +1,43 @@ +@extends('layouts.app') +@section('title', 'Settings') +@section('content') +
+

Integrations

+

Etsy app credentials

+

Save the credentials from your Etsy developer app, then connect your shop with OAuth.

+
+ +
+
+
+

API access

+

{{ $storedCredentials ? 'Credentials saved in the database.' : ($configured ? 'Using server credentials. Save them here to manage them in the dashboard.' : 'No credentials saved yet.') }}

+
+ {{ $configured ? 'Configured' : 'Setup needed' }} +
+ +
+ @csrf + @method('PUT') + + +
+ +
+
+

Saved values are encrypted and are never shown again. Changing the app key disconnects the current Etsy shop.

+
+ +
+

OAuth callback

+

Register this exact HTTPS URL in your Etsy developer app.

+ {{ $redirectUri }} + @if($configured)Go to Etsy shop@endif +
+@endsection diff --git a/routes/web.php b/routes/web.php index fb9b737..1ec2d4e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\BambuddyController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\DryerController; use App\Http\Controllers\EtsyController; +use App\Http\Controllers\EtsySettingsController; use App\Http\Controllers\FilamentController; use App\Http\Controllers\PrinterController; use App\Http\Controllers\ShippingController; @@ -32,6 +33,8 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void Route::get('/etsy', [EtsyController::class, 'index'])->name('etsy.index'); Route::post('/etsy/connect', [EtsyController::class, 'connect'])->name('etsy.connect'); Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback'); + Route::get('/settings', [EtsySettingsController::class, 'index'])->name('settings.index'); + Route::put('/settings/etsy', [EtsySettingsController::class, 'update'])->name('settings.etsy.update'); Route::get('/dryer', [DryerController::class, 'index'])->name('dryer'); Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status'); Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control'); diff --git a/tests/Feature/EtsySettingsTest.php b/tests/Feature/EtsySettingsTest.php new file mode 100644 index 0000000..4c7f2fc --- /dev/null +++ b/tests/Feature/EtsySettingsTest.php @@ -0,0 +1,112 @@ +get(route('settings.index'))->assertRedirect(route('login')); + + $user = User::factory()->create(); + $this->actingAs($user)->get(route('settings.index'))->assertForbidden(); + $this->actingAs($user)->put(route('settings.etsy.update'), [ + 'keystring' => 'key', 'shared_secret' => 'secret', + ])->assertForbidden(); + $this->assertDatabaseCount('etsy_app_credentials', 0); + } + + public function test_administrator_saves_encrypted_credentials_for_oauth_without_exposing_them(): void + { + config()->set('services.etsy.keystring', 'old-server-key'); + config()->set('services.etsy.shared_secret', 'old-server-secret'); + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => 'saved-key', 'shared_secret' => 'saved-secret', + ])->assertRedirect(route('settings.index'))->assertSessionHas('status'); + + $credentials = EtsyAppCredential::firstOrFail(); + $this->assertSame('saved-key', $credentials->keystring); + $this->assertSame('saved-secret', $credentials->shared_secret); + $this->assertNotSame('saved-key', $credentials->getRawOriginal('keystring')); + $this->assertNotSame('saved-secret', $credentials->getRawOriginal('shared_secret')); + $this->assertArrayNotHasKey('shared_secret', $credentials->toArray()); + + $this->actingAs($admin)->get(route('settings.index')) + ->assertOk()->assertSee('Credentials saved in the database.') + ->assertDontSee('saved-key')->assertDontSee('saved-secret'); + $this->actingAs($admin)->post(route('etsy.connect')) + ->assertRedirectContains('client_id=saved-key'); + } + + public function test_oauth_api_requests_use_the_saved_shared_secret(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/public/oauth/token' => Http::response(['access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_in' => 3600]), + 'https://api.etsy.com/v3/application/users/123/shops' => Http::response(['shop_id' => 42, 'shop_name' => 'HickLab3D']), + ]); + config()->set('services.etsy.keystring', 'old-server-key'); + config()->set('services.etsy.shared_secret', 'old-server-secret'); + $admin = User::factory()->create(['role' => 'admin']); + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => 'saved-key', 'shared_secret' => 'saved-secret', + ])->assertRedirect(route('settings.index')); + + $this->actingAs($admin)->withSession(['etsy_oauth' => [ + 'state' => 'expected', 'verifier' => 'verifier', 'started' => time(), + ]])->get(route('etsy.callback', ['state' => 'expected', 'code' => 'code'])) + ->assertRedirect(route('etsy.index')); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/users/123/shops' + && $request->hasHeader('x-api-key', 'saved-key:saved-secret')); + } + + public function test_initial_credentials_require_both_values(): void + { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => 'key', + ])->assertSessionHasErrors('shared_secret'); + $this->assertDatabaseCount('etsy_app_credentials', 0); + } + + public function test_blank_fields_keep_saved_credentials_and_new_key_disconnects_old_shop(): void + { + $admin = User::factory()->create(['role' => 'admin']); + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => 'first-key', 'shared_secret' => 'first-secret', + ])->assertRedirect(route('settings.index')); + EtsyConnection::create([ + 'shop_id' => 42, + 'shop_name' => 'HickLab3D', + 'access_token' => '123.token', + 'refresh_token' => '123.refresh', + 'expires_at' => now()->addHour(), + ]); + + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => '', 'shared_secret' => '', + ])->assertRedirect(route('settings.index')); + $this->assertSame('first-key', EtsyAppCredential::firstOrFail()->keystring); + $this->assertDatabaseCount('etsy_connections', 1); + + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'keystring' => 'second-key', 'shared_secret' => '', + ])->assertRedirect(route('settings.index'))->assertSessionHas('status'); + $this->assertSame('second-key', EtsyAppCredential::firstOrFail()->keystring); + $this->assertSame('first-secret', EtsyAppCredential::firstOrFail()->shared_secret); + $this->assertDatabaseCount('etsy_connections', 0); + } +}