Skip to content

Commit e939e2f

Browse files
[Fix] Dropbox OAuth Provider (#1174)
* wip * fixes * lint * documentation * fixes * final fix * fix * better s3 docs
1 parent 10b3a4a commit e939e2f

16 files changed

Lines changed: 646 additions & 81 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Actions\StorageProvider;
4+
5+
use App\Models\StorageProvider;
6+
use App\Models\User;
7+
use App\StorageProviders\Dropbox;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Http;
10+
use Illuminate\Support\Facades\Validator;
11+
use Illuminate\Support\Str;
12+
use Illuminate\Validation\ValidationException;
13+
use RuntimeException;
14+
15+
class ConnectDropbox
16+
{
17+
private const string AUTHORIZE_URL = 'https://www.dropbox.com/oauth2/authorize';
18+
19+
private const string TOKEN_URL = 'https://api.dropbox.com/oauth2/token';
20+
21+
private const string SESSION_KEY = 'dropbox_oauth';
22+
23+
/**
24+
* @param array<string, mixed> $input
25+
*
26+
* @throws ValidationException
27+
*/
28+
public function redirectUrl(array $input): string
29+
{
30+
Validator::make($input, [
31+
'name' => ['required'],
32+
'app_key' => ['required'],
33+
'app_secret' => ['required'],
34+
])->validate();
35+
36+
$state = Str::random(40);
37+
38+
session()->put(self::SESSION_KEY, [
39+
'state' => $state,
40+
'name' => $input['name'],
41+
'app_key' => $input['app_key'],
42+
'app_secret' => $input['app_secret'],
43+
'global' => isset($input['global']) && $input['global'],
44+
]);
45+
46+
return self::AUTHORIZE_URL.'?'.http_build_query([
47+
'client_id' => $input['app_key'],
48+
'response_type' => 'code',
49+
'token_access_type' => 'offline',
50+
'state' => $state,
51+
'redirect_uri' => $this->redirectUri(),
52+
]);
53+
}
54+
55+
/**
56+
* @throws ValidationException
57+
* @throws RuntimeException
58+
*/
59+
public function handleCallback(User $user, Request $request): StorageProvider
60+
{
61+
if ($request->filled('error')) {
62+
session()->forget(self::SESSION_KEY);
63+
64+
throw ValidationException::withMessages([
65+
'provider' => __('Dropbox authorization was cancelled.'),
66+
]);
67+
}
68+
69+
/** @var array<string, mixed>|null $pending */
70+
$pending = session()->pull(self::SESSION_KEY);
71+
72+
if (! is_array($pending) || ! hash_equals((string) ($pending['state'] ?? ''), (string) $request->query('state'))) {
73+
throw ValidationException::withMessages([
74+
'provider' => __('Invalid Dropbox authorization state.'),
75+
]);
76+
}
77+
78+
Validator::make($request->query(), [
79+
'code' => ['required', 'string'],
80+
])->validate();
81+
82+
$refreshToken = $this->exchangeCode(
83+
(string) $request->query('code'),
84+
(string) $pending['app_key'],
85+
(string) $pending['app_secret'],
86+
);
87+
88+
return app(CreateStorageProvider::class)->create($user, [
89+
'provider' => Dropbox::id(),
90+
'name' => $pending['name'],
91+
'app_key' => $pending['app_key'],
92+
'app_secret' => $pending['app_secret'],
93+
'refresh_token' => $refreshToken,
94+
'global' => $pending['global'],
95+
]);
96+
}
97+
98+
public function redirectUri(): string
99+
{
100+
return route('storage-providers.dropbox.callback');
101+
}
102+
103+
private function exchangeCode(string $code, string $appKey, string $appSecret): string
104+
{
105+
$res = Http::asForm()->post(self::TOKEN_URL, [
106+
'grant_type' => 'authorization_code',
107+
'code' => $code,
108+
'client_id' => $appKey,
109+
'client_secret' => $appSecret,
110+
'redirect_uri' => $this->redirectUri(),
111+
]);
112+
113+
if (! $res->successful()) {
114+
throw new RuntimeException("Failed to exchange Dropbox authorization code (HTTP {$res->status()})");
115+
}
116+
117+
$refreshToken = $res->json('refresh_token');
118+
119+
if (! is_string($refreshToken) || $refreshToken === '') {
120+
throw new RuntimeException('Dropbox did not return a refresh token. Ensure the app uses offline access.');
121+
}
122+
123+
return $refreshToken;
124+
}
125+
}

app/Actions/StorageProvider/DeleteStorageProvider.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Actions\StorageProvider;
44

55
use App\Models\StorageProvider;
6+
use App\StorageProviders\Dropbox;
67
use Illuminate\Validation\ValidationException;
78

89
class DeleteStorageProvider
@@ -15,6 +16,12 @@ public function delete(StorageProvider $storageProvider): void
1516
]);
1617
}
1718

