Update Dropbox storage locations to support short-lived OAuth-compatible tokens.

This commit is contained in:
Buster Neece 2023-04-10 17:46:25 -05:00
parent 59adca779e
commit 12b708a164
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
13 changed files with 447 additions and 37 deletions

View File

@ -48,6 +48,7 @@
"league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-sftp-v3": "^3.0",
"league/mime-type-detection": "^1.7",
"league/oauth2-client": "^2.6",
"league/plates": "^3.1",
"lstrojny/fxmlrpc": "dev-master",
"marcw/rss-writer": "^0.4.0",

72
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": "1231ed6704dea3766d3014a14233b3cb",
"content-hash": "fe3a67cfc6f90897593aef83f8a54767",
"packages": [
{
"name": "aws/aws-crt-php",
@ -3401,6 +3401,76 @@
],
"time": "2022-04-17T13:12:02+00:00"
},
{
"name": "league/oauth2-client",
"version": "2.6.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "2334c249907190c132364f5dae0287ab8666aa19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/2334c249907190c132364f5dae0287ab8666aa19",
"reference": "2334c249907190c132364f5dae0287ab8666aa19",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^6.0 || ^7.0",
"paragonie/random_compat": "^1 || ^2 || ^9.99",
"php": "^5.6 || ^7.0 || ^8.0"
},
"require-dev": {
"mockery/mockery": "^1.3.5",
"php-parallel-lint/php-parallel-lint": "^1.3.1",
"phpunit/phpunit": "^5.7 || ^6.0 || ^9.5",
"squizlabs/php_codesniffer": "^2.3 || ^3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-2.x": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\OAuth2\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Bilbie",
"email": "hello@alexbilbie.com",
"homepage": "http://www.alexbilbie.com",
"role": "Developer"
},
{
"name": "Woody Gilk",
"homepage": "https://github.com/shadowhand",
"role": "Contributor"
}
],
"description": "OAuth 2.0 Client Library",
"keywords": [
"Authentication",
"SSO",
"authorization",
"identity",
"idp",
"oauth",
"oauth2",
"single sign on"
],
"support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.6.1"
},
"time": "2021-12-22T16:42:49+00:00"
},
{
"name": "league/plates",
"version": "v3.5.0",

View File

@ -0,0 +1,108 @@
<template>
<section class="card mt-3">
<div class="card-header bg-primary-dark">
<h2 class="card-title">
{{ $gettext('Remote: Dropbox') }}
</h2>
</div>
<b-card-body>
<b-form-group>
<div class="form-row">
<div class="col-md-12">
<h3>{{ $gettext('Dropbox Setup Instructions') }}</h3>
<ul>
<li>
{{ $gettext('Visit the Dropbox App Console:') }}<br>
<a
href="https://www.dropbox.com/developers/apps"
target="_blank"
>
{{ $gettext('Dropbox App Console') }}
</a>
</li>
<li>
{{
$gettext('Create a new application. Choose "Scoped Access", select your preferred level of access, then name your app. Do not name it "AzuraCast", but rather use a name specific to your installation.')
}}
</li>
<li>
{{ $gettext('Enter your app secret and app key below.') }}
</li>
</ul>
</div>
<b-wrapped-form-group
id="form_edit_dropboxAppKey"
class="col-md-6"
:field="form.dropboxAppKey"
>
<template #label>
{{ $gettext('App Key') }}
</template>
</b-wrapped-form-group>
<b-wrapped-form-group
id="form_edit_dropboxAppSecret"
class="col-md-6"
:field="form.dropboxAppSecret"
>
<template #label>
{{ $gettext('App Secret') }}
</template>
</b-wrapped-form-group>
<div class="col-md-12">
<ul>
<li>
{{ $gettext('Visit the link below to sign in and generate an access code:') }}<br>
<a
:href="authUrl"
target="_blank"
>
{{ $gettext('Generate Access Code') }}
</a>
</li>
<li>
{{ $gettext('Enter the authorization code you receive below.') }}
</li>
</ul>
</div>
<b-wrapped-form-group
id="form_edit_dropboxAuthToken"
class="col-md-12"
:field="form.dropboxAuthToken"
>
<template #label>
{{ $gettext('Access Token') }}
</template>
</b-wrapped-form-group>
</div>
</b-form-group>
</b-card-body>
</section>
</template>
<script setup>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup.vue";
import {computed} from "vue";
const props = defineProps({
form: {
type: Object,
required: true
}
});
const baseAuthUrl = 'https://www.dropbox.com/oauth2/authorize';
const authUrl = computed(() => {
const params = new URLSearchParams();
params.append('client_id', props.form.dropboxAppKey.$model);
params.append('response_type', 'code');
params.append('token_access_type', 'offline');
return baseAuthUrl + '?' + params.toString();
});
</script>

View File

@ -57,6 +57,8 @@ const {
's3Version': {},
's3Bucket': {},
's3Endpoint': {},
'dropboxAppKey': {},
'dropboxAppSecret': {},
'dropboxAuthToken': {},
'sftpHost': {},
'sftpPort': {},

View File

@ -140,37 +140,10 @@
</b-card-body>
</b-card>
<b-card
<dropbox
v-show="form.adapter.$model === 'dropbox'"
class="mb-3"
no-body
>
<div class="card-header bg-primary-dark">
<h2 class="card-title">
{{ $gettext('Remote: Dropbox') }}
</h2>
</div>
<b-card-body>
<b-form-group>
<div class="form-row">
<b-wrapped-form-group
id="form_edit_dropboxAuthToken"
class="col-md-12"
:field="form.dropboxAuthToken"
>
<template #label>
{{ $gettext('Dropbox Generated Access Token') }}
</template>
<template #description>
{{
$gettext('Note: Dropbox now only issues short-lived tokens that will not work for this purpose. If your token begins with "sl", it is short-lived and will not work correctly.')
}}
</template>
</b-wrapped-form-group>
</div>
</b-form-group>
</b-card-body>
</b-card>
:form="form"
/>
<b-card
v-show="form.adapter.$model === 'sftp'"
@ -256,6 +229,7 @@
<script setup>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup.vue";
import Dropbox from "./Dropbox.vue";
const props = defineProps({
form: {

View File

@ -25,6 +25,11 @@ parameters:
count: 1
path: src/Entity/Migration/Version20201204043539.php
-
message: "#^Parameter \\#1 \\$accessTokenOrAppCredentials of class Spatie\\\\Dropbox\\\\Client constructor expects array\\|string\\|null, App\\\\Service\\\\Dropbox\\\\OAuthAdapter given\\.$#"
count: 1
path: src/Entity/StorageLocationAdapter/DropboxStorageLocationAdapter.php
-
message: "#^Cannot cast Symfony\\\\Component\\\\Validator\\\\ConstraintViolationListInterface to string\\.$#"
count: 1

View File

@ -74,6 +74,18 @@ final class StorageLocation
)]
public ?string $s3Endpoint = null;
#[OA\Property(
description: 'The optional Dropbox App Key.',
example: ''
)]
public ?string $dropboxAppKey = null;
#[OA\Property(
description: 'The optional Dropbox App Secret.',
example: ''
)]
public ?string $dropboxAppSecret = null;
#[OA\Property(
description: 'The optional Dropbox Auth Token.',
example: ''

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230410210554 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add new Dropbox-related keys.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE storage_location ADD dropbox_app_key VARCHAR(50) DEFAULT NULL, ADD dropbox_app_secret VARCHAR(150) DEFAULT NULL, ADD dropbox_refresh_token VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE storage_location DROP dropbox_app_key, DROP dropbox_app_secret, DROP dropbox_refresh_token');
}
}

