diff --git a/.env.example b/.env.example index a56a3778f..9a3469610 100644 --- a/.env.example +++ b/.env.example @@ -253,8 +253,13 @@ DISCORD_BOT_API_URL= DISCORD_BOT_API_KEY= DB_UPDATES_DISCORD_CHANNEL= ADMIN_DISCORD_CHANNEL= -MAL_CLIENT_ID=null OPENAI_BEARER_TOKEN=null +MAL_CLIENT_ID=null +MAL_CLIENT_SECRET=null +MAL_REDIRECT_URI=null +ANILIST_CLIENT_ID=null +ANILIST_CLIENT_SECRET=null +ANILIST_REDIRECT_URI=null # session SESSION_DRIVER=database @@ -294,3 +299,4 @@ WEB_PATH= # wiki WIKI_LOGIN=http://localhost/login WIKI_RESET_PASSWORD=http://localhost/reset-password +WIKI_EXTERNAL_PROFILE=http://localhost/external diff --git a/.env.example-sail b/.env.example-sail index a295cfb5b..1cfc59b0a 100644 --- a/.env.example-sail +++ b/.env.example-sail @@ -251,8 +251,13 @@ DISCORD_BOT_API_URL= DISCORD_BOT_API_KEY= DB_UPDATES_DISCORD_CHANNEL= ADMIN_DISCORD_CHANNEL= -MAL_CLIENT_ID=null OPENAI_BEARER_TOKEN=null +MAL_CLIENT_ID=null +MAL_CLIENT_SECRET=null +MAL_REDIRECT_URI=null +ANILIST_CLIENT_ID=null +ANILIST_CLIENT_SECRET=null +ANILIST_REDIRECT_URI=null # session SESSION_DRIVER=database @@ -292,3 +297,4 @@ WEB_PATH= # wiki WIKI_LOGIN=http://localhost/login WIKI_RESET_PASSWORD=http://localhost/reset-password +WIKI_EXTERNAL_PROFILE=http://localhost/external \ No newline at end of file diff --git a/app/Actions/Models/List/BaseStoreExternalProfileAction.php b/app/Actions/Models/List/BaseStoreExternalProfileAction.php new file mode 100644 index 000000000..d843f6a65 --- /dev/null +++ b/app/Actions/Models/List/BaseStoreExternalProfileAction.php @@ -0,0 +1,49 @@ +where(ExternalResource::ATTRIBUTE_SITE, $profileSite->getResourceSite()->value) + ->whereIn(ExternalResource::ATTRIBUTE_EXTERNAL_ID, Arr::pluck($entries, 'external_id')) + ->with(ExternalResource::RELATION_ANIME) + ->get() + ->mapWithKeys(fn (ExternalResource $resource) => [$resource->external_id => $resource->anime]); + + $this->resources = $externalResources; + } + + /** + * Get the animes by the external id. + * + * @param int $externalId + * @return Collection + */ + protected function getAnimesByExternalId(int $externalId): Collection + { + return $this->resources[$externalId] ?? new Collection(); + } +} \ No newline at end of file diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryAction.php index 1630ebab6..df7703f87 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryAction.php @@ -15,6 +15,8 @@ */ abstract class BaseExternalEntryAction { + protected ?array $response = null; + /** * Create a new action instance. * @@ -54,6 +56,13 @@ public function getUsername(): string return Arr::get($this->profileParameters, 'name'); } + /** + * Get the id of the external user. + * + * @return int|null + */ + abstract public function getId(): ?int; + /** * Get the entries of the response. * @@ -64,7 +73,7 @@ abstract public function getEntries(): array; /** * Make the request to the external api. * - * @return array|null + * @return static */ - abstract public function makeRequest(): ?array; + abstract protected function makeRequest(): static; } diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php index 7d8b70b96..3470acc4c 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/BaseExternalEntryTokenAction.php @@ -4,6 +4,11 @@ namespace App\Actions\Models\List\ExternalProfile\ExternalEntry; +use App\Models\List\External\ExternalToken; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use Illuminate\Support\Facades\Config; + /** * Class BaseExternalEntryTokenAction * @@ -11,23 +16,24 @@ */ abstract class BaseExternalEntryTokenAction { + protected ?array $response = null; + protected ?int $id = null; + /** * Create a new action instance. - * - * @param array $parameters */ - public function __construct(protected array $parameters) + public function __construct(protected ExternalToken $token) { } /** - * Get the username of the profile. + * Get the id of the external user. * - * @return string + * @return int|null */ - public function getUsername(): string + public function getId(): ?int { - return ''; // TODO + return $this->id; } /** @@ -37,7 +43,17 @@ public function getUsername(): string */ public function getToken(): string { - return ''; // TODO + return $this->token->access_token; + } + + /** + * Get the username. + * + * @return string|null + */ + public function getUsername(): ?string + { + return null; } /** @@ -50,7 +66,7 @@ abstract public function getEntries(): array; /** * Make the request to the external api. * - * @return array|null + * @return static */ - abstract public function makeRequest(): ?array; + abstract protected function makeRequest(): static; } diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryAction.php index 1fd74928c..ebf827e93 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryAction.php @@ -26,39 +26,55 @@ class AnilistExternalEntryAction extends BaseExternalEntryAction public function getEntries(): array { $entries = []; - $response = $this->makeRequest(); - - if ($response !== null) { - $favorites = Arr::map(Arr::get($response, 'data.User.favourites.anime.nodes'), fn ($value) => $value['id']); - $lists = Arr::where(Arr::get($response, 'data.MediaListCollection.lists'), fn ($value) => $value['isCustomList'] === false); - - foreach ($lists as $list) { - foreach (Arr::get($list, 'entries') as $entry) { - $entryId = intval(Arr::get($entry, 'media.id')); - $entries[] = [ - ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId, - ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'), - ExternalEntry::ATTRIBUTE_WATCH_STATUS => ExternalEntryWatchStatus::getAnilistMapping(Arr::get($entry, 'status'))->value, - ExternalEntry::ATTRIBUTE_IS_FAVORITE => in_array($entryId, $favorites), - ]; - } + + if ($this->response === null) { + $this->makeRequest(); + } + + $favorites = Arr::map(Arr::get($this->response, 'data.User.favourites.anime.nodes'), fn ($value) => $value['id']); + $lists = Arr::where(Arr::get($this->response, 'data.MediaListCollection.lists'), fn ($value) => $value['isCustomList'] === false); + + foreach ($lists as $list) { + foreach (Arr::get($list, 'entries') as $entry) { + $entryId = intval(Arr::get($entry, 'media.id')); + $entries[] = [ + ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId, + ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'), + ExternalEntry::ATTRIBUTE_WATCH_STATUS => ExternalEntryWatchStatus::getAnilistMapping(Arr::get($entry, 'status'))->value, + ExternalEntry::ATTRIBUTE_IS_FAVORITE => in_array($entryId, $favorites), + ]; } } return $entries; } + /** + * Get the id of the external user. + * + * @return int|null + */ + public function getId(): ?int + { + if ($this->response === null) { + $this->makeRequest(); + } + + return Arr::get($this->response, 'data.User.id'); + } + /** * Make the request to the external api. * - * @return array|null + * @return static */ - public function makeRequest(): ?array + protected function makeRequest(): static { try { $query = ' query($userName: String) { User(name: $userName) { + id favourites { anime { nodes { @@ -88,14 +104,14 @@ public function makeRequest(): ?array 'userName' => $this->getUsername(), ]; - $response = Http::post('https://graphql.anilist.co', [ + $this->response = Http::post('https://graphql.anilist.co', [ 'query' => $query, 'variables' => $variables, ]) ->throw() ->json(); - return $response; + return $this; } catch (RequestException $e) { Log::error($e->getMessage()); diff --git a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryTokenAction.php index cfd8823dc..eb0265cd9 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalEntry/Site/AnilistExternalEntryTokenAction.php @@ -8,8 +8,12 @@ use App\Enums\Models\List\ExternalEntryWatchStatus; use App\Models\List\External\ExternalEntry; use App\Models\Wiki\ExternalResource; +use Exception; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -26,9 +30,12 @@ class AnilistExternalEntryTokenAction extends BaseExternalEntryTokenAction public function getEntries(): array { $entries = []; - $response = $this->makeRequest(); - if ($response !== null) { + if ($this->response === null) { + $this->makeRequest(); + } + + if ($response = $this->response) { $lists = Arr::where(Arr::get($response, 'data.MediaListCollection.lists'), fn ($value) => $value['isCustomList'] === false); foreach ($lists as $list) { @@ -47,17 +54,61 @@ public function getEntries(): array return $entries; } + /** + * Get the username. + * + * @return string|null + */ + public function getUsername(): ?string + { + if ($this->response === null) { + $this->makeRequest(); + } + + return Arr::get($this->response, 'data.Viewer.name'); + } + + /** + * Get the id of the external user. + * + * @return int|null + */ + public function getId(): ?int + { + if ($this->id !== null) { + return $this->id; + } + + // TODO: This should be tested. + try { + $decoded = JWT::decode($this->getToken(), new Key(Config::get('services.anilist.client_secret'), 'HS256')); + + $decodedArray = json_decode(json_encode($decoded), true); + + $this->id = Arr::get($decodedArray, 'id'); + + return $this->id; + } catch (Exception $e) { + Log::error($e->getMessage()); + + return null; + } + } + /** * Make the request to the external api. * - * @return array|null + * @return static */ - public function makeRequest(): ?array + protected function makeRequest(): static { try { $query = ' - query($userName: String) { - MediaListCollection(userName: $userName, type: ANIME) { + query($userId: Int) { + Viewer { + name + } + MediaListCollection(userId: $userId, type: ANIME) { lists { name status @@ -77,10 +128,10 @@ public function makeRequest(): ?array '; $variables = [ - 'userName' => $this->getUsername(), + 'userId' => $this->getId(), ]; - $response = Http::withToken($this->getToken()) + $this->response = Http::withToken($this->getToken()) ->post('https://graphql.anilist.co', [ 'query' => $query, 'variables' => $variables, @@ -88,7 +139,7 @@ public function makeRequest(): ?array ->throw() ->json(); - return $response; + return $this; } catch (RequestException $e) { Log::error($e->getMessage()); diff --git a/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php index 6463921d9..55afcf4b1 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalToken/BaseExternalTokenAction.php @@ -11,5 +11,18 @@ */ abstract class BaseExternalTokenAction { - abstract public function store(): ExternalToken; + /** + * Create a new action instance. + */ + public function __construct() + { + } + + /** + * Use the authorization code to get the tokens and store them. + * + * @param string $code + * @return ExternalToken|null + */ + abstract public function store(string $code): ?ExternalToken; } diff --git a/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php index e1700f6a1..39e2b2a81 100644 --- a/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/ExternalToken/Site/AnilistExternalTokenAction.php @@ -6,6 +6,11 @@ use App\Actions\Models\List\ExternalProfile\ExternalToken\BaseExternalTokenAction; use App\Models\List\External\ExternalToken; +use Illuminate\Http\Client\RequestException; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; /** * Class AnilistExternalTokenAction. @@ -13,18 +18,40 @@ class AnilistExternalTokenAction extends BaseExternalTokenAction { /** - * Create a new action instance. + * Use the authorization code to get the tokens and store them. * * @param string $code + * @return ExternalToken|null */ - public function __construct(protected string $code) + public function store(string $code): ?ExternalToken { - } + try { + $response = Http::acceptJson() + ->asForm() + ->post('https://anilist.co/api/v2/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => Config::get('services.anilist.client_id'), + 'client_secret' => Config::get('services.anilist.client_secret'), + 'redirect_uri' => Config::get('services.anilist.redirect_uri'), + 'code' => $code, + ]) + ->throw() + ->json(); - public function store(): ExternalToken - { - // TODO: Make a request to the AniList API to get the access and the refresh tokens - // and return the external token created. - return new ExternalToken(); + $token = Arr::get($response, 'access_token'); + + if ($token !== null) { + return ExternalToken::query()->create([ + ExternalToken::ATTRIBUTE_ACCESS_TOKEN => $token, + ]); + } + + return null; + + } catch (RequestException $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 new file mode 100644 index 000000000..92c8df0ef --- /dev/null +++ b/app/Actions/Models/List/ExternalProfile/StoreExternalProfileTokenAction.php @@ -0,0 +1,161 @@ +getActionClass($site, $token); + + if ($action === null) { + throw new Error("Undefined action for site {$site->localize()}"); // TODO: check if it is working + } + + $userId = $action->getId(); + + // TODO: if the profile already exists, the list should be synced. + $profile = $this->searchForUserId($userId, $site, $action, $parameters); + + $entries = $action->getEntries(); + + $this->preloadResources($site, $entries); + + $token->externalprofile()->associate($profile); + + $externalEntries = []; + foreach ($entries as $entry) { + $externalId = Arr::get($entry, 'external_id'); + + foreach ($this->getAnimesByExternalId($externalId) as $anime) { + $externalEntries[] = [ + ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, ExternalEntry::ATTRIBUTE_SCORE), + ExternalEntry::ATTRIBUTE_IS_FAVORITE => Arr::get($entry, ExternalEntry::ATTRIBUTE_IS_FAVORITE), + ExternalEntry::ATTRIBUTE_WATCH_STATUS => Arr::get($entry, ExternalEntry::ATTRIBUTE_WATCH_STATUS), + ExternalEntry::ATTRIBUTE_ANIME => $anime->getKey(), + ExternalEntry::ATTRIBUTE_PROFILE => $profile->getKey(), + ]; + } + } + + ExternalEntry::insert($externalEntries); + + DB::commit(); + + return $profile; + + } catch (Exception $e) { + Log::error($e->getMessage()); + + DB::rollBack(); + + throw $e; + } + } + + /** + * Find or create the profile for a userId and site. + * + * @param int $userId + * @param ExternalProfileSite $site + * @param BaseExternalEntryTokenAction $action + * @param array $parameters + * @return ExternalProfile|null + */ + protected function searchForUserId(int $userId, ExternalProfileSite $site, BaseExternalEntryTokenAction $action, array $parameters): ?ExternalProfile + { + $claimedProfile = ExternalProfile::query() + ->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId) + ->where(ExternalProfile::ATTRIBUTE_SITE, $site->value) + ->whereHas(ExternalProfile::RELATION_USER) + ->first(); + + if ($claimedProfile instanceof ExternalProfile) { + return $claimedProfile; + } + + $unclaimedProfile = ExternalProfile::query() + ->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId) + ->where(ExternalProfile::ATTRIBUTE_SITE, $site->value) + ->whereDoesntHave(ExternalProfile::RELATION_USER) + ->first(); + + if ($unclaimedProfile instanceof ExternalProfile) { + $unclaimedProfile->update([ + ExternalProfile::ATTRIBUTE_USER => Arr::get($parameters, ExternalProfile::ATTRIBUTE_USER), + ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(), + ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value, + ]); + + return $unclaimedProfile; + } + + $storeAction = new StoreAction(); + + $profile = $storeAction->store(ExternalProfile::query(), [ + ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $userId, + ExternalProfile::ATTRIBUTE_USER => Arr::get($parameters, ExternalProfile::ATTRIBUTE_USER), + ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(), + ExternalProfile::ATTRIBUTE_SITE => $site->value, + ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value, + ]); + + if ($profile instanceof ExternalProfile) { + return $profile; + } + + return null; + } + + /** + * Get the mapping for the entries token class. + * + * @param ExternalProfileSite $site + * @param ExternalToken $token + * @return BaseExternalEntryTokenAction|null + */ + protected function getActionClass(ExternalProfileSite $site, ExternalToken $token): ?BaseExternalEntryTokenAction + { + return match ($site) { + ExternalProfileSite::ANILIST => new AnilistExternalEntryTokenAction($token), + default => null, + }; + } +} diff --git a/app/Actions/Models/List/ExternalProfile/StoreExternalProfileAction.php b/app/Actions/Models/List/ExternalProfile/StoreExternalProfileUsernameAction.php similarity index 69% rename from app/Actions/Models/List/ExternalProfile/StoreExternalProfileAction.php rename to app/Actions/Models/List/ExternalProfile/StoreExternalProfileUsernameAction.php index 3a9eb6f33..277b9506a 100644 --- a/app/Actions/Models/List/ExternalProfile/StoreExternalProfileAction.php +++ b/app/Actions/Models/List/ExternalProfile/StoreExternalProfileUsernameAction.php @@ -5,47 +5,49 @@ namespace App\Actions\Models\List\ExternalProfile; use App\Actions\Http\Api\StoreAction; +use App\Actions\Models\List\BaseStoreExternalProfileAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\BaseExternalEntryAction; use App\Actions\Models\List\ExternalProfile\ExternalEntry\Site\AnilistExternalEntryAction; use App\Enums\Models\List\ExternalProfileSite; use App\Enums\Models\List\ExternalProfileVisibility; use App\Models\List\External\ExternalEntry; use App\Models\List\ExternalProfile; -use App\Models\Wiki\Anime; -use App\Models\Wiki\ExternalResource; use Error; use Exception; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; /** - * Class StoreExternalProfileAction. + * Class StoreExternalProfileUsernameAction. */ -class StoreExternalProfileAction +class StoreExternalProfileUsernameAction extends BaseStoreExternalProfileAction { - protected Collection $resources; - /** - * Store external profile and its entries. + * Find or store an external profile and its entries given determined username. * * @param Builder $builder * @param array $profileParameters - * @return Model + * @return ExternalProfile * * @throws Exception */ - public function store(Builder $builder, array $profileParameters): Model + public function findOrCreate(Builder $builder, array $profileParameters): ExternalProfile { try { - DB::beginTransaction(); + $profileSite = ExternalProfileSite::fromLocalizedName(Arr::get($profileParameters, 'site')); - $storeAction = new StoreAction(); + $findProfile = ExternalProfile::query() + ->where(ExternalProfile::ATTRIBUTE_NAME, Arr::get($profileParameters, 'name')) + ->where(ExternalProfile::ATTRIBUTE_SITE, $profileSite->value) + ->first(); - $profileSite = ExternalProfileSite::fromLocalizedName(Arr::get($profileParameters, 'site')); + if ($findProfile instanceof ExternalProfile) { + return $findProfile; + } + + DB::beginTransaction(); $action = $this->getActionClass($profileSite, $profileParameters); @@ -57,7 +59,11 @@ public function store(Builder $builder, array $profileParameters): Model $this->preloadResources($profileSite, $entries); + $storeAction = new StoreAction(); + + /** @var ExternalProfile $profile */ $profile = $storeAction->store($builder, [ + ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $action->getId(), ExternalProfile::ATTRIBUTE_NAME => Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_NAME), ExternalProfile::ATTRIBUTE_SITE => $profileSite->value, ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::fromLocalizedName(Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_VISIBILITY))->value, @@ -106,34 +112,4 @@ protected function getActionClass(ExternalProfileSite $site, array $profileParam default => null, }; } - - /** - * Preload the resources for performance proposals. - * - * @param ExternalProfileSite $profileSite - * @param array $entries - * @return void - */ - protected function preloadResources(ExternalProfileSite $profileSite, array $entries): void - { - $externalResources = ExternalResource::query() - ->where(ExternalResource::ATTRIBUTE_SITE, $profileSite->getResourceSite()->value) - ->whereIn(ExternalResource::ATTRIBUTE_EXTERNAL_ID, Arr::pluck($entries, 'external_id')) - ->with(ExternalResource::RELATION_ANIME) - ->get() - ->mapWithKeys(fn (ExternalResource $resource) => [$resource->external_id => $resource->anime]); - - $this->resources = $externalResources; - } - - /** - * Get the animes by the external id. - * - * @param int $externalId - * @return Collection - */ - protected function getAnimesByExternalId(int $externalId): Collection - { - return $this->resources[$externalId] ?? new Collection(); - } -} +} \ No newline at end of file diff --git a/app/Actions/Models/List/ExternalProfile/StoreExternalTokenAction.php b/app/Actions/Models/List/ExternalProfile/StoreExternalTokenAction.php index 46a5b1b3f..ce3d1b89f 100644 --- a/app/Actions/Models/List/ExternalProfile/StoreExternalTokenAction.php +++ b/app/Actions/Models/List/ExternalProfile/StoreExternalTokenAction.php @@ -7,6 +7,9 @@ use App\Actions\Models\List\ExternalProfile\ExternalToken\BaseExternalTokenAction; use App\Actions\Models\List\ExternalProfile\ExternalToken\Site\AnilistExternalTokenAction; use App\Enums\Models\List\ExternalProfileSite; +use App\Models\List\External\ExternalToken; +use Error; +use Exception; use Illuminate\Support\Arr; /** @@ -14,29 +17,41 @@ */ class StoreExternalTokenAction { - public function store($query) + /** + * Store the token given the query of the callback URL. + * + * @param array $query + * @return ExternalToken|null + * + * @throws Exception + */ + public function store(array $query): ?ExternalToken { - $profileSite = ExternalProfileSite::fromLocalizedName(Arr::get($query, 'site')); - $code = Arr::get($query, 'code'); + $site = Arr::get($query, 'site'); + $profileSite = ExternalProfileSite::fromLocalizedName($site); - $action = $this->getActionClass($profileSite, $code); + $action = $this->getActionClass($profileSite); - $externalToken = $action->store(); + if ($action === null) { + throw new Error("Undefined callback URL for site {$site}"); + } + + $externalToken = $action->store(Arr::get($query, 'code')); + + return $externalToken; } /** * Get the mapping for the token class. * * @param ExternalProfileSite $site - * @param string $code * @return BaseExternalTokenAction|null */ - protected function getActionClass(ExternalProfileSite $site, string $code): ?BaseExternalTokenAction + protected function getActionClass(ExternalProfileSite $site): ?BaseExternalTokenAction { return match ($site) { - ExternalProfileSite::ANILIST => new AnilistExternalTokenAction($code), + ExternalProfileSite::ANILIST => new AnilistExternalTokenAction(), default => null, }; } - } diff --git a/app/Actions/Models/Wiki/Anime/ApiAction/MalAnimeApiAction.php b/app/Actions/Models/Wiki/Anime/ApiAction/MalAnimeApiAction.php index 6b94560f3..4511cb0f3 100644 --- a/app/Actions/Models/Wiki/Anime/ApiAction/MalAnimeApiAction.php +++ b/app/Actions/Models/Wiki/Anime/ApiAction/MalAnimeApiAction.php @@ -38,7 +38,7 @@ public function handle(BelongsToMany $resources): static $resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value); if ($resource instanceof ExternalResource) { - $response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get('services.mal.client')]) + $response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get('services.mal.client_id')]) ->get("https://api.myanimelist.net/v2/anime/$resource->external_id", [ 'fields' => 'studios', ]) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index f128885ab..6839926a4 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,6 +8,7 @@ use App\Console\Commands\Storage\Admin\DumpPruneCommand; use App\Console\Commands\Storage\Admin\WikiDumpCommand; use App\Models\BaseModel; +use App\Models\List\ExternalProfile; use BezhanSalleh\FilamentExceptions\Models\Exception; use Illuminate\Auth\Console\ClearResetsCommand; use Illuminate\Cache\Console\PruneStaleTagsCommand; diff --git a/app/Filament/TableActions/Models/List/ExternalProfile/CreateExternalProfileTableAction.php b/app/Filament/TableActions/Models/List/ExternalProfile/CreateExternalProfileTableAction.php index 232172da3..e31ff515d 100644 --- a/app/Filament/TableActions/Models/List/ExternalProfile/CreateExternalProfileTableAction.php +++ b/app/Filament/TableActions/Models/List/ExternalProfile/CreateExternalProfileTableAction.php @@ -4,7 +4,7 @@ namespace App\Filament\TableActions\Models\List\ExternalProfile; -use App\Actions\Models\List\ExternalProfile\StoreExternalProfileAction; +use App\Actions\Models\List\ExternalProfile\StoreExternalProfileUsernameAction; use App\Enums\Models\List\ExternalProfileSite; use App\Enums\Models\List\ExternalProfileVisibility; use App\Filament\Components\Fields\Select; @@ -47,9 +47,9 @@ public function handle(array $fields): void $site = Arr::get($fields, ExternalProfile::ATTRIBUTE_SITE); $visibility = Arr::get($fields, ExternalProfile::ATTRIBUTE_VISIBILITY); - $action = new StoreExternalProfileAction(); + $action = new StoreExternalProfileUsernameAction(); - $action->store(ExternalProfile::query(), [ + $action->findOrCreate(ExternalProfile::query(), [ ExternalProfile::ATTRIBUTE_USER => Filament::auth()->id(), ExternalProfile::ATTRIBUTE_NAME => $name, ExternalProfile::ATTRIBUTE_SITE => ExternalProfileSite::from(intval($site))->localize(), diff --git a/app/Http/Controllers/Api/List/External/ExternalTokenCallbackController.php b/app/Http/Controllers/Api/List/External/ExternalTokenCallbackController.php index b3580a94a..a061018f3 100644 --- a/app/Http/Controllers/Api/List/External/ExternalTokenCallbackController.php +++ b/app/Http/Controllers/Api/List/External/ExternalTokenCallbackController.php @@ -4,12 +4,18 @@ namespace App\Http\Controllers\Api\List\External; +use App\Actions\Models\List\ExternalProfile\StoreExternalProfileTokenAction; use App\Actions\Models\List\ExternalProfile\StoreExternalTokenAction; use App\Features\AllowExternalProfileManagement; -use App\Http\Api\Query\Query; use App\Http\Controllers\Api\BaseController; use App\Http\Requests\Api\IndexRequest; use App\Models\List\External\ExternalToken; +use App\Models\List\ExternalProfile; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Str; use Laravel\Pennant\Middleware\EnsureFeaturesAreActive; @@ -30,24 +36,44 @@ public function __construct() ->append(AllowExternalProfileManagement::class) ->__toString(); - $this->middleware($isExternalProfileManagementAllowed)->except(['index', 'show']); + $this->middleware($isExternalProfileManagementAllowed); } /** * This is the redirect URL which is set in the external provider. * * @param IndexRequest $request + * @return RedirectResponse|JsonResponse */ - public function index(IndexRequest $request) + public function index(IndexRequest $request): RedirectResponse|JsonResponse { - //$query = new Query($request->validated()); + $validated = array_merge( + $request->validated(), + [ExternalProfile::ATTRIBUTE_USER => Auth::id()] + ); - //$action = new StoreExternalTokenAction(); + $action = new StoreExternalTokenAction(); - //$action->store($query); // This stores the external token. + $externalToken = $action->store($validated); - // TODO: We should find or create a profile with the entries. + if ($externalToken === null) { + return new JsonResponse([ + 'error' => 'invalid code', + ], 400); + } - // TODO: Then, the user should be redirect to the client page, e.g. /external/{site}/{profile}. + $profileAction = new StoreExternalProfileTokenAction(); + + $profile = $profileAction->findOrCreate($externalToken, $validated); + + // https://animethemes.moe/external/{mal|anilist}/{profile_name} + $clientUrl = Str::of(Config::get('wiki.external_profile')) + ->append('/') + ->append(Str::lower($profile->site->name)) + ->append('/') + ->append($profile->getName()) + ->__toString(); + + return Redirect::to($clientUrl); } } diff --git a/app/Http/Controllers/Api/List/ExternalProfileController.php b/app/Http/Controllers/Api/List/ExternalProfileController.php index 42c85c630..fb349d84b 100644 --- a/app/Http/Controllers/Api/List/ExternalProfileController.php +++ b/app/Http/Controllers/Api/List/ExternalProfileController.php @@ -10,7 +10,7 @@ use App\Actions\Http\Api\RestoreAction; use App\Actions\Http\Api\ShowAction; use App\Actions\Http\Api\UpdateAction; -use App\Actions\Models\List\ExternalProfile\StoreExternalProfileAction; +use App\Actions\Models\List\ExternalProfile\StoreExternalProfileUsernameAction; use App\Enums\Models\List\ExternalProfileVisibility; use App\Features\AllowExternalProfileManagement; use App\Http\Api\Query\Query; @@ -91,12 +91,12 @@ public function show(ShowRequest $request, ExternalProfile $externalprofile, Sho * Store a newly created resource. * * @param StoreRequest $request - * @param StoreExternalProfileAction $action + * @param StoreExternalProfileUsernameAction $action * @return ExternalProfileResource */ - public function store(StoreRequest $request, StoreExternalProfileAction $action): ExternalProfileResource + public function store(StoreRequest $request, StoreExternalProfileUsernameAction $action): ExternalProfileResource { - $externalprofile = $action->store(ExternalProfile::query(), $request->validated()); + $externalprofile = $action->findOrCreate(ExternalProfile::query(), $request->validated()); return new ExternalProfileResource($externalprofile, new Query()); } diff --git a/app/Models/List/ExternalProfile.php b/app/Models/List/ExternalProfile.php index 67e2d7255..28754c8ec 100644 --- a/app/Models/List/ExternalProfile.php +++ b/app/Models/List/ExternalProfile.php @@ -4,6 +4,7 @@ namespace App\Models\List; +use App\Enums\Http\Api\Filter\ComparisonOperator; use App\Enums\Models\List\ExternalProfileSite; use App\Enums\Models\List\ExternalProfileVisibility; use App\Events\List\ExternalProfile\ExternalProfileCreated; @@ -16,11 +17,13 @@ use App\Models\List\External\ExternalToken; use Database\Factories\List\ExternalProfileFactory; use Elastic\ScoutDriverPlus\Searchable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Date; /** * Class ExternalProfile. @@ -28,6 +31,7 @@ * @property int $profile_id * @property Collection $externalentries * @property ExternalToken|null $externaltoken + * @property int|null $external_user_id * @property string $name * @property ExternalProfileSite $site * @property Carbon|null $synced_at @@ -44,6 +48,7 @@ class ExternalProfile extends BaseModel final public const TABLE = 'external_profiles'; final public const ATTRIBUTE_ID = 'profile_id'; + final public const ATTRIBUTE_EXTERNAL_USER_ID = 'external_user_id'; final public const ATTRIBUTE_NAME = 'name'; final public const ATTRIBUTE_SITE = 'site'; final public const ATTRIBUTE_VISIBILITY = 'visibility'; @@ -61,6 +66,7 @@ class ExternalProfile extends BaseModel * @var array */ protected $fillable = [ + ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, ExternalProfile::ATTRIBUTE_NAME, ExternalProfile::ATTRIBUTE_SITE, ExternalProfile::ATTRIBUTE_SYNCED_AT, @@ -200,4 +206,20 @@ public function externaltoken(): HasOne { return $this->hasOne(ExternalToken::class, ExternalToken::ATTRIBUTE_PROFILE); } + + /** + * Get the prunable model query. + * + * @return Builder + */ + public function prunable(): Builder + { + return static::query() + ->whereDoesntHave(ExternalProfile::RELATION_USER) + ->where( + BaseModel::ATTRIBUTE_CREATED_AT, + ComparisonOperator::LTE->value, + Date::now()->subWeek() + ); + } } diff --git a/composer.json b/composer.json index 2b0c2d9e6..0931cd8f4 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "fakerphp/faker": "^1.21", "filament/filament": "3.2.106", "filament/forms": "3.2.106", + "firebase/php-jwt": "^6.10", "flowframe/laravel-trend": "*", "guzzlehttp/guzzle": "^7.5", "laravel-notification-channels/discord": "^1.4", diff --git a/composer.lock b/composer.lock index fd7a64767..27aa80ede 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "a7f56b90543d589c1141e6bca68e58c5", + "content-hash": "4a53b034ee5565ea66e4046e539c2fd2", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2444,6 +2444,69 @@ }, "time": "2024-07-31T11:53:30+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.10.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "500501c2ce893c824c801da135d02661199f60c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", + "reference": "500501c2ce893c824c801da135d02661199f60c5", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + }, + "time": "2024-05-18T18:05:11+00:00" + }, { "name": "flowframe/laravel-trend", "version": "v0.2.0", diff --git a/config/services.php b/config/services.php index 6dc8bb0f5..12a2a2341 100644 --- a/config/services.php +++ b/config/services.php @@ -41,11 +41,19 @@ 'admin_discord_channel' => env('ADMIN_DISCORD_CHANNEL'), ], - 'mal' => [ - 'client' => env('MAL_CLIENT_ID'), - ], - 'openai' => [ 'token' => env('OPENAI_BEARER_TOKEN'), ], + + 'anilist' => [ + 'client_id' => env('ANILIST_CLIENT_ID'), + 'client_secret' => env('ANILIST_CLIENT_SECRET'), + 'redirect_uri' => env('ANILIST_REDIRECT_URI'), + ], + + 'mal' => [ + 'client_id' => env('MAL_CLIENT_ID'), + 'client_secret' => env('MAL_CLIENT_SECRET'), + 'redirect_uri' => env('MAL_REDIRECT_URI'), + ], ]; diff --git a/config/wiki.php b/config/wiki.php index 8dbc1895f..ec0ad4047 100644 --- a/config/wiki.php +++ b/config/wiki.php @@ -16,4 +16,6 @@ 'login' => env('WIKI_LOGIN'), 'reset_password' => env('WIKI_RESET_PASSWORD'), + + 'external_profile' => env('WIKI_EXTERNAL_PROFILE'), ]; diff --git a/database/migrations/2024_08_28_203854_add_synced_at_attribute_to_external_profile_table.php b/database/migrations/2024_08_28_203854_add_attributes_to_external_profile_table.php similarity index 62% rename from database/migrations/2024_08_28_203854_add_synced_at_attribute_to_external_profile_table.php rename to database/migrations/2024_08_28_203854_add_attributes_to_external_profile_table.php index ca1199ce3..ea3961a31 100644 --- a/database/migrations/2024_08_28_203854_add_synced_at_attribute_to_external_profile_table.php +++ b/database/migrations/2024_08_28_203854_add_attributes_to_external_profile_table.php @@ -19,6 +19,12 @@ public function up(): void $table->timestamp(ExternalProfile::ATTRIBUTE_SYNCED_AT, 6)->nullable(); }); } + + if (! Schema::hasColumn(ExternalProfile::TABLE, ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID)) { + Schema::table(ExternalProfile::TABLE, function (Blueprint $table) { + $table->integer(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID)->nullable(); + }); + } } /** @@ -31,5 +37,11 @@ public function down(): void $table->dropColumn(ExternalProfile::ATTRIBUTE_SYNCED_AT); }); } + + if (Schema::hasColumn(ExternalProfile::TABLE, ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID)) { + Schema::table(ExternalProfile::TABLE, function (Blueprint $table) { + $table->dropColumn(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID); + }); + } } };