19+
if ($storageProvider->provider === Dropbox::id()) {
20+
$provider = $storageProvider->provider();
21+
assert($provider instanceof Dropbox);
22+
$provider->forgetAccessToken();
23+
}
24+
1825
$storageProvider->delete();
1926
}
2027
}

app/Http/Controllers/StorageProviderController.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Actions\StorageProvider\ConnectDropbox;
56
use App\Actions\StorageProvider\CreateStorageProvider;
67
use App\Actions\StorageProvider\DeleteStorageProvider;
78
use App\Actions\StorageProvider\EditStorageProvider;
@@ -11,6 +12,8 @@
1112
use Illuminate\Http\RedirectResponse;
1213
use Illuminate\Http\Request;
1314
use Illuminate\Http\Resources\Json\ResourceCollection;
15+
use Illuminate\Support\Facades\Log;
16+
use Illuminate\Validation\ValidationException;
1417
use Inertia\Inertia;
1518
use Inertia\Response;
1619
use Spatie\RouteAttributes\Attributes\Delete;
@@ -19,6 +22,8 @@
1922
use Spatie\RouteAttributes\Attributes\Patch;
2023
use Spatie\RouteAttributes\Attributes\Post;
2124
use Spatie\RouteAttributes\Attributes\Prefix;
25+
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
26+
use Throwable;
2227

2328
#[Prefix('settings/storage-providers')]
2429
#[Middleware(['auth'])]
@@ -60,6 +65,32 @@ public function store(Request $request): RedirectResponse
6065
return back()->with('success', 'Storage provider created.');
6166
}
6267

68+
#[Post('/dropbox/redirect', name: 'storage-providers.dropbox.redirect')]
69+
public function dropboxRedirect(Request $request, ConnectDropbox $action): SymfonyResponse
70+
{
71+
$this->authorize('create', StorageProvider::class);
72+
73+
return Inertia::location($action->redirectUrl($request->all()));
74+
}
75+
76+
#[Get('/dropbox/callback', name: 'storage-providers.dropbox.callback')]
77+
public function dropboxCallback(Request $request, ConnectDropbox $action): RedirectResponse
78+
{
79+
$this->authorize('create', StorageProvider::class);
80+
81+
try {
82+
$action->handleCallback(user(), $request);
83+
} catch (ValidationException $e) {
84+
return to_route('storage-providers')->with('error', $e->validator->errors()->first());
85+
} catch (Throwable $e) {
86+
Log::error('Dropbox OAuth callback failed', ['exception' => get_class($e)]);
87+
88+
return to_route('storage-providers')->with('error', __('Failed to connect to Dropbox.'));
89+
}
90+
91+
return to_route('storage-providers')->with('success', 'Storage provider created.');
92+
}
93+
6394
#[Patch('/{storageProvider}', name: 'storage-providers.update')]
6495
public function update(Request $request, StorageProvider $storageProvider): RedirectResponse
6596
{

app/Providers/StorageProviderServiceProvider.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,12 @@ private function dropbox(): void
7777
->handler(Dropbox::class)
7878
->form(
7979
DynamicForm::make([
80-
DynamicField::make('token')
80+
DynamicField::make('app_key')
8181
->text()
82-
->label('Token'),
82+
->label('App key'),
83+
DynamicField::make('app_secret')
84+
->password()
85+
->label('App secret'),
8386
])
8487
)
8588
->register();

app/SSH/Storage/Dropbox.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,23 @@
44

55
use App\Exceptions\SSHCommandError;
66
use App\Exceptions\SSHError;
7+
use App\StorageProviders\Dropbox as DropboxProvider;
78
use Illuminate\Support\Facades\Log;
9+
use RuntimeException;
810

911
class Dropbox extends AbstractStorage
1012
{
13+
private function accessToken(): string
14+
{
15+
$provider = $this->storageProvider->provider();
16+
17+
if (! $provider instanceof DropboxProvider) {
18+
throw new RuntimeException('Storage provider is not Dropbox.');
19+
}
20+
21+
return $provider->accessToken();
22+
}
23+
1124
/**
1225
* @throws SSHError
1326
*/
@@ -17,7 +30,7 @@ public function upload(string $src, string $dest): array
1730
view('ssh.storage.dropbox.upload', [
1831
'src' => $src,
1932
'dest' => $dest,
20-
'token' => $this->storageProvider->credentials['token'],
33+
'token' => $this->accessToken(),
2134
]),
2235
'upload-to-dropbox'
2336
);
@@ -43,7 +56,7 @@ public function download(string $src, string $dest): void
4356
view('ssh.storage.dropbox.download', [
4457
'src' => $src,
4558
'dest' => $dest,
46-
'token' => $this->storageProvider->credentials['token'],
59+
'token' => $this->accessToken(),
4760
]),
4861
'download-from-dropbox'
4962
);
@@ -57,7 +70,7 @@ public function delete(string $src): void
5770
$this->server->ssh()->exec(
5871
view('ssh.storage.dropbox.delete-file', [
5972
'src' => $src,
60-
'token' => $this->storageProvider->credentials['token'],
73+
'token' => $this->accessToken(),
6174
]),
6275
'delete-from-dropbox'
6376
);

