aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2024-09-08 02:32:17 -0400
committerBobby <[email protected]>2024-09-08 02:32:17 -0400
commit61bc5b38044bc52442415f83390ba72ed3b27491 (patch)
tree73b727180897573d6a13c0a84ac4e1bb617f33ed
downloadyato-61bc5b38044bc52442415f83390ba72ed3b27491.tar.xz
yato-61bc5b38044bc52442415f83390ba72ed3b27491.zip
Initial Commit with MAL Auth
-rw-r--r--.env.example2
-rw-r--r--.gitignore27
-rw-r--r--LICENSE21
-rw-r--r--Makefile40
-rw-r--r--README.md3
-rw-r--r--config/config.go73
-rw-r--r--config/constants.go64
-rw-r--r--go.mod28
-rw-r--r--go.sum44
-rw-r--r--main.go125
-rw-r--r--screens/home.go28
-rw-r--r--screens/screens.go69
-rw-r--r--utils/auth.go92
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/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9d9d963
--- /dev/null
+++ b/LICENSE
@@ -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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..8b0735c
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7ced19a
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..4764a3c
--- /dev/null
+++ b/main.go
@@ -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
+}