diff options
| author | Bobby <[email protected]> | 2024-09-08 02:32:17 -0400 |
|---|---|---|
| committer | Bobby <[email protected]> | 2024-09-08 02:32:17 -0400 |
| commit | 61bc5b38044bc52442415f83390ba72ed3b27491 (patch) | |
| tree | 73b727180897573d6a13c0a84ac4e1bb617f33ed | |
| download | yato-61bc5b38044bc52442415f83390ba72ed3b27491.tar.xz yato-61bc5b38044bc52442415f83390ba72ed3b27491.zip | |
Initial Commit with MAL Auth
| -rw-r--r-- | .env.example | 2 | ||||
| -rw-r--r-- | .gitignore | 27 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | Makefile | 40 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | config/config.go | 73 | ||||
| -rw-r--r-- | config/constants.go | 64 | ||||
| -rw-r--r-- | go.mod | 28 | ||||
| -rw-r--r-- | go.sum | 44 | ||||
| -rw-r--r-- | main.go | 125 | ||||
| -rw-r--r-- | screens/home.go | 28 | ||||
| -rw-r--r-- | screens/screens.go | 69 | ||||
| -rw-r--r-- | utils/auth.go | 92 |
13 files changed, 616 insertions, 0 deletions
diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e23693 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +MAL_CLIENT_ID= # Your MAL client ID +MAL_CLIENT_SECRET= # Your MAL client secret
\ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4440394 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +build/ @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Bobby + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..638d13d --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# Makefile for Yato + +# Check for .env file +ifneq ($(wildcard .env),) + include .env + export $(shell sed 's/=.*//' .env) +endif + +# Ensure required environment variables are set +ifndef MAL_CLIENT_ID +$(error MAL_CLIENT_ID is not set. Please check your .env file) +endif +ifndef MAL_CLIENT_SECRET +$(error MAL_CLIENT_SECRET is not set. Please check your .env file) +endif + +# Variables +BUILD_DIR=build +BINARY_NAME=yato +MAIN_FILE=main.go +ENCODED_CLIENT_ID=$(shell printf '%s' "$(MAL_CLIENT_ID)" | base64) +ENCODED_CLIENT_SECRET=$(shell printf '%s' "$(MAL_CLIENT_SECRET)" | base64) + +.PHONY: build run clean + +build: + @echo "Building Yato..." + @echo "Encoded Client ID: $(ENCODED_CLIENT_ID)" + @echo "Encoded Client Secret: $(ENCODED_CLIENT_SECRET)" + @go build -ldflags '-X "yato/config.encodedClientID=$(ENCODED_CLIENT_ID)" -X "yato/config.encodedClientSecret=$(ENCODED_CLIENT_SECRET)"' -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_FILE) + @echo "Build complete. Binary '$(BINARY_NAME)' created." + +run: + @echo "Running Yato..." + @go run . + +clean: + @echo "Cleaning up..." + @rm -rf $(BUILD_DIR) + @echo "Cleanup complete."
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..230bf7e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Yato + +Yato is a Terminal-based client for MyAnimeList written in [Go](https://golang.org/) using [Bubbletea](https://github.com/charmbracelet/bubbletea). diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..53eb4e0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,73 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + MyAnimeList MyAnimeListConfig `yaml:"myanimelist"` +} + +type MyAnimeListConfig struct { + TokenType string `yaml:"token_type"` + AccessToken string `yaml:"access_token"` + RefreshToken string `yaml:"refresh_token"` + ExpiresIn int `yaml:"expires_in"` +} + +var config Config + +func LoadConfig() error { + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config dir: %w", err) + } + + configPath := filepath.Join(configDir, AppName, "config.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + config = Config{} + return nil + } + return fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to unmarshal config file: %w", err) + } + + return nil +} + +func SaveConfig() error { + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("failed to get user config dir: %w", err) + } + + appConfigDir := filepath.Join(configDir, AppName) + if err := os.MkdirAll(appConfigDir, 0755); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + configPath := filepath.Join(appConfigDir, "config.yaml") + data, err := yaml.Marshal(&config) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +func GetConfig() *Config { + return &config +} diff --git a/config/constants.go b/config/constants.go new file mode 100644 index 0000000..47c3ab1 --- /dev/null +++ b/config/constants.go @@ -0,0 +1,64 @@ +package config + +import ( + "encoding/base64" + "fmt" + "os" +) + +var ( + AppName = "yato" + Version = "0.1.0" + MALOAuthBaseURL = "https://myanimelist.net/v1/oauth2/authorize" + MALClientID string + MALClientSecret string + MALRedirectURI = "http://localhost:42069/authenticate" +) + +// These variables will be set by the linker during build +var ( + encodedClientID string + encodedClientSecret string +) + +func init() { + var err error + + // Try to decode from build flags first + MALClientID, err = decodeSecret(encodedClientID) + if err != nil || MALClientID == "" { + // Fallback to environment variable + MALClientID = os.Getenv("MAL_CLIENT_ID") + } + + MALClientSecret, err = decodeSecret(encodedClientSecret) + if err != nil || MALClientSecret == "" { + // Fallback to environment variable + MALClientSecret = os.Getenv("MAL_CLIENT_SECRET") + } + + if MALClientID == "" || MALClientSecret == "" { + fmt.Println("Warning: Client ID or Secret not set. Please set MAL_CLIENT_ID and MAL_CLIENT_SECRET environment variables or use the build script.") + } +} + +func decodeSecret(encoded string) (string, error) { + if encoded == "" { + return "", fmt.Errorf("encoded string is empty") + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + return string(decoded), nil +} + +// GetMALClientID returns the MAL Client ID +func GetMALClientID() string { + return MALClientID +} + +// GetMALClientSecret returns the MAL Client Secret +func GetMALClientSecret() string { + return MALClientSecret +} @@ -0,0 +1,28 @@ +module yato + +go 1.21.4 + +require ( + github.com/charmbracelet/bubbletea v1.1.0 + golang.org/x/term v0.24.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) @@ -0,0 +1,44 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "runtime" + "yato/config" + "yato/screens" + "yato/utils" + + tea "github.com/charmbracelet/bubbletea" +) + +var ( + authorizationChan = make(chan struct{}) + codeVerifier string + errorChan = make(chan error) +) + +func main() { + if err := config.LoadConfig(); err != nil { + log.Fatalf(err.Error()) + } + + config := config.GetConfig() + if config.MyAnimeList.AccessToken == "" { + go StartOAuthFlow() + select { + case <-authorizationChan: // Authorization successful + case err := <-errorChan: + log.Fatalf("Unable to authenticate: %s", err) + } + } + + StartApp() +} + +func StartApp() { + p := tea.NewProgram(screens.Initialize(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Println("Error starting program:", err) + os.Exit(1) + } +} + +func StartOAuthFlow() { + var err error + + codeVerifier, err = utils.GetNewCodeVerifier() + if err != nil { + log.Fatalf("failed to generate code verifier: %s", err) + } + + url := utils.GetOAuthURL(codeVerifier) + + server := &http.Server{Addr: ":42069"} + http.HandleFunc("/authenticate", handleOAuthCallback) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } + }() + + if err := openBrowser(url); err != nil { + log.Printf("failed to open browser: %v. Visit %s in your browser to authenticate.", err, url) + } + + select { + case <-authorizationChan: + case err := <-errorChan: + log.Fatalf("Unable to authenticate: %s", err) + } + + if err := server.Shutdown(context.Background()); err != nil { + log.Printf("failed to shutdown server: %v", err) + } +} + +func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + errorChan <- fmt.Errorf("user cancelled authentication") + http.Error(w, "missing code query parameter. user cancelled authentication", http.StatusBadRequest) + return + } + + malConfig, err := utils.ExchangeToken(code, codeVerifier) + if err != nil { + errorChan <- fmt.Errorf("failed to exchange token: %w", err) + http.Error(w, "failed to exchange token", http.StatusInternalServerError) + return + } + + config.GetConfig().MyAnimeList = *malConfig + if err := config.SaveConfig(); err != nil { + errorChan <- fmt.Errorf("failed to save config: %w", err) + http.Error(w, "failed to save config", http.StatusInternalServerError) + return + } + + w.Write([]byte("Authentication successful! You can now close this tab.")) + authorizationChan <- struct{}{} +} + +func openBrowser(url string) error { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + + return err +} diff --git a/screens/home.go b/screens/home.go new file mode 100644 index 0000000..64e4705 --- /dev/null +++ b/screens/home.go @@ -0,0 +1,28 @@ +package screens + +import tea "github.com/charmbracelet/bubbletea" + +type HomeScreen struct { +} + +func homeScreen() tea.Model { + return HomeScreen{} +} + +func (h HomeScreen) Init() tea.Cmd { + return nil +} + +func (h HomeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + if msg.Type == tea.KeyCtrlC { + return h, tea.Quit + } + } + + return h, nil +} + +func (h HomeScreen) View() string { + return "Home" +} diff --git a/screens/screens.go b/screens/screens.go new file mode 100644 index 0000000..1675ec7 --- /dev/null +++ b/screens/screens.go @@ -0,0 +1,69 @@ +package screens + +import ( + "os" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +type ScreenSwitcher struct { + currentScreen tea.Model +} + +type Globals struct { + width int + height int +} + +var globals Globals + +func (s ScreenSwitcher) Init() tea.Cmd { + return s.currentScreen.Init() +} + +func (s ScreenSwitcher) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var model tea.Model + + switch m := msg.(type) { + case tea.WindowSizeMsg: + globals.width, globals.height = m.Width, m.Height + } + + model, cmd = s.currentScreen.Update(msg) + + return ScreenSwitcher{currentScreen: model}, cmd +} + +func (s ScreenSwitcher) View() string { + return s.currentScreen.View() +} + +func (s ScreenSwitcher) Switch(screen tea.Model) (tea.Model, tea.Cmd) { + s.currentScreen = screen + return s.currentScreen, s.currentScreen.Init() +} + +func screen() ScreenSwitcher { + screen := homeScreen() + + return ScreenSwitcher{ + currentScreen: screen, + } +} + +func Initialize() tea.Model { + + width, height, err := term.GetSize(int(os.Stdout.Fd())) + + if err != nil { + width = 80 + height = 30 + } + + globals.width = width + globals.height = height + + return screen() +} diff --git a/utils/auth.go b/utils/auth.go new file mode 100644 index 0000000..7745237 --- /dev/null +++ b/utils/auth.go @@ -0,0 +1,92 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "yato/config" +) + +func GetNewCodeVerifier() (string, error) { + bytes := make([]byte, 100) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate code verifier: %w", err) + } + + encoded := base64.RawURLEncoding.EncodeToString(bytes) + + // Truncate to 128 characters + if len(encoded) > 128 { + encoded = encoded[:128] + } + + return encoded, nil +} + +func GetOAuthURL(codeVerifier string) string { + return fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=plain", + config.MALOAuthBaseURL, config.MALClientID, config.MALRedirectURI, codeVerifier) +} + +func ExchangeToken(code, codeVerifier string) (*config.MyAnimeListConfig, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", config.MALClientID) + data.Set("client_secret", config.MALClientSecret) + data.Set("redirect_uri", config.MALRedirectURI) + data.Set("code", code) + + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + // POST request to MAL API + req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var malConfig config.MyAnimeListConfig + var tokenResponse struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + malConfig.TokenType = tokenResponse.TokenType + malConfig.ExpiresIn = tokenResponse.ExpiresIn + malConfig.AccessToken = tokenResponse.AccessToken + malConfig.RefreshToken = tokenResponse.RefreshToken + + return &malConfig, nil +} |
