From fa3304db9206a8d3ed26431ffdd0862031f8bf94 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 13 Aug 2024 18:54:27 -0400 Subject: [PATCH] added MAL Login --- AniListUserFunctions.go | 12 +- MALTypes.go | 39 +++ MALUserFunctions.go | 250 ++++++++++++++++++ SimklUserFunctions.go | 4 +- app.go | 3 +- bruno/AniTrack/environments/Dev.bru | 7 +- frontend/src/App.svelte | 15 +- .../GlobalVariablesAndHelperFunctions.svelte | 16 +- frontend/src/Header.svelte | 22 +- frontend/src/mal/types/MALTypes.ts | 31 +++ frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 + frontend/wailsjs/go/models.ts | 58 ++++ main.go | 6 +- 14 files changed, 459 insertions(+), 22 deletions(-) create mode 100644 MALTypes.go create mode 100644 MALUserFunctions.go create mode 100644 frontend/src/mal/types/MALTypes.ts diff --git a/AniListUserFunctions.go b/AniListUserFunctions.go index 092ff94..efdd728 100644 --- a/AniListUserFunctions.go +++ b/AniListUserFunctions.go @@ -31,14 +31,13 @@ func (a *App) CheckIfAniListLoggedIn() bool { expiresIn, err := aniRing.Get("anilistTokenExpiresIn") accessToken, err := aniRing.Get("anilistAccessToken") refreshToken, err := aniRing.Get("anilistRefreshToken") - if err != nil { + if err != nil || len(accessToken.Data) == 0 { return false } else { aniListJwt.TokenType = string(tokenType.Data) aniListJwt.AccessToken = string(accessToken.Data) aniListJwt.RefreshToken = string(refreshToken.Data) - expiresInString := string(expiresIn.Data) - aniListJwt.ExpiresIn, _ = strconv.Atoi(expiresInString) + aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data)) return true } } else { @@ -52,7 +51,7 @@ func (a *App) AniListLogin() { expiresIn, err := aniRing.Get("anilistTokenExpiresIn") accessToken, err := aniRing.Get("anilistAccessToken") refreshToken, err := aniRing.Get("anilistRefreshToken") - if err != nil { + if err != nil || len(accessToken.Data) == 0 { getAniListCodeUrl := "https://anilist.co/api/v2/oauth/authorize?client_id=" + os.Getenv("ANILIST_APP_ID") + "&redirect_uri=" + os.Getenv("ANILIST_CALLBACK_URI") + "&response_type=code" runtime.BrowserOpenURL(a.ctx, getAniListCodeUrl) @@ -64,8 +63,7 @@ func (a *App) AniListLogin() { aniListJwt.TokenType = string(tokenType.Data) aniListJwt.AccessToken = string(accessToken.Data) aniListJwt.RefreshToken = string(refreshToken.Data) - expiresInString := string(expiresIn.Data) - aniListJwt.ExpiresIn, _ = strconv.Atoi(expiresInString) + aniListJwt.ExpiresIn, _ = strconv.Atoi(string(expiresIn.Data)) } } } @@ -89,7 +87,7 @@ func handleAniListCallback(wg *sync.WaitGroup) { }) _ = aniRing.Set(keyring.Item{ Key: "anilistTokenExpiresIn", - Data: []byte(string(aniListJwt.ExpiresIn)), + Data: []byte(strconv.Itoa(aniListJwt.ExpiresIn)), }) _ = aniRing.Set(keyring.Item{ Key: "anilistAccessToken", diff --git a/MALTypes.go b/MALTypes.go new file mode 100644 index 0000000..8dc91d3 --- /dev/null +++ b/MALTypes.go @@ -0,0 +1,39 @@ +package main + +type MyAnimeListJWT struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type MyAnimeListUser struct { + Id int32 `json:"id" ts_type:"id"` + Name string `json:"name" ts_type:"name"` + Picture string `json:"picture" ts_type:"picture"` + Gender string `json:"gender" ts_type:"gender"` + Birthday string `json:"birthday" ts_type:"birthday"` + Location string `json:"location" ts_type:"location"` + JoinedAt string `json:"joined_at" ts_type:"joinedAt"` + AnimeStatistics `json:"anime_statistics" ts_type:"AnimeStatistics"` + TimeZone string `json:"time_zone" ts_type:"timeZone"` + IsSupporter bool `json:"is_supporter" ts_type:"isSupporter"` +} + +type AnimeStatistics struct { + NumItemsWatching int `json:"num_items_watching" ts_type:"numItemsWatching"` + NumItemsCompleted int `json:"num_items_completed" ts_type:"numItemsCompleted"` + NumItemsOnHold int `json:"num_items_on_hold" ts_type:"numItemsOnHold"` + NumItemsDropped int `json:"num_items_dropped" ts_type:"numItemsDropped"` + NumItemsPlanToWatch int `json:"num_items_plan_to_watch" ts_type:"numItemsPlanToWatch"` + NumItems int `json:"num_items" ts_type:"numItems"` + NumDaysWatched float64 `json:"num_days_watched" ts_type:"numDaysWatched"` + NumDaysWatching float64 `json:"num_days_watching" ts_type:"numDaysWatching"` + NumDaysCompleted float64 `json:"num_days_completed" ts_type:"numDaysCompleted"` + NumDaysOnHold float64 `json:"num_days_on_hold" ts_type:"numDaysOnHold"` + NumDaysDropped float64 `json:"num_days_dropped" ts_type:"numDaysDropped"` + NumDays float64 `json:"num_days" ts_type:"numDays"` + NumEpisodes int `json:"num_episodes" ts_type:"numEpisodes"` + NumTimesRewatched int `json:"num_times_rewatched" ts_type:"numTimesRewatched"` + MeanScore float64 `json:"mean_score" ts_type:"meanScore"` +} diff --git a/MALUserFunctions.go b/MALUserFunctions.go new file mode 100644 index 0000000..f1b112b --- /dev/null +++ b/MALUserFunctions.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/99designs/keyring" + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +var myAnimeListJwt MyAnimeListJWT + +var myAnimeListRing, _ = keyring.Open(keyring.Config{ + ServiceName: "AniTrack", +}) + +var myAnimeListCtxShutdown, myAnimeListCancel = context.WithCancel(context.Background()) + +type CodeVerifier struct { + Value string +} + +const ( + length = 32 +) + +func base64URLEncode(str []byte) string { + encoded := base64.StdEncoding.EncodeToString(str) + encoded = strings.Replace(encoded, "+", "-", -1) + encoded = strings.Replace(encoded, "/", "_", -1) + encoded = strings.Replace(encoded, "=", "", -1) + return encoded +} + +func verifier() (*CodeVerifier, error) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]byte, length, length) + for i := 0; i < length; i++ { + b[i] = byte(r.Intn(255)) + } + return CreateCodeVerifierFromBytes(b) +} + +func CreateCodeVerifierFromBytes(b []byte) (*CodeVerifier, error) { + return &CodeVerifier{ + Value: base64URLEncode(b), + }, nil +} + +func (v *CodeVerifier) CodeChallengeS256() string { + h := sha256.New() + h.Write([]byte(v.Value)) + return base64URLEncode(h.Sum(nil)) +} + +func (a *App) CheckIfMyAnimeListLoggedIn() bool { + if (MyAnimeListJWT{} == myAnimeListJwt) { + tokenType, err := myAnimeListRing.Get("MyAnimeListTokenType") + expiresIn, err := myAnimeListRing.Get("MyAnimeListExpiresIn") + accessToken, err := myAnimeListRing.Get("MyAnimeListAccessToken") + refreshToken, err := myAnimeListRing.Get("MyAnimeListRefreshToken") + if err != nil || len(accessToken.Data) == 0 { + return false + } else { + myAnimeListJwt.TokenType = string(tokenType.Data) + myAnimeListJwt.ExpiresIn, err = strconv.Atoi(string(expiresIn.Data)) + if err != nil { + fmt.Println("unable to convert string to int") + } + myAnimeListJwt.AccessToken = string(accessToken.Data) + myAnimeListJwt.RefreshToken = string(refreshToken.Data) + return true + } + } else { + return true + } +} + +func (a *App) MyAnimeListLogin() { + if a.CheckIfMyAnimeListLoggedIn() == false { + tokenType, err := myAnimeListRing.Get("MyAnimeListTokenType") + expiresIn, err := myAnimeListRing.Get("MyAnimeListExpiresIn") + accessToken, err := myAnimeListRing.Get("MyAnimeListAccessToken") + refreshToken, err := myAnimeListRing.Get("MyAnimeListRefreshToken") + if err != nil || len(accessToken.Data) == 0 { + verifier, _ := verifier() + getMyAnimeListCodeUrl := "https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=" + os.Getenv("MAL_CLIENT_ID") + "&redirect_uri=" + os.Getenv("MAL_CALLBACK_URI") + "&code_challenge=" + verifier.Value + "&code_challenge_method=plain" + runtime.BrowserOpenURL(a.ctx, getMyAnimeListCodeUrl) + serverDone := &sync.WaitGroup{} + serverDone.Add(1) + handleMyAnimeListCallback(serverDone, verifier) + serverDone.Wait() + } else { + myAnimeListJwt.TokenType = string(tokenType.Data) + myAnimeListJwt.ExpiresIn, err = strconv.Atoi(string(expiresIn.Data)) + if err != nil { + fmt.Println("unable to convert string to int in Login function") + } + myAnimeListJwt.AccessToken = string(accessToken.Data) + myAnimeListJwt.RefreshToken = string(refreshToken.Data) + } + } +} + +func handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifier) { + srv := &http.Server{Addr: ":6734"} + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + select { + case <-myAnimeListCtxShutdown.Done(): + fmt.Println("Shutting down...") + return + default: + } + content := r.FormValue("code") + + if content != "" { + myAnimeListJwt = getMyAnimeListAuthorizationToken(content, verifier) + _ = myAnimeListRing.Set(keyring.Item{ + Key: "MyAnimeListTokenType", + Data: []byte(myAnimeListJwt.TokenType), + }) + _ = myAnimeListRing.Set(keyring.Item{ + Key: "MyAnimeListExpiresIn", + Data: []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn)), + }) + _ = myAnimeListRing.Set(keyring.Item{ + Key: "MyAnimeListAccessToken", + Data: []byte(myAnimeListJwt.AccessToken), + }) + _ = myAnimeListRing.Set(keyring.Item{ + Key: "MyAnimeListRefreshToken", + Data: []byte(myAnimeListJwt.RefreshToken), + }) + fmt.Println("Shutting down...") + myAnimeListCancel() + err := srv.Shutdown(context.Background()) + if err != nil { + log.Println("server.Shutdown:", err) + } + } else { + _, err := fmt.Fprintf(w, "Getting code failed.") + if err != nil { + return + } + } + }) + + go func() { + defer wg.Done() + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + fmt.Println("Shutting down...") + }() +} + +func getMyAnimeListAuthorizationToken(content string, verifier *CodeVerifier) MyAnimeListJWT { + dataForURLs := struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURI string `json:"redirect_uri"` + Code string `json:"code"` + CodeVerifier *CodeVerifier `json:"code_verifier"` + }{ + GrantType: "authorization_code", + ClientID: os.Getenv("MAL_CLIENT_ID"), + ClientSecret: os.Getenv("MAL_CLIENT_SECRET"), + RedirectURI: os.Getenv("MAL_CALLBACK_URI"), + Code: content, + CodeVerifier: verifier, + } + + data := url.Values{} + data.Set("grant_type", dataForURLs.GrantType) + data.Set("client_id", dataForURLs.ClientID) + data.Set("client_secret", dataForURLs.ClientSecret) + data.Set("redirect_uri", dataForURLs.RedirectURI) + data.Set("code", dataForURLs.Code) + data.Set("code_verifier", dataForURLs.CodeVerifier.Value) + + response, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + log.Printf("Failed at response, %s\n", err) + } + response.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + res, reserr := client.Do(response) + if reserr != nil { + log.Printf("Failed at res, %s\n", err) + } + + defer res.Body.Close() + + returnedBody, err := io.ReadAll(res.Body) + + var post MyAnimeListJWT + err = json.Unmarshal(returnedBody, &post) + if err != nil { + log.Printf("Failed at unmarshal, %s\n", err) + } + + return post +} + +func (a *App) GetMyAnimeListLoggedInUser() MyAnimeListUser { + a.MyAnimeListLogin() + + client := &http.Client{} + + req, _ := http.NewRequest("GET", "https://api.myanimelist.net/v2/users/@me?fields=anime_statistics", nil) + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+myAnimeListJwt.AccessToken) + req.Header.Add("myAnimeList-api-key", os.Getenv("MAL_CLIENT_ID")) + + response, err := client.Do(req) + + if err != nil { + log.Printf("Failed at request, %s\n", err) + return MyAnimeListUser{} + } + + defer response.Body.Close() + + var user MyAnimeListUser + + respBody, _ := io.ReadAll(response.Body) + + err = json.Unmarshal(respBody, &user) + if err != nil { + log.Printf("Failed at unmarshal, %s\n", err) + } + + return user +} diff --git a/SimklUserFunctions.go b/SimklUserFunctions.go index 9de0383..33ea248 100644 --- a/SimklUserFunctions.go +++ b/SimklUserFunctions.go @@ -28,7 +28,7 @@ func (a *App) CheckIfSimklLoggedIn() bool { tokenType, err := simklRing.Get("SimklTokenType") accessToken, err := simklRing.Get("SimklAccessToken") scope, err := simklRing.Get("SimklScope") - if err != nil { + if err != nil || len(accessToken.Data) == 0 { return false } else { simklJwt.TokenType = string(tokenType.Data) @@ -46,7 +46,7 @@ func (a *App) SimklLogin() { tokenType, err := simklRing.Get("SimklTokenType") accessToken, err := simklRing.Get("SimklAccessToken") scope, err := simklRing.Get("SimklScope") - if err != nil { + if err != nil || len(accessToken.Data) == 0 { getSimklCodeUrl := "https://simkl.com/oauth/authorize?response_type=code&client_id=" + os.Getenv("SIMKL_CLIENT_ID") + "&redirect_uri=" + os.Getenv("SIMKL_CALLBACK_URI") runtime.BrowserOpenURL(a.ctx, getSimklCodeUrl) diff --git a/app.go b/app.go index bc58f85..17a3abb 100644 --- a/app.go +++ b/app.go @@ -2,7 +2,6 @@ package main import ( "context" - "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct @@ -19,5 +18,5 @@ func NewApp() *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx - runtime.WindowMaximise(ctx) + //runtime.WindowMaximise(ctx) } diff --git a/bruno/AniTrack/environments/Dev.bru b/bruno/AniTrack/environments/Dev.bru index 927ee8b..60de753 100644 --- a/bruno/AniTrack/environments/Dev.bru +++ b/bruno/AniTrack/environments/Dev.bru @@ -3,9 +3,14 @@ vars { ANILIST_SECRET_TOKEN: {{process.env.ANILIST_SECRET_TOKEN}} SIMKL_CLIENT_ID: {{process.env.SIMKL_CLIENT_ID}} SIMKL_CLIENT_SECRET: {{process.env.SIMKL_CLIENT_SECRET}} + MAL_CLIENT_ID: {{process.env.MAL_CLIENT_ID}} + MAL_CLIENT_SECRET: {{process.env.MAL_CLIENT_SECRET}} + MAL_CALLBACK_URI: {{process.env.MAL_CALLBACK_URI}} } vars:secret [ code, SIMKL_AUTH_TOKEN, - ANILIST_ACCESS_TOKEN + ANILIST_ACCESS_TOKEN, + MAL_CODE, + MAL_VERIFIER ] diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 06e3003..080e1ef 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,6 +4,8 @@ anilistModal, aniListPrimary, aniListUser, + malUser, + malLoggedIn, aniListWatchlist, GetAniListSingleItemAndOpenModal, simklLoggedIn, @@ -14,10 +16,12 @@ import { CheckIfAniListLoggedIn, CheckIfSimklLoggedIn, + CheckIfMyAnimeListLoggedIn, GetAniListLoggedInUser, GetAniListUserWatchingList, GetSimklLoggedInUser, SimklGetUserWatchlist, + GetMyAnimeListLoggedInUser, } from "../wailsjs/go/main/App"; import {MediaListSort} from "./anilist/types/AniListTypes"; import type {AniListCurrentUserWatchList} from "./anilist/types/AniListCurrentUserWatchListType" @@ -58,6 +62,15 @@ } }) + await CheckIfMyAnimeListLoggedIn().then(result => { + if (result) { + GetMyAnimeListLoggedInUser().then(result => { + malUser.set(result) + malLoggedIn.set(result) + }) + } + }) + await CheckIfSimklLoggedIn().then(result => { if (result) { GetSimklLoggedInUser().then(result => { @@ -85,7 +98,7 @@
{#if isAniListLoggedIn}
-

Your WatchList

+

Your AniList WatchList