From 3ff694fba28fd18794309a2df07e83bf2c272d2e Mon Sep 17 00:00:00 2001 From: Kyrch Date: Mon, 18 Nov 2024 15:23:51 -0300 Subject: [PATCH] feat: added MAL option to external profiles (#760) --- .../BaseExternalEntryTokenAction.php | 6 +- .../Token/AnilistExternalEntryTokenAction.php | 12 +- .../Token/MalExternalEntryTokenAction.php | 131 ++++++++++++++++++ .../ExternalToken/BaseExternalTokenAction.php | 4 +- .../Site/AnilistExternalTokenAction.php | 6 +- .../Site/MalExternalTokenAction.php | 70 ++++++++++ .../StoreExternalProfileTokenAction.php | 6 +- .../SyncExternalProfileAction.php | 8 +- .../List/ExternalTokenCallbackAction.php | 6 +- .../Models/List/ExternalEntryWatchStatus.php | 4 +- app/Enums/Models/List/ExternalProfileSite.php | 42 ++++++ .../External/ExternalTokenAuthController.php | 29 +--- 12 files changed, 277 insertions(+), 47 deletions(-) create mode 100644 app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/MalExternalEntryTokenAction.php create mode 100644 app/Actions/Models/List/ExternalProfile/ExternalToken/Site/MalExternalTokenAction.php diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php index 148c30b09..d97e22e5a 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Crypt; abstract class BaseExternalEntryTokenAction { protected ?array $response = null; - protected ?int $id = null; + protected ?int $userId = null; /** * Create a new action instance. @@ -29,9 +29,9 @@ abstract class BaseExternalEntryTokenAction * * @return int|null */ - public function getId(): ?int + public function getUserId(): ?int { - return $this->id; + return $this->userId; } /** diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/AnilistExternalEntryTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/AnilistExternalEntryTokenAction.php index 2288c3d74..4d08895ca 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/AnilistExternalEntryTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/AnilistExternalEntryTokenAction.php @@ -69,19 +69,19 @@ class AnilistExternalEntryTokenAction extends BaseExternalEntryTokenAction * * @return int|null */ - public function getId(): ?int + public function getUserId(): ?int { - if ($this->id !== null) { - return $this->id; + if ($this->userId !== null) { + return $this->userId; } [, $payload] = explode('.', $this->getToken()); $decodedArray = json_decode(base64_decode($payload), true); - $this->id = intval(Arr::get($decodedArray, 'sub')); + $this->userId = intval(Arr::get($decodedArray, 'sub')); - return $this->id; + return $this->userId; } /** @@ -117,7 +117,7 @@ class AnilistExternalEntryTokenAction extends BaseExternalEntryTokenAction '; $variables = [ - 'userId' => $this->getId(), + 'userId' => $this->getUserId(), ]; $this->response = Http::withToken($this->getToken()) diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/MalExternalEntryTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/MalExternalEntryTokenAction.php new file mode 100644 index 000000000..137cdaa1f --- /dev/null +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Token/MalExternalEntryTokenAction.php @@ -0,0 +1,131 @@ +response === null) { + $this->makeRequest(); + } + + if ($response = $this->response) { + foreach (Arr::get($response, 'data') as $data) { + $animeInfo = Arr::get($data, 'node'); + $listStatus = Arr::get($data, 'list_status'); + + $watchStatus = Arr::get($listStatus, 'is_rewatching') + ? 'rewatching' + : Arr::get($listStatus, 'status'); + + $entries[] = [ + ExternalResource::ATTRIBUTE_EXTERNAL_ID => Arr::get($animeInfo, 'id'), + ExternalEntry::ATTRIBUTE_SCORE => Arr::get($listStatus, 'score'), + ExternalEntry::ATTRIBUTE_WATCH_STATUS => ExternalEntryWatchStatus::getMalMapping($watchStatus)->value, + ExternalEntry::ATTRIBUTE_IS_FAVORITE => false, + ]; + } + } + + return $entries; + } + + /** + * Get the username. + * + * @return string|null + */ + public function getUsername(): ?string + { + if ($this->userResponse === null) { + $this->makeUserRequest(); + } + + return Arr::get($this->userResponse, 'name'); + } + + /** + * Get the id of the external user. + * + * @return int|null + */ + public function getUserId(): ?int + { + if ($this->response === null) { + $this->makeUserRequest(); + } + + return Arr::get($this->userResponse, 'id'); + } + + /** + * Make the request to the user endpoint of the external api. + * + * @return static + */ + protected function makeUserRequest(): static + { + try { + $this->userResponse = Http::withToken($this->getToken()) + ->get('https://api.myanimelist.net/v2/users/@me') + ->throw() + ->json(); + + return $this; + } catch (RequestException $e) { + Log::error($e->getMessage()); + + throw $e; + } + } + + /** + * Make the request to the external api. + * + * @return static + */ + protected function makeRequest(): static + { + try { + $this->response = Http::withToken($this->getToken()) + ->get('https://api.myanimelist.net/v2/users/@me/animelist', [ + 'fields' => 'list_status', + 'limit' => '1000', + ]) + ->throw() + ->json(); + + var_dump($this->response); + + return $this; + } catch (RequestException $e) { + Log::error($e->getMessage()); + + throw $e; + } + } +} diff --git a/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php index 55afcf4b1..fc318537a 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php @@ -21,8 +21,8 @@ abstract class BaseExternalTokenAction /** * Use the authorization code to get the tokens and store them. * - * @param string $code + * @param array $parameters * @return ExternalToken|null */ - abstract public function store(string $code): ?ExternalToken; + abstract public function store(array $parameters): ?ExternalToken; } diff --git a/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php index 8bf3dcb53..6ae175033 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php @@ -22,13 +22,15 @@ class AnilistExternalTokenAction extends BaseExternalTokenAction /** * Use the authorization code to get the tokens and store them. * - * @param string $code + * @param array $parameters * @return ExternalToken * * @throws Exception */ - public function store(string $code): ExternalToken + public function store(array $parameters): ExternalToken { + $code = Arr::get($parameters, 'code'); + try { $response = Http::acceptJson() ->asForm() diff --git a/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/MalExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/MalExternalTokenAction.php new file mode 100644 index 000000000..f8bbee8dc --- /dev/null +++ b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/MalExternalTokenAction.php @@ -0,0 +1,70 @@ +post('https://myanimelist.net/v1/oauth2/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => Config::get('services.mal.client_id'), + 'client_secret' => Config::get('services.mal.client_secret'), + 'redirect_uri' => Config::get('services.mal.redirect_uri'), + 'code' => $code, + 'code_verifier' => $codeVerifier, + ]) + ->throw() + ->json(); + + $token = Arr::get($response, 'access_token'); + $refreshToken = Arr::get($response, 'refresh_token'); + + if ($token === null) { + throw new Error('Failed to get token'); + } + + return ExternalToken::query()->create([ + ExternalToken::ATTRIBUTE_ACCESS_TOKEN => Crypt::encrypt($token), + ExternalToken::ATTRIBUTE_REFRESH_TOKEN => Crypt::encrypt($refreshToken), + ]); + } catch (Exception $e) { + Log::error($e->getMessage()); + + throw $e; + } + } +} diff --git a/app/Actions/Models/List/ExternalProfile/StoreExternalProfileTokenAction.php b/app/Actions/Models/List/ExternalProfile/StoreExternalProfileTokenAction.php index 1d84f9df9..eb1be04d8 100644 --- a/app/Actions/Models/List/ExternalProfile/StoreExternalProfileTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/StoreExternalProfileTokenAction.php @@ -7,6 +7,7 @@ namespace App\Actions\Models\List\ExternalProfile; use App\Actions\Http\Api\StoreAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\BaseExternalEntryTokenAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\Token\AnilistExternalEntryTokenAction; +use App\Actions\Models\List\ExternalProfile\ExternalEntry\Token\MalExternalEntryTokenAction; use App\Enums\Models\List\ExternalProfileSite; use App\Enums\Models\List\ExternalProfileVisibility; use App\Models\List\External\ExternalToken; @@ -43,7 +44,7 @@ class StoreExternalProfileTokenAction return null; } - $userId = $action->getId(); + $userId = $action->getUserId(); $profile = $this->findForUserIdOrCreate($userId, $site, $action, $parameters); @@ -52,7 +53,7 @@ class StoreExternalProfileTokenAction } catch (Exception $e) { Log::error($e->getMessage()); - return null; + throw $e; } } @@ -118,6 +119,7 @@ class StoreExternalProfileTokenAction { return match ($site) { ExternalProfileSite::ANILIST => new AnilistExternalEntryTokenAction($token), + ExternalProfileSite::MAL => new MalExternalEntryTokenAction($token), default => null, }; } diff --git a/app/Actions/Models/List/ExternalProfile/SyncExternalProfileAction.php b/app/Actions/Models/List/ExternalProfile/SyncExternalProfileAction.php index f8b99275b..122246eb4 100644 --- a/app/Actions/Models/List/ExternalProfile/SyncExternalProfileAction.php +++ b/app/Actions/Models/List/ExternalProfile/SyncExternalProfileAction.php @@ -8,6 +8,7 @@ use App\Actions\Models\List\ExternalProfile\ExternalEntry\BaseExternalEntryActio use App\Actions\Models\List\ExternalProfile\ExternalEntry\BaseExternalEntryTokenAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\Username\AnilistExternalEntryAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\Token\AnilistExternalEntryTokenAction; +use App\Actions\Models\List\ExternalProfile\ExternalEntry\Token\MalExternalEntryTokenAction; use App\Enums\Models\List\ExternalProfileSite; use App\Models\List\External\ExternalEntry; use App\Models\List\ExternalProfile; @@ -63,7 +64,11 @@ class SyncExternalProfileAction } } - $profile->externalentries()->upsert($externalEntries, [ExternalEntry::ATTRIBUTE_ANIME, ExternalEntry::ATTRIBUTE_PROFILE]); + ExternalEntry::upsert($externalEntries, [ExternalEntry::ATTRIBUTE_ANIME, ExternalEntry::ATTRIBUTE_PROFILE]); + + $profile->update([ExternalProfile::ATTRIBUTE_SYNCED_AT => now()]); + + DB::commit(); return $profile; } catch (Exception $e) { @@ -85,6 +90,7 @@ class SyncExternalProfileAction { return match ($profile->site) { ExternalProfileSite::ANILIST => new AnilistExternalEntryTokenAction($profile->externaltoken), + ExternalProfileSite::MAL => new MalExternalEntryTokenAction($profile->externaltoken), default => null, }; } diff --git a/app/Actions/Models/List/ExternalTokenCallbackAction.php b/app/Actions/Models/List/ExternalTokenCallbackAction.php index 2bc117060..f5d362597 100644 --- a/app/Actions/Models/List/ExternalTokenCallbackAction.php +++ b/app/Actions/Models/List/ExternalTokenCallbackAction.php @@ -6,6 +6,7 @@ namespace App\Actions\Models\List; use App\Actions\Models\List\ExternalProfile\ExternalToken\BaseExternalTokenAction; use App\Actions\Models\List\ExternalProfile\ExternalToken\Site\AnilistExternalTokenAction; +use App\Actions\Models\List\ExternalProfile\ExternalToken\Site\MalExternalTokenAction; use App\Actions\Models\List\ExternalProfile\StoreExternalProfileTokenAction; use App\Enums\Models\List\ExternalProfileSite; use App\Models\List\ExternalProfile; @@ -42,7 +43,7 @@ class ExternalTokenCallbackAction throw new Error("Action not found for site {$profileSite->localize()}", 404); } - $externalToken = $action->store(Arr::get($parameters, 'code')); + $externalToken = $action->store($parameters); if ($externalToken === null) { throw new Error('Invalid Code', 400); @@ -52,6 +53,8 @@ class ExternalTokenCallbackAction $profile = $profileAction->findOrCreate($externalToken, $parameters); + $externalToken->externalprofile()->associate($profile); + DB::commit(); return $profile; @@ -74,6 +77,7 @@ class ExternalTokenCallbackAction { return match ($site) { ExternalProfileSite::ANILIST => new AnilistExternalTokenAction(), + ExternalProfileSite::MAL => new MalExternalTokenAction(), default => null, }; } diff --git a/app/Enums/Models/List/ExternalEntryWatchStatus.php b/app/Enums/Models/List/ExternalEntryWatchStatus.php index 77346d339..2505f20c6 100644 --- a/app/Enums/Models/List/ExternalEntryWatchStatus.php +++ b/app/Enums/Models/List/ExternalEntryWatchStatus.php @@ -29,12 +29,12 @@ enum ExternalEntryWatchStatus: int public static function getMalMapping(string $status): static { return match ($status) { - 'rewatching' => static::REWATCHING, 'watching' => static::WATCHING, 'completed' => static::COMPLETED, 'on_hold' => static::PAUSED, 'dropped' => static::DROPPED, - default => static::PLAN_TO_WATCH, + 'plan_to_watch' => static::PLAN_TO_WATCH, + default => static::REWATCHING, }; } diff --git a/app/Enums/Models/List/ExternalProfileSite.php b/app/Enums/Models/List/ExternalProfileSite.php index 153fc907a..e3b8626db 100644 --- a/app/Enums/Models/List/ExternalProfileSite.php +++ b/app/Enums/Models/List/ExternalProfileSite.php @@ -6,6 +6,9 @@ namespace App\Enums\Models\List; use App\Concerns\Enums\LocalizesName; use App\Enums\Models\Wiki\ResourceSite; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Str; /** * Enum ExternalProfileSite. @@ -31,4 +34,43 @@ enum ExternalProfileSite: int static::KITSU => ResourceSite::KITSU, }; } + + /** + * Get the link of the external site to authenticate the user. + * + * @return string|null + */ + public function getAuthorizeUrl(): ?string + { + if ($this === static::MAL) { + $codeVerifier = bin2hex(random_bytes(64)); + + $id = Str::uuid()->toString(); + + Cache::set("mal-external-token-request-{$id}", $codeVerifier); + + $query = [ + 'client_id' => Config::get('services.mal.client_id'), + 'redirect_uri' => Config::get('services.mal.redirect_uri'), + 'code_challenge' => $codeVerifier, + 'state' => $id, + 'response_type' => 'code', + 'code_challenge_method' => 'plain', + ]; + + return 'https://myanimelist.net/v1/oauth2/authorize?' . http_build_query($query); + } + + if ($this === static::ANILIST) { + $query = [ + 'client_id' => Config::get('services.anilist.client_id'), + 'redirect_uri' => Config::get('services.anilist.redirect_uri'), + 'response_type' => 'code', + ]; + + return 'https://anilist.co/api/v2/oauth/authorize?' . http_build_query($query); + } + + return null; + } } diff --git a/app/Http/Controllers/List/External/ExternalTokenAuthController.php b/app/Http/Controllers/List/External/ExternalTokenAuthController.php index cc98d8cd3..f847cf1d3 100644 --- a/app/Http/Controllers/List/External/ExternalTokenAuthController.php +++ b/app/Http/Controllers/List/External/ExternalTokenAuthController.php @@ -14,7 +14,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Str; use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; @@ -55,7 +54,7 @@ class ExternalTokenAuthController extends Controller $profileSite = ExternalProfileSite::fromLocalizedName($site); if ($profileSite instanceof ExternalProfileSite) { - $link = $this->getRedirectLink($profileSite); + $link = $profileSite->getAuthorizeUrl(); if ($link !== null) { return Redirect::to($link); @@ -66,30 +65,4 @@ class ExternalTokenAuthController extends Controller 'error' => 'invalid site', ], 400); } - - /** - * Get the link of the external site to authenticate the user. - * - * @param ExternalProfileSite $site - * @return string - */ - private function getRedirectLink(ExternalProfileSite $site): ?string - { - switch ($site) { - case ExternalProfileSite::KITSU: - return null; - case ExternalProfileSite::MAL: - return null; - case ExternalProfileSite::ANILIST: - $query = [ - 'client_id' => Config::get('services.anilist.client_id'), - 'redirect_uri' => Config::get('services.anilist.redirect_uri'), - 'response_type' => 'code', - ]; - - return 'https://anilist.co/api/v2/oauth/authorize?' . http_build_query($query); - default: - return null; - } - } }