app/StorageProviders/Dropbox.php

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44

55
use App\Models\Server;
66
use App\SSH\Storage\Storage;
7+
use Illuminate\Support\Facades\Cache;
78
use Illuminate\Support\Facades\Http;
9+
use RuntimeException;
810

911
class Dropbox extends AbstractStorageProvider
1012
{
13+
private const int TOKEN_TTL = 10800;
14+
1115
protected string $apiUrl = 'https://api.dropboxapi.com/2';
1216

17+
protected string $tokenUrl = 'https://api.dropbox.com/oauth2/token';
18+
1319
public static function id(): string
1420
{
1521
return 'dropbox';
@@ -18,27 +24,84 @@ public static function id(): string
1824
public function validationRules(): array
1925
{
2026
return [
21-
'token' => 'required',
27+
'app_key' => 'required',
28+
'app_secret' => 'required',
29+
'refresh_token' => 'required',
2230
];
2331
}
2432

2533
public function credentialData(array $input): array
2634
{
2735
return [
28-
'token' => $input['token'],
36+
'app_key' => $input['app_key'],
37+
'app_secret' => $input['app_secret'],
38+
'refresh_token' => $input['refresh_token'],
2939
];
3040
}
3141

42+
public function accessToken(): string
43+
{
44+
if (! $this->storageProvider->id) {
45+
return $this->fetchAccessToken();
46+
}
47+
48+
return Cache::remember(
49+
$this->tokenCacheKey(),
50+
self::TOKEN_TTL,
51+
fn (): string => $this->fetchAccessToken()
52+
);
53+
}
54+
55+
public function forgetAccessToken(): void
56+
{
57+
if ($this->storageProvider->id) {
58+
Cache::forget($this->tokenCacheKey());
59+
}
60+
}
61+
3262
public function connect(): bool
3363
{
34-
$res = Http::withToken($this->storageProvider->credentials['token'])
64+
$res = Http::withToken($this->accessToken())
3565
->post($this->apiUrl.'/check/user', [
3666
'query' => '',
3767
]);
3868

3969
return $res->successful();
4070
}
4171

72+
private function tokenCacheKey(): string
73+
{
74+
return "dropbox_token_{$this->storageProvider->id}";
75+
}
76+
77+
private function fetchAccessToken(): string
78+
{
79+
$credentials = $this->storageProvider->credentials;
80+
81+
if (! isset($credentials['app_key'], $credentials['app_secret'], $credentials['refresh_token'])) {
82+
throw new RuntimeException('Dropbox credentials are incomplete, please reconnect.');
83+
}
84+
85+
$res = Http::asForm()->post($this->tokenUrl, [
86+
'grant_type' => 'refresh_token',
87+
'refresh_token' => $credentials['refresh_token'],
88+
'client_id' => $credentials['app_key'],
89+
'client_secret' => $credentials['app_secret'],
90+
]);
91+
92+
if (! $res->successful()) {
93+
throw new RuntimeException("Failed to refresh Dropbox access token (HTTP {$res->status()})");
94+
}
95+
96+
$accessToken = $res->json('access_token');
97+
98+
if (! is_string($accessToken) || $accessToken === '') {
99+
throw new RuntimeException('Dropbox did not return an access token.');
100+
}
101+
102+
return $accessToken;
103+
}
104+
42105
public function ssh(Server $server): Storage
43106
{
44107
return new \App\SSH\Storage\Dropbox($server, $this->storageProvider);

0 commit comments

Comments
 (0)