package main import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log" "math/rand" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/zalando/go-keyring" ) var myAnimeListJwt MyAnimeListJWT // malKeyringService is the OS keyring service name all AniTrack secrets // live under (same wallet the 99designs/keyring build used). const malKeyringService = "AniTrack" func malRingSet(key string, data []byte) error { if err := keyring.Set(malKeyringService, key, string(data)); err != nil { log.Printf("mal: save %s failed: %s", key, err) return err } return nil } 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) 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, tokenErr := keyring.Get(malKeyringService, "MyAnimeListTokenType") expiresIn, expiresInErr := keyring.Get(malKeyringService, "MyAnimeListExpiresIn") accessToken, accessTokenErr := keyring.Get(malKeyringService, "MyAnimeListAccessToken") refreshToken, refreshTokenErr := keyring.Get(malKeyringService, "MyAnimeListRefreshToken") if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 { return false } else { var expiresInConvertErr error myAnimeListJwt.TokenType = tokenType myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn) if expiresInConvertErr != nil { log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr) return false } myAnimeListJwt.AccessToken = accessToken myAnimeListJwt.RefreshToken = refreshToken return true } } else { return true } } func (a *App) MyAnimeListLogin() { if !a.CheckIfMyAnimeListLoggedIn() { fmt.Println("check logged in function failed") tokenType, tokenErr := keyring.Get(malKeyringService, "MyAnimeListTokenType") expiresIn, expiresInErr := keyring.Get(malKeyringService, "MyAnimeListExpiresIn") accessToken, accessTokenErr := keyring.Get(malKeyringService, "MyAnimeListAccessToken") refreshToken, refreshTokenErr := keyring.Get(malKeyringService, "MyAnimeListRefreshToken") if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken) == 0 { verifier, _ := verifier() getMyAnimeListCodeUrl := "https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=" + Environment.MAL_CLIENT_ID + "&redirect_uri=" + Environment.MAL_CALLBACK_URI + "&code_challenge=" + verifier.Value + "&code_challenge_method=plain" if err := a.app.Browser.OpenURL(getMyAnimeListCodeUrl); err != nil { log.Printf("mal: failed to open browser: %s", err) return } serverDone := &sync.WaitGroup{} serverDone.Add(1) a.handleMyAnimeListCallback(serverDone, verifier) serverDone.Wait() } else { var expiresInConvertErr error myAnimeListJwt.TokenType = tokenType myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(expiresIn) if expiresInConvertErr != nil { log.Printf("mal: invalid expiresIn %q: %s", expiresIn, expiresInConvertErr) } myAnimeListJwt.AccessToken = accessToken myAnimeListJwt.RefreshToken = refreshToken } } } func (a *App) handleMyAnimeListCallback(wg *sync.WaitGroup, verifier *CodeVerifier) { mux := http.NewServeMux() srv := &http.Server{Addr: ":6734", Handler: mux} mux.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) _ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType)) _ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn))) _ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken)) _ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken)) a.app.Dialog.Info(). SetTitle("MyAnimeList Authorization"). SetMessage("It is now safe to close your browser tab"). Show() fmt.Println("Shutting down...") myAnimeListCancel() if err := srv.Shutdown(context.Background()); 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 && !errors.Is(err, http.ErrServerClosed) { log.Printf("Server error: %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: Environment.MAL_CLIENT_ID, ClientSecret: Environment.MAL_CLIENT_SECRET, RedirectURI: Environment.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) if err != nil { log.Printf("Could not read returned body, %s\n", err) } var post MyAnimeListJWT err = json.Unmarshal(returnedBody, &post) if err != nil { log.Printf("Failed at unmarshal, %s\n", err) } return post } func refreshMyAnimeListAuthorizationToken() bool { dataForURLs := struct { GrantType string `json:"grant_type"` RefreshToken string `json:"refresh_token"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` RedirectURI string `json:"redirect_uri"` }{ GrantType: "refresh_token", RefreshToken: myAnimeListJwt.RefreshToken, ClientID: Environment.MAL_CLIENT_ID, ClientSecret: Environment.MAL_CLIENT_SECRET, RedirectURI: Environment.MAL_CALLBACK_URI, } data := url.Values{} data.Set("grant_type", dataForURLs.GrantType) data.Set("refresh_token", dataForURLs.RefreshToken) data.Set("client_id", dataForURLs.ClientID) data.Set("client_secret", dataForURLs.ClientSecret) data.Set("redirect_uri", dataForURLs.RedirectURI) 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) return false } 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", resErr) return false } defer res.Body.Close() returnedBody, err := io.ReadAll(res.Body) if err != nil { log.Printf("Could not read returned body, %s\n", err) return false } var refreshed MyAnimeListJWT err = json.Unmarshal(returnedBody, &refreshed) if err != nil { log.Printf("Failed at unmarshal, %s\n", err) return false } if refreshed.AccessToken == "" { return false } myAnimeListJwt = refreshed _ = malRingSet("MyAnimeListTokenType", []byte(myAnimeListJwt.TokenType)) _ = malRingSet("MyAnimeListExpiresIn", []byte(strconv.Itoa(myAnimeListJwt.ExpiresIn))) _ = malRingSet("MyAnimeListAccessToken", []byte(myAnimeListJwt.AccessToken)) _ = malRingSet("MyAnimeListRefreshToken", []byte(myAnimeListJwt.RefreshToken)) return true } func (a *App) GetMyAnimeListLoggedInUser() MyAnimeListUser { a.MyAnimeListLogin() user := createUser() if user.Name == "" && !a.refreshMyAnimeListAndGetUser(&user) { a.LogoutMyAnimeList() a.MyAnimeListLogin() user = createUser() } return user } func (a *App) refreshMyAnimeListAndGetUser(user *MyAnimeListUser) bool { if !refreshMyAnimeListAuthorizationToken() { return false } freshUser := createUser() if freshUser.Name == "" { return false } *user = freshUser return true } func createUser() MyAnimeListUser { 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", Environment.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 } func (a *App) LogoutMyAnimeList() string { if (MyAnimeListJWT{} != myAnimeListJwt) { typeErr := keyring.Delete(malKeyringService, "MyAnimeListTokenType") expiresInErr := keyring.Delete(malKeyringService, "MyAnimeListExpiresIn") accessTokenErr := keyring.Delete(malKeyringService, "MyAnimeListAccessToken") refreshTokenErr := keyring.Delete(malKeyringService, "MyAnimeListRefreshToken") if typeErr != nil || expiresInErr != nil || accessTokenErr != nil || refreshTokenErr != nil { log.Printf("mal: logout cleanup failed (type=%v expires=%v access=%v refresh=%v)", typeErr, expiresInErr, accessTokenErr, refreshTokenErr) } myAnimeListJwt = MyAnimeListJWT{} } return "MAL Logged Out Successfully" }