View File

@ -56,9 +56,18 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
#[ORM\Column(name: 's3_endpoint', length: 255, nullable: true)]
protected ?string $s3Endpoint = null;
#[ORM\Column(name: 'dropbox_app_key', length: 50, nullable: true)]
protected ?string $dropboxAppKey = null;
#[ORM\Column(name: 'dropbox_app_secret', length: 150, nullable: true)]
protected ?string $dropboxAppSecret = null;
#[ORM\Column(name: 'dropbox_auth_token', length: 255, nullable: true)]
protected ?string $dropboxAuthToken = null;
#[ORM\Column(name: 'dropbox_refresh_token', length: 255, nullable: true)]
protected ?string $dropboxRefreshToken = null;
#[ORM\Column(name: 'sftp_host', length: 255, nullable: true)]
protected ?string $sftpHost = null;
@ -195,6 +204,26 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
$this->s3Endpoint = $this->truncateNullableString($s3Endpoint);
}
public function getDropboxAppKey(): ?string
{
return $this->dropboxAppKey;
}
public function setDropboxAppKey(?string $dropboxAppKey): void
{
$this->dropboxAppKey = $dropboxAppKey;
}
public function getDropboxAppSecret(): ?string
{
return $this->dropboxAppSecret;
}
public function setDropboxAppSecret(?string $dropboxAppSecret): void
{
$this->dropboxAppSecret = $dropboxAppSecret;
}
public function getDropboxAuthToken(): ?string
{
return $this->dropboxAuthToken;
@ -205,6 +234,16 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
$this->dropboxAuthToken = $dropboxAuthToken;
}
public function getDropboxRefreshToken(): ?string
{
return $this->dropboxRefreshToken;
}
public function setDropboxRefreshToken(?string $dropboxRefreshToken): void
{
$this->dropboxRefreshToken = $dropboxRefreshToken;
}
public function getSftpHost(): ?string
{
return $this->sftpHost;

View File

@ -8,10 +8,16 @@ use App\Entity\Enums\StorageLocationAdapters;
use App\Entity\StorageLocation;
use App\Flysystem\Adapter\DropboxAdapter;
use App\Flysystem\Adapter\ExtendedAdapterInterface;
use App\Service\Dropbox\OAuthAdapter;
use Spatie\Dropbox\Client;
final class DropboxStorageLocationAdapter extends AbstractStorageLocationLocationAdapter
{
public function __construct(
private readonly OAuthAdapter $oauthAdapter
) {
}
public function getType(): StorageLocationAdapters
{
return StorageLocationAdapters::Dropbox;
@ -26,7 +32,15 @@ final class DropboxStorageLocationAdapter extends AbstractStorageLocationLocatio
private function getClient(): Client
{
return new Client($this->storageLocation->getDropboxAuthToken());
return new Client($this->oauthAdapter->withStorageLocation($this->storageLocation));
}
public function validate(): void
{
$adapter = $this->oauthAdapter->withStorageLocation($this->storageLocation);
$adapter->setup();
parent::validate();
}
public static function filterPath(string $path): string
@ -40,9 +54,9 @@ final class DropboxStorageLocationAdapter extends AbstractStorageLocationLocatio
$token = (!empty($storageLocation->getDropboxAuthToken()))
? $storageLocation->getDropboxAuthToken()
: '';
: $storageLocation->getDropboxRefreshToken();
$token = substr(md5($token), 0, 10);
$token = substr(md5($token ?? ''), 0, 10);
return 'dropbox://' . $token . '/' . ltrim($path, '/');
}

View File

@ -4,6 +4,95 @@ declare(strict_types=1);
namespace App\Service\Dropbox;
final class OAuthAdapter
use App\Entity\StorageLocation;
use GuzzleHttp\Exception\ClientException;
use Psr\Cache\CacheItemPoolInterface;
use Spatie\Dropbox\RefreshableTokenProvider;
final class OAuthAdapter implements RefreshableTokenProvider
{
private StorageLocation $storageLocation;
public function __construct(
private readonly CacheItemPoolInterface $psr6Cache
) {
}
public function withStorageLocation(StorageLocation $storageLocation): self
{
$clone = clone $this;
$clone->setStorageLocation($storageLocation);
return $clone;
}
private function setStorageLocation(StorageLocation $storageLocation): void
{
$this->storageLocation = $storageLocation;
}
public function setup(): void
{
$this->psr6Cache->deleteItem($this->getTokenCacheKey());
if (!empty($this->storageLocation->getDropboxAuthToken())) {
// Convert the short-lived auth code into an oauth refresh token.
$token = $this->getOauthProvider()->getAccessToken(
'authorization_code',
[
'code' => $this->storageLocation->getDropboxAuthToken(),
]
);
$this->storageLocation->setDropboxAuthToken(null);
$this->storageLocation->setDropboxRefreshToken($token->getRefreshToken());
}
}
public function refresh(ClientException $exception): bool
{
$this->psr6Cache->deleteItem($this->getTokenCacheKey());
$this->getToken();
return true;
}
public function getToken(): string
{
$cacheKey = $this->getTokenCacheKey();
$cacheItem = $this->psr6Cache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
if (empty($this->storageLocation->getDropboxRefreshToken())) {
$cacheItem->set($this->storageLocation->getDropboxAuthToken());
} else {
// Try to get a new auth token from the refresh token.
$token = $this->getOauthProvider()->getAccessToken(
'refresh_token',
[
'refresh_token' => $this->storageLocation->getDropboxRefreshToken(),
]
);
$cacheItem->set($token->getToken());
}
$cacheItem->expiresAfter(600);
$this->psr6Cache->saveDeferred($cacheItem);
}
return $cacheItem->get();
}
private function getOauthProvider(): OAuthProvider
{
return new OAuthProvider([
'clientId' => $this->storageLocation->getDropboxAppKey(),
'clientSecret' => $this->storageLocation->getDropboxAppSecret(),
]);
}
private function getTokenCacheKey(): string
{
return 'storage_location_' . ($this->storageLocation->getId() ?? 'new') . '_auth_token';
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Service\Dropbox;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Psr\Http\Message\ResponseInterface;
final class OAuthProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
/**
* @var string Key used in the access token response to identify the resource owner.
*/
public const ACCESS_TOKEN_RESOURCE_OWNER_ID = 'account_id';
public function getBaseAuthorizationUrl(): string
{
return 'https://www.dropbox.com/oauth2/authorize';
}
public function getBaseAccessTokenUrl(array $params): string
{
return 'https://api.dropbox.com/oauth2/token';
}
public function getResourceOwnerDetailsUrl(AccessToken $token): string
{
return 'https://api.dropbox.com/2/users/get_current_account';
}
protected function getDefaultScopes(): array
{
return [];
}
/**
* Check a provider response for errors.
*
* @link https://www.dropbox.com/developers/core/docs
* @throws IdentityProviderException
* @param ResponseInterface $response
* @param array|string $data Parsed response data
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data)
{
if (isset($data['error'])) {
throw new IdentityProviderException(
$data['error'] ?: $response->getReasonPhrase(),
$response->getStatusCode(),
(string)$response->getBody()
);
}
}
protected function createResourceOwner(array $response, AccessToken $token)
{
throw new \LogicException('Not implemented.');
}
}

View File

@ -13,11 +13,15 @@ trait LoadFromParentObject
{
if (is_object($obj)) {
foreach (get_object_vars($obj) as $key => $value) {
$this->$key = $value;
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
} elseif (is_array($obj)) {
foreach ($obj as $key => $value) {
$this->$key = $value;
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
}