Stop ignoring keyring.Open failures in all three login files. A failed Open previously left a nil ring behind the blank identifier, so the next Get/Set panicked with no message. Each service now opens its ring in init, logs a service-prefixed warning when storage is unavailable, and guards every use with a Ready check that fails safe to logged-out. - AniListUserFunctions.go: add aniRingReady/aniRingSet helpers, log Open and per-key Set failures, reject invalid ExpiresIn instead of silently storing 0, clear in-memory JWT on logout even when storage is missing - MALUserFunctions.go: add malRingReady/malRingSet helpers covering login, OAuth callback, and token-refresh saves; same Open/Set/ ExpiresIn/logout treatment - SimklUserFunctions.go: add simklRingReady/simklRingSet helpers; same Open/Set/logout treatment No wallet, key names, or login flow changed. Same ServiceName AniTrack, same keys, same OAuth callback behavior.
397 lines
12 KiB
Go
397 lines
12 KiB
Go
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/99designs/keyring"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
var myAnimeListJwt MyAnimeListJWT
|
|
|
|
var myAnimeListRing keyring.Keyring
|
|
|
|
func init() {
|
|
var err error
|
|
myAnimeListRing, err = keyring.Open(keyring.Config{
|
|
ServiceName: "AniTrack",
|
|
KeychainName: "AniTrack",
|
|
KeychainSynchronizable: false,
|
|
KeychainTrustApplication: true,
|
|
KeychainAccessibleWhenUnlocked: true,
|
|
})
|
|
if err != nil {
|
|
log.Printf("mal: secure storage unavailable: %s", err)
|
|
}
|
|
}
|
|
|
|
func malRingReady() bool {
|
|
if myAnimeListRing == nil {
|
|
log.Println("mal: secure storage unavailable")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func malRingSet(key string, data []byte) error {
|
|
if !malRingReady() {
|
|
return errors.New("mal: secure storage unavailable")
|
|
}
|
|
if err := myAnimeListRing.Set(keyring.Item{Key: key, Data: 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) {
|
|
if !malRingReady() {
|
|
return false
|
|
}
|
|
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
|
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
|
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
|
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
|
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 0 {
|
|
return false
|
|
} else {
|
|
var expiresInConvertErr error
|
|
myAnimeListJwt.TokenType = string(tokenType.Data)
|
|
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
|
if expiresInConvertErr != nil {
|
|
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
|
return false
|
|
}
|
|
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
|
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
|
return true
|
|
}
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (a *App) MyAnimeListLogin() {
|
|
if !a.CheckIfMyAnimeListLoggedIn() {
|
|
fmt.Println("check logged in function failed")
|
|
if !malRingReady() {
|
|
log.Println("mal: cannot check login, secure storage unavailable")
|
|
return
|
|
}
|
|
tokenType, tokenErr := myAnimeListRing.Get("MyAnimeListTokenType")
|
|
expiresIn, expiresInErr := myAnimeListRing.Get("MyAnimeListExpiresIn")
|
|
accessToken, accessTokenErr := myAnimeListRing.Get("MyAnimeListAccessToken")
|
|
refreshToken, refreshTokenErr := myAnimeListRing.Get("MyAnimeListRefreshToken")
|
|
if (tokenErr != nil || expiresInErr != nil || refreshTokenErr != nil || accessTokenErr != nil) || len(accessToken.Data) == 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"
|
|
runtime.BrowserOpenURL(*wailsContext, getMyAnimeListCodeUrl)
|
|
serverDone := &sync.WaitGroup{}
|
|
serverDone.Add(1)
|
|
a.handleMyAnimeListCallback(serverDone, verifier)
|
|
serverDone.Wait()
|
|
} else {
|
|
var expiresInConvertErr error
|
|
myAnimeListJwt.TokenType = string(tokenType.Data)
|
|
myAnimeListJwt.ExpiresIn, expiresInConvertErr = strconv.Atoi(string(expiresIn.Data))
|
|
if expiresInConvertErr != nil {
|
|
log.Printf("mal: invalid expiresIn %q: %s", string(expiresIn.Data), expiresInConvertErr)
|
|
}
|
|
myAnimeListJwt.AccessToken = string(accessToken.Data)
|
|
myAnimeListJwt.RefreshToken = string(refreshToken.Data)
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
_, err := runtime.MessageDialog(*wailsContext, runtime.MessageDialogOptions{
|
|
Title: "MyAnimeList Authorization",
|
|
Message: "It is now safe to close your browser tab",
|
|
})
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
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 && !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) {
|
|
if !malRingReady() {
|
|
myAnimeListJwt = MyAnimeListJWT{}
|
|
return "MAL Logged Out Successfully"
|
|
}
|
|
typeErr := myAnimeListRing.Remove("MyAnimeListTokenType")
|
|
expiresInErr := myAnimeListRing.Remove("MyAnimeListExpiresIn")
|
|
accessTokenErr := myAnimeListRing.Remove("MyAnimeListAccessToken")
|
|
refreshTokenErr := myAnimeListRing.Remove("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"
|
|
}
|