From 180b17706be7602c67d5ab57fc6f2b93b96d8add Mon Sep 17 00:00:00 2001 From: cosrnic <75631523+cosrnic@users.noreply.github.com> Date: Fri, 4 Oct 2024 00:40:39 +0100 Subject: [PATCH 1/5] feat: skin manager --- cmd/mc/root.go | 2 + cmd/mc/skin/add.go | 125 ++++++++++++++++++++ cmd/mc/skin/apply.go | 62 ++++++++++ cmd/mc/skin/list.go | 41 +++++++ cmd/mc/skin/skin.go | 19 +++ go.mod | 1 + go.sum | 2 + internal/pkg/cli/app.go | 14 +++ internal/pkg/cli/model/skin.go | 28 +++++ internal/pkg/skin/skin.go | 195 +++++++++++++++++++++++++++++++ internal/pkg/skin/types.go | 208 +++++++++++++++++++++++++++++++++ 11 files changed, 697 insertions(+) create mode 100644 cmd/mc/skin/add.go create mode 100644 cmd/mc/skin/apply.go create mode 100644 cmd/mc/skin/list.go create mode 100644 cmd/mc/skin/skin.go create mode 100644 internal/pkg/cli/model/skin.go create mode 100644 internal/pkg/skin/skin.go create mode 100644 internal/pkg/skin/types.go diff --git a/cmd/mc/root.go b/cmd/mc/root.go index 9177d34..db9f93d 100644 --- a/cmd/mc/root.go +++ b/cmd/mc/root.go @@ -3,6 +3,7 @@ package mc import ( "github.com/MakeNowJust/heredoc" "github.com/mworzala/mc/cmd/mc/modrinth" + "github.com/mworzala/mc/cmd/mc/skin" "github.com/mworzala/mc/cmd/mc/profile" @@ -36,6 +37,7 @@ func NewRootCmd(app *cli.App) *cobra.Command { cmd.AddCommand(account.NewAccountCmd(app)) cmd.AddCommand(java.NewJavaCmd(app)) cmd.AddCommand(profile.NewProfileCmd(app)) + cmd.AddCommand(skin.NewSkinCmd(app)) cmd.AddCommand(newLaunchCmd(app)) cmd.AddCommand(newInstallCmd(app)) cmd.AddCommand(modrinth.NewModrinthCmd(app)) diff --git a/cmd/mc/skin/add.go b/cmd/mc/skin/add.go new file mode 100644 index 0000000..78df6de --- /dev/null +++ b/cmd/mc/skin/add.go @@ -0,0 +1,125 @@ +package skin + +import ( + "errors" + "fmt" + "slices" + + "github.com/google/uuid" + "github.com/mworzala/mc/internal/pkg/cli" + "github.com/spf13/cobra" +) + +type addSkinOpts struct { + app *cli.App + + account string + variant string + cape string + name string + apply bool +} + +var ( + ErrInvalidType = errors.New("invalid type") + ErrInvalidVariant = errors.New("invalid variant") + + validVariants = []string{"classic", "slim"} +) + +func newAddCmd(app *cli.App) *cobra.Command { + var o addSkinOpts + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a skin to your list", + Args: func(cmd *cobra.Command, args []string) error { + o.app = app + return o.validateArgs(cmd, args) + }, + RunE: func(_ *cobra.Command, args []string) error { + o.app = app + return o.execute(args) + }, + } + + cmd.Flags().StringVar(&o.account, "account", "", "Account to use") + cmd.Flags().StringVar(&o.variant, "variant", "classic", "Skin variant [classic/slim]") + cmd.Flags().StringVar(&o.cape, "cape", "", "Cape name, 'none' to remove") + cmd.Flags().BoolVar(&o.apply, "apply", false, "Apply the skin") + cmd.Flags().BoolVar(&o.apply, "set", false, "Apply the skin") + + cmd.Flags().FlagUsages() + + return cmd +} + +func (o *addSkinOpts) validateArgs(cmd *cobra.Command, args []string) (err error) { + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + + if !slices.Contains(validVariants, o.variant) { + return ErrInvalidVariant + } + + return nil +} + +func (o *addSkinOpts) execute(args []string) error { + if len(args) > 1 { + o.name = args[1] + } + + if o.name == "" { + o.name = uuid.New().String() + } + + if o.account == "" { + o.account = o.app.AccountManager().GetDefault() + } + + token, err := o.app.AccountManager().GetAccountToken(o.account) + if err != nil { + return err + } + + info, err := o.app.SkinManager().GetProfileInformation(token) + if err != nil { + return err + } + + if o.cape == "" { + for _, cape := range info.Capes { + if cape.State == "ACTIVE" { + o.cape = cape.ID + } + } + } else if o.cape != "none" { + for _, cape := range info.Capes { + if cape.Alias == o.cape { + o.cape = cape.ID + } + } + } + + skinData := args[0] + + skin, err := o.app.SkinManager().CreateSkin(o.name, o.variant, skinData, o.cape) + if err != nil { + return err + } + + if o.apply { + + err = skin.Apply(token) + if err != nil { + return err + } + } + + fmt.Printf("skin %s with cape %s was added to the list", skin.Name, skin.Cape) + + return o.app.SkinManager().Save() + +} diff --git a/cmd/mc/skin/apply.go b/cmd/mc/skin/apply.go new file mode 100644 index 0000000..30d5249 --- /dev/null +++ b/cmd/mc/skin/apply.go @@ -0,0 +1,62 @@ +package skin + +import ( + "github.com/mworzala/mc/internal/pkg/cli" + "github.com/spf13/cobra" +) + +type applySkinOpts struct { + app *cli.App + + account string +} + +func newApplyCmd(app *cli.App) *cobra.Command { + var o applySkinOpts + + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a saved skin", + Aliases: []string{"set"}, + Args: func(cmd *cobra.Command, args []string) error { + o.app = app + return o.validateArgs(cmd, args) + }, + RunE: func(_ *cobra.Command, args []string) error { + o.app = app + return o.execute(args) + }, + } + + cmd.Flags().StringVar(&o.account, "account", "", "Account to use") + + return cmd +} + +func (o *applySkinOpts) validateArgs(cmd *cobra.Command, args []string) (err error) { + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + + return nil +} + +func (o *applySkinOpts) execute(args []string) error { + skinName := args[0] + + if o.account == "" { + o.account = o.app.AccountManager().GetDefault() + } + + token, err := o.app.AccountManager().GetAccountToken(o.account) + if err != nil { + return err + } + + skin, err := o.app.SkinManager().GetSkin(skinName) + if err != nil { + return err + } + + return skin.Apply(token) +} diff --git a/cmd/mc/skin/list.go b/cmd/mc/skin/list.go new file mode 100644 index 0000000..0654552 --- /dev/null +++ b/cmd/mc/skin/list.go @@ -0,0 +1,41 @@ +package skin + +import ( + "github.com/mworzala/mc/internal/pkg/cli" + appModel "github.com/mworzala/mc/internal/pkg/cli/model" + "github.com/spf13/cobra" +) + +type listSkinsOpts struct { + app *cli.App +} + +func newListCmd(app *cli.App) *cobra.Command { + var o listSkinsOpts + + cmd := &cobra.Command{ + Use: "list", + Short: "List saved skins", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, args []string) error { + o.app = app + return o.listSkins() + }, + } + + return cmd +} + +func (o *listSkinsOpts) listSkins() error { + skinManager := o.app.SkinManager() + + var result appModel.SkinList + for _, skin := range skinManager.Skins() { + result = append(result, &appModel.Skin{ + Name: skin.Name, + Date: skin.AddedDate, + }) + } + + return o.app.Present(result) +} diff --git a/cmd/mc/skin/skin.go b/cmd/mc/skin/skin.go new file mode 100644 index 0000000..d150e15 --- /dev/null +++ b/cmd/mc/skin/skin.go @@ -0,0 +1,19 @@ +package skin + +import ( + "github.com/mworzala/mc/internal/pkg/cli" + "github.com/spf13/cobra" +) + +func NewSkinCmd(app *cli.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "skin", + Short: "Manage Minecraft Skins and Capes", + } + + cmd.AddCommand(newListCmd(app)) + cmd.AddCommand(newAddCmd(app)) + cmd.AddCommand(newApplyCmd(app)) + + return cmd +} diff --git a/go.mod b/go.mod index 103c526..030d706 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.4 github.com/gosuri/uitable v0.0.4 + github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.16.0 diff --git a/go.sum b/go.sum index d7a811e..8454b0b 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,8 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= diff --git a/internal/pkg/cli/app.go b/internal/pkg/cli/app.go index be13abe..e0ab0f0 100644 --- a/internal/pkg/cli/app.go +++ b/internal/pkg/cli/app.go @@ -11,6 +11,7 @@ import ( "github.com/mworzala/mc/internal/pkg/java" "github.com/mworzala/mc/internal/pkg/platform" "github.com/mworzala/mc/internal/pkg/profile" + "github.com/mworzala/mc/internal/pkg/skin" "github.com/spf13/viper" ) @@ -34,6 +35,7 @@ type App struct { versionManager *game.VersionManager profileManager profile.Manager gameManager game.Manager + skinManager skin.Manager } func NewApp(build BuildInfo) *App { @@ -133,3 +135,15 @@ func (a *App) GameManager() game.Manager { return a.gameManager } + +func (a *App) SkinManager() skin.Manager { + if a.skinManager == nil { + var err error + a.skinManager, err = skin.NewManager(a.ConfigDir) + if err != nil { + a.Fatal(err) + } + } + + return a.skinManager +} diff --git a/internal/pkg/cli/model/skin.go b/internal/pkg/cli/model/skin.go new file mode 100644 index 0000000..161c613 --- /dev/null +++ b/internal/pkg/cli/model/skin.go @@ -0,0 +1,28 @@ +package model + +import ( + "fmt" + "time" + + "github.com/gosuri/uitable" +) + +type Skin struct { + Name string + Date time.Time +} + +func (i *Skin) String() string { + return fmt.Sprintf("%s\t%s", i.Name, i.Date) +} + +type SkinList []*Skin + +func (l SkinList) String() string { + table := uitable.New() + table.AddRow("NAME", "DATE") + for _, skin := range l { + table.AddRow(skin.Name, skin.Date) + } + return table.String() +} diff --git a/internal/pkg/skin/skin.go b/internal/pkg/skin/skin.go new file mode 100644 index 0000000..75e910e --- /dev/null +++ b/internal/pkg/skin/skin.go @@ -0,0 +1,195 @@ +package skin + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path" + "regexp" + "strings" + "time" +) + +var ( + ErrInvalidName = errors.New("invalid skin name") + ErrNameInUse = errors.New("name in use") + ErrNotFound = errors.New("skin not found") + + namePattern = regexp.MustCompile("^[a-zA-Z0-9_.-]{1,36}$") +) + +func isValidName(name string) bool { + return namePattern.MatchString(name) +} + +func isURL(s string) bool { + u, err := url.ParseRequestURI(s) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") +} + +func isFilePath(s string) bool { + if _, err := os.Stat(s); err == nil { + return true + } + return false +} + +func isImage(data []byte) bool { + contentType := http.DetectContentType(data) + + // probably more of these however i know png and jpeg are supported + if contentType == "image/png" || contentType == "image/jpeg" { + return true + } + + return false +} + +type Manager interface { + CreateSkin(name string, variant string, skinData string, capeData string) (*Skin, error) + Skins() []*Skin + GetSkin(name string) (*Skin, error) + + GetProfileInformation(accountToken string) (*profileInformationResponse, error) + + Save() error +} + +var ( + skinsFileName = "skins.json" +) + +type fileManager struct { + Path string `json:"-"` + AllSkins map[string]*Skin `json:"skins"` +} + +func NewManager(dataDir string) (Manager, error) { + skinsFile := path.Join(dataDir, skinsFileName) + if _, err := os.Stat(skinsFile); errors.Is(err, fs.ErrNotExist) { + return &fileManager{ + Path: skinsFile, + AllSkins: make(map[string]*Skin), + }, nil + } + + f, err := os.Open(skinsFile) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + manager := fileManager{Path: skinsFile} + if err := json.NewDecoder(f).Decode(&manager); err != nil { + return nil, fmt.Errorf("failed to read %s: %w", skinsFileName, err) + } + if manager.AllSkins == nil { + manager.AllSkins = make(map[string]*Skin) + } + return &manager, nil +} + +func (m *fileManager) CreateSkin(name string, variant string, skinData string, capeData string) (*Skin, error) { + if !isValidName(name) { + return nil, ErrInvalidName + } + if _, ok := m.AllSkins[strings.ToLower(name)]; ok { + return nil, ErrNameInUse + } + + skin := &Skin{ + Name: name, + Cape: capeData, + Variant: variant, + } + if isFilePath(skinData) { + fileBytes, err := os.ReadFile(skinData) + if err != nil { + return nil, err + } + isValid := isImage(fileBytes) + if !isValid { + return nil, fmt.Errorf("%s is not a valid image", skinData) + } + + base64Str := base64.StdEncoding.EncodeToString(fileBytes) + skin.Skin = base64Str + } else { + skin.Skin = skinData + } + + skin.AddedDate = time.Now() + + m.AllSkins[strings.ToLower(name)] = skin + return skin, nil +} + +func (m *fileManager) Skins() (result []*Skin) { + for _, s := range m.AllSkins { + result = append(result, s) + } + return +} + +func (m *fileManager) GetSkin(name string) (*Skin, error) { + name = strings.ToLower(name) + var matchingSkin *Skin + matchCount := 0 + for id, s := range m.AllSkins { + if id == name { + return s, nil + } + if strings.HasPrefix(id, name) { + matchCount++ + matchingSkin = s + } + } + + if matchCount == 1 { + return matchingSkin, nil + } + return nil, ErrNotFound +} + +func (m *fileManager) Save() error { + f, err := os.OpenFile(m.Path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666) + if err != nil { + return fmt.Errorf("failed to open %s: %w", m.Path, err) + } + defer f.Close() + + if err := json.NewEncoder(f).Encode(m); err != nil { + return fmt.Errorf("failed to write json: %w", err) + } + + return nil +} + +func (m *fileManager) GetProfileInformation(accountToken string) (*profileInformationResponse, error) { + endpoint := "https://api.minecraftservices.com/minecraft/profile" + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var information profileInformationResponse + if err := json.NewDecoder(res.Body).Decode(&information); err != nil { + return nil, err + } + + return &information, nil + +} diff --git a/internal/pkg/skin/types.go b/internal/pkg/skin/types.go new file mode 100644 index 0000000..95d9e3c --- /dev/null +++ b/internal/pkg/skin/types.go @@ -0,0 +1,208 @@ +package skin + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + +type Skin struct { + Name string `json:"name"` + Variant string `json:"variant"` + Skin string `json:"skin"` + Cape string `json:"cape"` + AddedDate time.Time `json:"added_date"` +} + +func (s *Skin) Apply(accountToken string) error { + var newCape bool + + if s.Cape == "none" { + endpoint := "https://api.minecraftservices.com/minecraft/profile/capes/active" + + req, err := http.NewRequest("DELETE", endpoint, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("cape disable request was not ok: %d", res.StatusCode) + } + } + + if isURL(s.Skin) { + endpoint := "https://api.minecraftservices.com/minecraft/profile/skins" + + requestData := map[string]string{ + "url": s.Skin, + "variant": s.Variant, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("skin url request was not ok: %d", res.StatusCode) + } + + var information profileInformationResponse + if err := json.NewDecoder(res.Body).Decode(&information); err != nil { + return err + } + + if s.Cape != "none" { + for _, c := range information.Capes { + if c.ID == s.Cape && c.State == "INACTIVE" { + newCape = true + } + } + } + + } else { + imageData, err := base64.StdEncoding.DecodeString(s.Skin) + if err != nil { + return err + } + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + err = writer.WriteField("variant", s.Variant) + if err != nil { + return err + } + + part, err := writer.CreateFormFile("file", "skin.png") + if err != nil { + return err + } + + _, err = io.Copy(part, bytes.NewReader(imageData)) + if err != nil { + return err + } + + err = writer.Close() + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "https://api.minecraftservices.com/minecraft/profile/skins", body) + if err != nil { + return err + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("skin request was not ok: %d", res.StatusCode) + } + + var information profileInformationResponse + if err := json.NewDecoder(res.Body).Decode(&information); err != nil { + return err + } + + if s.Cape != "none" { + for _, c := range information.Capes { + if c.ID == s.Cape && c.State == "INACTIVE" { + newCape = true + } + } + } + + } + + if newCape { + endpoint := "https://api.minecraftservices.com/minecraft/profile/capes/active" + + requestData := map[string]string{ + "capeId": s.Cape, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", endpoint, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("cape put request was not ok: %d", res.StatusCode) + } + } + + fmt.Printf("skin %s and cape %s was applied", s.Name, s.Cape) + + return nil +} + +type profileInformationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Skins []profileSkin `json:"skins"` + Capes []profileCape `json:"capes"` + ProfileActions struct { + } `json:"profileActions"` +} + +type profileSkin struct { + ID string `json:"id"` + State string `json:"state"` + URL string `json:"url"` + TextureKey string `json:"textureKey"` + Variant string `json:"variant"` +} + +type profileCape struct { + ID string `json:"id"` + State string `json:"state"` + URL string `json:"url"` + Alias string `json:"alias"` +} From 9443ab6a320778a3e78555963ee6989a831116a2 Mon Sep 17 00:00:00 2001 From: cosrnic <75631523+cosrnic@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:23:54 +0100 Subject: [PATCH 2/5] fix: run go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 030d706..b116a0a 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.23.2 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.4 - github.com/gosuri/uitable v0.0.4 github.com/google/uuid v1.6.0 + github.com/gosuri/uitable v0.0.4 github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.16.0 From bfae4b2d71e3b78bf392d220fc1ad9b1606a5e30 Mon Sep 17 00:00:00 2001 From: cosrnic <75631523+cosrnic@users.noreply.github.com> Date: Fri, 4 Oct 2024 23:08:32 +0100 Subject: [PATCH 3/5] make requested changes --- cmd/mc/skin/add.go | 30 +++-- cmd/mc/skin/apply.go | 24 +++- cmd/mc/skin/list.go | 4 +- cmd/mc/skin/skin.go | 10 +- internal/pkg/cli/model/skin.go | 10 +- internal/pkg/mojang/mojang.go | 224 +++++++++++++++++++++++++++++++++ internal/pkg/mojang/profile.go | 108 ++++++++++++++++ internal/pkg/mojang/types.go | 25 ++++ internal/pkg/skin/skin.go | 67 +++++----- internal/pkg/skin/types.go | 195 ---------------------------- 10 files changed, 449 insertions(+), 248 deletions(-) create mode 100644 internal/pkg/mojang/mojang.go create mode 100644 internal/pkg/mojang/profile.go create mode 100644 internal/pkg/mojang/types.go diff --git a/cmd/mc/skin/add.go b/cmd/mc/skin/add.go index 78df6de..609a372 100644 --- a/cmd/mc/skin/add.go +++ b/cmd/mc/skin/add.go @@ -1,12 +1,15 @@ package skin import ( + "context" "errors" "fmt" - "slices" + "os" + "os/signal" "github.com/google/uuid" "github.com/mworzala/mc/internal/pkg/cli" + "github.com/mworzala/mc/internal/pkg/mojang" "github.com/spf13/cobra" ) @@ -21,13 +24,12 @@ type addSkinOpts struct { } var ( - ErrInvalidType = errors.New("invalid type") ErrInvalidVariant = errors.New("invalid variant") - validVariants = []string{"classic", "slim"} + validationMap = map[string]bool{"classic": true, "slim": true} ) -func newAddCmd(app *cli.App) *cobra.Command { +func newAddCmd(app *cli.App, account string) *cobra.Command { var o addSkinOpts cmd := &cobra.Command{ @@ -43,7 +45,8 @@ func newAddCmd(app *cli.App) *cobra.Command { }, } - cmd.Flags().StringVar(&o.account, "account", "", "Account to use") + o.account = account + cmd.Flags().StringVar(&o.variant, "variant", "classic", "Skin variant [classic/slim]") cmd.Flags().StringVar(&o.cape, "cape", "", "Cape name, 'none' to remove") cmd.Flags().BoolVar(&o.apply, "apply", false, "Apply the skin") @@ -59,7 +62,7 @@ func (o *addSkinOpts) validateArgs(cmd *cobra.Command, args []string) (err error return err } - if !slices.Contains(validVariants, o.variant) { + if !validationMap[o.variant] { return ErrInvalidVariant } @@ -84,7 +87,11 @@ func (o *addSkinOpts) execute(args []string) error { return err } - info, err := o.app.SkinManager().GetProfileInformation(token) + client := mojang.NewProfileClient(o.app.Build.Version) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + info, err := client.ProfileInformation(ctx, token) if err != nil { return err } @@ -112,13 +119,18 @@ func (o *addSkinOpts) execute(args []string) error { if o.apply { - err = skin.Apply(token) + err = o.app.SkinManager().ApplySkin(skin, client, ctx, token) if err != nil { return err } + if !o.app.Config.NonInteractive { + fmt.Printf("skin %s applied", skin.Name) + } } - fmt.Printf("skin %s with cape %s was added to the list", skin.Name, skin.Cape) + if !o.app.Config.NonInteractive { + fmt.Printf("skin %s with cape %s was added to the list", skin.Name, skin.Cape) + } return o.app.SkinManager().Save() diff --git a/cmd/mc/skin/apply.go b/cmd/mc/skin/apply.go index 30d5249..73e23dd 100644 --- a/cmd/mc/skin/apply.go +++ b/cmd/mc/skin/apply.go @@ -1,7 +1,13 @@ package skin import ( + "context" + "fmt" + "os" + "os/signal" + "github.com/mworzala/mc/internal/pkg/cli" + "github.com/mworzala/mc/internal/pkg/mojang" "github.com/spf13/cobra" ) @@ -11,7 +17,7 @@ type applySkinOpts struct { account string } -func newApplyCmd(app *cli.App) *cobra.Command { +func newApplyCmd(app *cli.App, account string) *cobra.Command { var o applySkinOpts cmd := &cobra.Command{ @@ -28,7 +34,7 @@ func newApplyCmd(app *cli.App) *cobra.Command { }, } - cmd.Flags().StringVar(&o.account, "account", "", "Account to use") + o.account = account return cmd } @@ -58,5 +64,17 @@ func (o *applySkinOpts) execute(args []string) error { return err } - return skin.Apply(token) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + client := mojang.NewProfileClient(o.app.Build.Version) + + err = o.app.SkinManager().ApplySkin(skin, client, ctx, token) + if err != nil { + return err + } + if !o.app.Config.NonInteractive { + fmt.Printf("skin %s applied", skin.Name) + } + return nil } diff --git a/cmd/mc/skin/list.go b/cmd/mc/skin/list.go index 0654552..e451398 100644 --- a/cmd/mc/skin/list.go +++ b/cmd/mc/skin/list.go @@ -32,8 +32,8 @@ func (o *listSkinsOpts) listSkins() error { var result appModel.SkinList for _, skin := range skinManager.Skins() { result = append(result, &appModel.Skin{ - Name: skin.Name, - Date: skin.AddedDate, + Name: skin.Name, + Modified: skin.AddedDate, }) } diff --git a/cmd/mc/skin/skin.go b/cmd/mc/skin/skin.go index d150e15..24ae0c7 100644 --- a/cmd/mc/skin/skin.go +++ b/cmd/mc/skin/skin.go @@ -8,12 +8,16 @@ import ( func NewSkinCmd(app *cli.App) *cobra.Command { cmd := &cobra.Command{ Use: "skin", - Short: "Manage Minecraft Skins and Capes", + Short: "Manage Minecraft skins and capes", } + var account string + + cmd.Flags().StringVar(&account, "account", "", "Account to use") + cmd.AddCommand(newListCmd(app)) - cmd.AddCommand(newAddCmd(app)) - cmd.AddCommand(newApplyCmd(app)) + cmd.AddCommand(newAddCmd(app, account)) + cmd.AddCommand(newApplyCmd(app, account)) return cmd } diff --git a/internal/pkg/cli/model/skin.go b/internal/pkg/cli/model/skin.go index 161c613..d71647c 100644 --- a/internal/pkg/cli/model/skin.go +++ b/internal/pkg/cli/model/skin.go @@ -8,21 +8,21 @@ import ( ) type Skin struct { - Name string - Date time.Time + Name string + Modified time.Time } func (i *Skin) String() string { - return fmt.Sprintf("%s\t%s", i.Name, i.Date) + return fmt.Sprintf("%s\t%s", i.Name, i.Modified) } type SkinList []*Skin func (l SkinList) String() string { table := uitable.New() - table.AddRow("NAME", "DATE") + table.AddRow("NAME", "MODIFIED") for _, skin := range l { - table.AddRow(skin.Name, skin.Date) + table.AddRow(skin.Name, skin.Modified) } return table.String() } diff --git a/internal/pkg/mojang/mojang.go b/internal/pkg/mojang/mojang.go new file mode 100644 index 0000000..fd2047a --- /dev/null +++ b/internal/pkg/mojang/mojang.go @@ -0,0 +1,224 @@ +package mojang + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + userAgentFormat = "mworzala/mc/%s" +) + +var ( + servicesApiUrl = "https://api.minecraftservices.com/" + profileApiUrl = servicesApiUrl + "minecraft/profile" +) + +type Client struct { + baseUrl string + userAgent string + httpClient *http.Client + timeout time.Duration +} + +func NewProfileClient(idVersion string) *Client { + return &Client{ + baseUrl: profileApiUrl, + userAgent: fmt.Sprintf(userAgentFormat, idVersion), + httpClient: http.DefaultClient, + timeout: 10 * time.Second, + } +} + +func get[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil) + if err != nil { + return nil, err + } + req.Header = headers + req.Header.Set("User-Agent", c.userAgent) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized { + var errorRes unauthorizedError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusNotFound { + var errorRes notFoundError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("404 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusInternalServerError { + return nil, errors.New("500 internal server error") + } else if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) + } + + var result T + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return &result, nil +} + +func delete[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil) + if err != nil { + return nil, err + } + req.Header = headers + req.Header.Set("User-Agent", c.userAgent) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized { + var errorRes unauthorizedError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusInternalServerError { + return nil, errors.New("500 internal server error") + } else if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) + } + + var result T + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return &result, nil +} + +func post[T any](c *Client, ctx context.Context, endpoint string, headers http.Header, body io.Reader) (*T, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUrl, body) + if err != nil { + return nil, err + } + req.Header = headers + req.Header.Set("User-Agent", c.userAgent) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized { + var errorRes unauthorizedError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusBadRequest { + var errorRes badRequestError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("400 %s: %s", errorRes.Path, errorRes.Error) + } else if res.StatusCode == http.StatusInternalServerError { + return nil, errors.New("500 internal server error") + } else if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) + } + + var result T + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return &result, nil +} + +func put[T any](c *Client, ctx context.Context, endpoint string, headers http.Header, body io.Reader) (*T, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, fullUrl, body) + if err != nil { + return nil, err + } + req.Header = headers + req.Header.Set("User-Agent", c.userAgent) + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized { + var errorRes unauthorizedError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusBadRequest { + var errorRes badRequestError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("400 %s: %s", errorRes.Path, errorRes.Error) + } else if res.StatusCode == http.StatusInternalServerError { + return nil, errors.New("500 internal server error") + } else if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) + } + + var result T + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return &result, nil +} + +type unauthorizedError struct { + Path string `json:"path"` + ErrorType string `json:"errorType"` + Error string `json:"error"` + ErrorMessage string `json:"errorMessage"` + DeveloperMessage string `json:"developerMessage"` +} + +type notFoundError struct { + Path string `json:"path"` + ErrorType string `json:"errorType"` + Error string `json:"error"` + ErrorMessage string `json:"errorMessage"` + DeveloperMessage string `json:"developerMessage"` +} + +type badRequestError struct { + Path string `json:"path"` + ErrorType string `json:"errorType"` + Error string `json:"error"` +} diff --git a/internal/pkg/mojang/profile.go b/internal/pkg/mojang/profile.go new file mode 100644 index 0000000..fb4ab4e --- /dev/null +++ b/internal/pkg/mojang/profile.go @@ -0,0 +1,108 @@ +package mojang + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/url" +) + +func isURL(s string) bool { + u, err := url.ParseRequestURI(s) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") +} + +func (c *Client) ProfileInformation(ctx context.Context, accountToken string) (*ProfileInformationResponse, error) { + + headers := http.Header{} + + headers.Set("Authorization", "Bearer "+accountToken) + + return get[ProfileInformationResponse](c, ctx, "", headers) +} + +func (c *Client) ChangeSkin(ctx context.Context, accountToken string, texture string, variant string) (*ProfileInformationResponse, error) { + var body *bytes.Buffer + var contentType string + + if isURL(texture) { + requestData := map[string]string{ + "url": texture, + "variant": variant, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + return nil, err + } + body = bytes.NewBuffer(jsonData) + contentType = "application/json" + } else { + imageData, err := base64.StdEncoding.DecodeString(texture) + if err != nil { + return nil, err + } + + body = new(bytes.Buffer) + writer := multipart.NewWriter(body) + + err = writer.WriteField("variant", variant) + if err != nil { + return nil, err + } + + part, err := writer.CreateFormFile("file", "skin.png") + if err != nil { + return nil, err + } + + _, err = io.Copy(part, bytes.NewReader(imageData)) + if err != nil { + return nil, err + } + + err = writer.Close() + if err != nil { + return nil, err + } + + contentType = writer.FormDataContentType() + } + + headers := http.Header{} + + headers.Set("Content-Type", contentType) + headers.Set("Authorization", "Bearer "+accountToken) + + return post[ProfileInformationResponse](c, ctx, "/skins", headers, body) +} + +func (c *Client) ChangeCape(ctx context.Context, accountToken string, cape string) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accountToken) + headers.Set("Content-Type", "application/json") + + requestData := map[string]string{ + "capeId": cape, + } + + jsonData, err := json.Marshal(requestData) + if err != nil { + return nil, err + } + + return put[ProfileInformationResponse](c, ctx, endpoint, headers, bytes.NewBuffer(jsonData)) +} + +func (c *Client) DeleteCape(ctx context.Context, accountToken string) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accountToken) + + return delete[ProfileInformationResponse](c, ctx, endpoint, headers) +} diff --git a/internal/pkg/mojang/types.go b/internal/pkg/mojang/types.go new file mode 100644 index 0000000..57b1d1a --- /dev/null +++ b/internal/pkg/mojang/types.go @@ -0,0 +1,25 @@ +package mojang + +type ProfileInformationResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Skins []ProfileSkin `json:"skins"` + Capes []ProfileCape `json:"capes"` + ProfileActions struct { + } `json:"profileActions"` +} + +type ProfileSkin struct { + ID string `json:"id"` + State string `json:"state"` + URL string `json:"url"` + TextureKey string `json:"textureKey"` + Variant string `json:"variant"` +} + +type ProfileCape struct { + ID string `json:"id"` + State string `json:"state"` + URL string `json:"url"` + Alias string `json:"alias"` +} diff --git a/internal/pkg/skin/skin.go b/internal/pkg/skin/skin.go index 75e910e..ae0cc38 100644 --- a/internal/pkg/skin/skin.go +++ b/internal/pkg/skin/skin.go @@ -1,18 +1,20 @@ package skin import ( + "context" "encoding/base64" "encoding/json" "errors" "fmt" "io/fs" "net/http" - "net/url" "os" "path" "regexp" "strings" "time" + + "github.com/mworzala/mc/internal/pkg/mojang" ) var ( @@ -27,11 +29,6 @@ func isValidName(name string) bool { return namePattern.MatchString(name) } -func isURL(s string) bool { - u, err := url.ParseRequestURI(s) - return err == nil && (u.Scheme == "http" || u.Scheme == "https") -} - func isFilePath(s string) bool { if _, err := os.Stat(s); err == nil { return true @@ -54,8 +51,7 @@ type Manager interface { CreateSkin(name string, variant string, skinData string, capeData string) (*Skin, error) Skins() []*Skin GetSkin(name string) (*Skin, error) - - GetProfileInformation(accountToken string) (*profileInformationResponse, error) + ApplySkin(s *Skin, client *mojang.Client, ctx context.Context, accountToken string) error Save() error } @@ -156,40 +152,49 @@ func (m *fileManager) GetSkin(name string) (*Skin, error) { return nil, ErrNotFound } -func (m *fileManager) Save() error { - f, err := os.OpenFile(m.Path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666) +func (m *fileManager) ApplySkin(s *Skin, client *mojang.Client, ctx context.Context, accountToken string) error { + var newCape bool + + if s.Cape == "none" { + _, err := client.DeleteCape(ctx, accountToken) + if err != nil { + return err + } + } + + info, err := client.ChangeSkin(ctx, accountToken, s.Skin, s.Variant) if err != nil { - return fmt.Errorf("failed to open %s: %w", m.Path, err) + return err } - defer f.Close() - if err := json.NewEncoder(f).Encode(m); err != nil { - return fmt.Errorf("failed to write json: %w", err) + if s.Cape != "none" { + for _, c := range info.Capes { + if c.ID == s.Cape && c.State == "INACTIVE" { + newCape = true + } + } + } + + if newCape { + _, err = client.ChangeCape(ctx, accountToken, s.Cape) + if err != nil { + return err + } } return nil } -func (m *fileManager) GetProfileInformation(accountToken string) (*profileInformationResponse, error) { - endpoint := "https://api.minecraftservices.com/minecraft/profile" - - req, err := http.NewRequest("GET", endpoint, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) - res, err := http.DefaultClient.Do(req) +func (m *fileManager) Save() error { + f, err := os.OpenFile(m.Path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0666) if err != nil { - return nil, err + return fmt.Errorf("failed to open %s: %w", m.Path, err) } - defer res.Body.Close() + defer f.Close() - var information profileInformationResponse - if err := json.NewDecoder(res.Body).Decode(&information); err != nil { - return nil, err + if err := json.NewEncoder(f).Encode(m); err != nil { + return fmt.Errorf("failed to write json: %w", err) } - return &information, nil - + return nil } diff --git a/internal/pkg/skin/types.go b/internal/pkg/skin/types.go index 95d9e3c..8d70241 100644 --- a/internal/pkg/skin/types.go +++ b/internal/pkg/skin/types.go @@ -1,13 +1,6 @@ package skin import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" "time" ) @@ -18,191 +11,3 @@ type Skin struct { Cape string `json:"cape"` AddedDate time.Time `json:"added_date"` } - -func (s *Skin) Apply(accountToken string) error { - var newCape bool - - if s.Cape == "none" { - endpoint := "https://api.minecraftservices.com/minecraft/profile/capes/active" - - req, err := http.NewRequest("DELETE", endpoint, nil) - if err != nil { - return err - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) - res, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("cape disable request was not ok: %d", res.StatusCode) - } - } - - if isURL(s.Skin) { - endpoint := "https://api.minecraftservices.com/minecraft/profile/skins" - - requestData := map[string]string{ - "url": s.Skin, - "variant": s.Variant, - } - - jsonData, err := json.Marshal(requestData) - if err != nil { - return err - } - - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("skin url request was not ok: %d", res.StatusCode) - } - - var information profileInformationResponse - if err := json.NewDecoder(res.Body).Decode(&information); err != nil { - return err - } - - if s.Cape != "none" { - for _, c := range information.Capes { - if c.ID == s.Cape && c.State == "INACTIVE" { - newCape = true - } - } - } - - } else { - imageData, err := base64.StdEncoding.DecodeString(s.Skin) - if err != nil { - return err - } - - body := new(bytes.Buffer) - writer := multipart.NewWriter(body) - - err = writer.WriteField("variant", s.Variant) - if err != nil { - return err - } - - part, err := writer.CreateFormFile("file", "skin.png") - if err != nil { - return err - } - - _, err = io.Copy(part, bytes.NewReader(imageData)) - if err != nil { - return err - } - - err = writer.Close() - if err != nil { - return err - } - - req, err := http.NewRequest("POST", "https://api.minecraftservices.com/minecraft/profile/skins", body) - if err != nil { - return err - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("skin request was not ok: %d", res.StatusCode) - } - - var information profileInformationResponse - if err := json.NewDecoder(res.Body).Decode(&information); err != nil { - return err - } - - if s.Cape != "none" { - for _, c := range information.Capes { - if c.ID == s.Cape && c.State == "INACTIVE" { - newCape = true - } - } - } - - } - - if newCape { - endpoint := "https://api.minecraftservices.com/minecraft/profile/capes/active" - - requestData := map[string]string{ - "capeId": s.Cape, - } - - jsonData, err := json.Marshal(requestData) - if err != nil { - return err - } - - req, err := http.NewRequest("PUT", endpoint, bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accountToken)) - req.Header.Set("Content-Type", "application/json") - res, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return fmt.Errorf("cape put request was not ok: %d", res.StatusCode) - } - } - - fmt.Printf("skin %s and cape %s was applied", s.Name, s.Cape) - - return nil -} - -type profileInformationResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Skins []profileSkin `json:"skins"` - Capes []profileCape `json:"capes"` - ProfileActions struct { - } `json:"profileActions"` -} - -type profileSkin struct { - ID string `json:"id"` - State string `json:"state"` - URL string `json:"url"` - TextureKey string `json:"textureKey"` - Variant string `json:"variant"` -} - -type profileCape struct { - ID string `json:"id"` - State string `json:"state"` - URL string `json:"url"` - Alias string `json:"alias"` -} From fa115194c57de87b621074f417dc68a54bd7ffe6 Mon Sep 17 00:00:00 2001 From: cosrnic <75631523+cosrnic@users.noreply.github.com> Date: Sat, 5 Oct 2024 00:28:05 +0100 Subject: [PATCH 4/5] feat: skins from username/uuid --- cmd/mc/skin/add.go | 6 +-- internal/pkg/mojang/mojang.go | 6 ++- internal/pkg/mojang/profile.go | 24 +++++++++++- internal/pkg/mojang/types.go | 37 ++++++++++++++++++ internal/pkg/skin/skin.go | 69 +++++++++++++++++++++++++++++++--- 5 files changed, 129 insertions(+), 13 deletions(-) diff --git a/cmd/mc/skin/add.go b/cmd/mc/skin/add.go index 609a372..2a0b61c 100644 --- a/cmd/mc/skin/add.go +++ b/cmd/mc/skin/add.go @@ -26,7 +26,7 @@ type addSkinOpts struct { var ( ErrInvalidVariant = errors.New("invalid variant") - validationMap = map[string]bool{"classic": true, "slim": true} + validationMap = map[string]bool{"classic": true, "slim": true, "": true} ) func newAddCmd(app *cli.App, account string) *cobra.Command { @@ -47,7 +47,7 @@ func newAddCmd(app *cli.App, account string) *cobra.Command { o.account = account - cmd.Flags().StringVar(&o.variant, "variant", "classic", "Skin variant [classic/slim]") + cmd.Flags().StringVar(&o.variant, "variant", "", "Skin variant [classic/slim] (defaults to classic)") cmd.Flags().StringVar(&o.cape, "cape", "", "Cape name, 'none' to remove") cmd.Flags().BoolVar(&o.apply, "apply", false, "Apply the skin") cmd.Flags().BoolVar(&o.apply, "set", false, "Apply the skin") @@ -112,7 +112,7 @@ func (o *addSkinOpts) execute(args []string) error { skinData := args[0] - skin, err := o.app.SkinManager().CreateSkin(o.name, o.variant, skinData, o.cape) + skin, err := o.app.SkinManager().CreateSkin(o.name, o.variant, skinData, o.cape, client, ctx) if err != nil { return err } diff --git a/internal/pkg/mojang/mojang.go b/internal/pkg/mojang/mojang.go index fd2047a..ebe88da 100644 --- a/internal/pkg/mojang/mojang.go +++ b/internal/pkg/mojang/mojang.go @@ -15,8 +15,10 @@ const ( ) var ( - servicesApiUrl = "https://api.minecraftservices.com/" - profileApiUrl = servicesApiUrl + "minecraft/profile" + mojangApiUrl = "https://api.mojang.com/" + sessionserverUrl = "https://sessionserver.mojang.com/" + servicesApiUrl = "https://api.minecraftservices.com/" + profileApiUrl = servicesApiUrl + "minecraft/profile" ) type Client struct { diff --git a/internal/pkg/mojang/profile.go b/internal/pkg/mojang/profile.go index fb4ab4e..7ae68a2 100644 --- a/internal/pkg/mojang/profile.go +++ b/internal/pkg/mojang/profile.go @@ -82,7 +82,7 @@ func (c *Client) ChangeSkin(ctx context.Context, accountToken string, texture st } func (c *Client) ChangeCape(ctx context.Context, accountToken string, cape string) (*ProfileInformationResponse, error) { - endpoint := "/capes/active" + endpoint := "capes/active" headers := http.Header{} headers.Set("Authorization", "Bearer "+accountToken) headers.Set("Content-Type", "application/json") @@ -100,9 +100,29 @@ func (c *Client) ChangeCape(ctx context.Context, accountToken string, cape strin } func (c *Client) DeleteCape(ctx context.Context, accountToken string) (*ProfileInformationResponse, error) { - endpoint := "/capes/active" + endpoint := "capes/active" headers := http.Header{} headers.Set("Authorization", "Bearer "+accountToken) return delete[ProfileInformationResponse](c, ctx, endpoint, headers) } + +func (c *Client) UsernameToUuid(ctx context.Context, username string) (*UsernameToUuidResponse, error) { + oldUrl := c.baseUrl + c.baseUrl = mojangApiUrl // i dont like this but i cant think of any other way atm :( + endpoint := "users/profiles/minecraft/" + username + + response, err := get[UsernameToUuidResponse](c, ctx, endpoint, http.Header{}) + c.baseUrl = oldUrl + return response, err +} + +func (c *Client) UuidToProfile(ctx context.Context, uuid string) (*UuidToProfileResponse, error) { + oldUrl := c.baseUrl + c.baseUrl = sessionserverUrl // i dont like this but i cant think of any other way atm :( + endpoint := "session/minecraft/profile/" + uuid + + response, err := get[UuidToProfileResponse](c, ctx, endpoint, http.Header{}) + c.baseUrl = oldUrl + return response, err +} diff --git a/internal/pkg/mojang/types.go b/internal/pkg/mojang/types.go index 57b1d1a..6ab4772 100644 --- a/internal/pkg/mojang/types.go +++ b/internal/pkg/mojang/types.go @@ -23,3 +23,40 @@ type ProfileCape struct { URL string `json:"url"` Alias string `json:"alias"` } + +type UsernameToUuidResponse struct { + Name string `json:"name"` + Id string `json:"id"` +} + +type UuidToProfileResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Properties []ProfileProperties `json:"properties"` + Legacy bool `json:"legacy"` +} + +type ProfileProperties struct { + Name string `json:"name"` + Value string `json:"value"` + Signature string `json:"signature"` +} + +type TextureInformation struct { + Timestamp int `json:"timestamp"` + ProfileId string `json:"profileId"` + ProfileName string `json:"profileName"` + Textures Textures `json:"textures"` +} + +type Textures struct { + Skin struct { + Url string `json:"url"` + Metadata struct { + Model string `json:"model"` + } `json:"metadata"` + } `json:"SKIN"` + Cape struct { + Url string `json:"url"` + } `json:"CAPE"` +} diff --git a/internal/pkg/skin/skin.go b/internal/pkg/skin/skin.go index ae0cc38..3c8b46a 100644 --- a/internal/pkg/skin/skin.go +++ b/internal/pkg/skin/skin.go @@ -15,6 +15,7 @@ import ( "time" "github.com/mworzala/mc/internal/pkg/mojang" + "github.com/mworzala/mc/internal/pkg/util" ) var ( @@ -48,7 +49,7 @@ func isImage(data []byte) bool { } type Manager interface { - CreateSkin(name string, variant string, skinData string, capeData string) (*Skin, error) + CreateSkin(name string, variant string, skinData string, capeData string, client *mojang.Client, ctx context.Context) (*Skin, error) Skins() []*Skin GetSkin(name string) (*Skin, error) ApplySkin(s *Skin, client *mojang.Client, ctx context.Context, accountToken string) error @@ -90,7 +91,7 @@ func NewManager(dataDir string) (Manager, error) { return &manager, nil } -func (m *fileManager) CreateSkin(name string, variant string, skinData string, capeData string) (*Skin, error) { +func (m *fileManager) CreateSkin(name string, variant string, skinData string, capeData string, client *mojang.Client, ctx context.Context) (*Skin, error) { if !isValidName(name) { return nil, ErrInvalidName } @@ -99,9 +100,8 @@ func (m *fileManager) CreateSkin(name string, variant string, skinData string, c } skin := &Skin{ - Name: name, - Cape: capeData, - Variant: variant, + Name: name, + Cape: capeData, } if isFilePath(skinData) { fileBytes, err := os.ReadFile(skinData) @@ -116,7 +116,15 @@ func (m *fileManager) CreateSkin(name string, variant string, skinData string, c base64Str := base64.StdEncoding.EncodeToString(fileBytes) skin.Skin = base64Str } else { - skin.Skin = skinData + texture, newVariant := getSkinInfo(skinData, variant, client, ctx) + skin.Skin = texture + skin.Variant = newVariant + } + + if variant == "" && skin.Variant == "" { + skin.Variant = "classic" + } else if skin.Variant == "" { + skin.Variant = variant } skin.AddedDate = time.Now() @@ -125,6 +133,55 @@ func (m *fileManager) CreateSkin(name string, variant string, skinData string, c return skin, nil } +func getSkinInfo(skinData string, variant string, client *mojang.Client, ctx context.Context) (string, string) { + if util.IsUUID(skinData) { + profile, err := client.UuidToProfile(ctx, skinData) + if err != nil { + return skinData, variant + } + + base64TextureInfo, err := base64.StdEncoding.DecodeString(profile.Properties[0].Value) + if err != nil { + return skinData, variant + } + var textureInfo mojang.TextureInformation + err = json.Unmarshal(base64TextureInfo, &textureInfo) + if err != nil { + return skinData, variant + } + if variant == "" { + variant = textureInfo.Textures.Skin.Metadata.Model + } + return textureInfo.Textures.Skin.Url, variant + + } else { + uuid, err := client.UsernameToUuid(ctx, skinData) + if err != nil { + return skinData, variant + } + + profile, err := client.UuidToProfile(ctx, uuid.Id) + if err != nil { + return skinData, variant + } + base64texture := profile.Properties[0].Value + + base64TextureInfo, err := base64.StdEncoding.DecodeString(base64texture) + if err != nil { + return skinData, variant + } + var textureInfo mojang.TextureInformation + err = json.Unmarshal(base64TextureInfo, &textureInfo) + if err != nil { + return skinData, variant + } + if variant == "" { + variant = textureInfo.Textures.Skin.Metadata.Model + } + return textureInfo.Textures.Skin.Url, variant + } +} + func (m *fileManager) Skins() (result []*Skin) { for _, s := range m.AllSkins { result = append(result, s) From e0513c70ebfb131e2d44fabd1dcdc6e22d4d93ef Mon Sep 17 00:00:00 2001 From: cosrnic <75631523+cosrnic@users.noreply.github.com> Date: Sat, 5 Oct 2024 12:48:37 +0100 Subject: [PATCH 5/5] Make requested changes --- cmd/mc/skin/add.go | 8 +- cmd/mc/skin/apply.go | 4 +- internal/pkg/modrinth/modrinth.go | 8 +- internal/pkg/mojang/mojang.go | 169 +++++++----------------------- internal/pkg/mojang/profile.go | 38 +++---- internal/pkg/skin/skin.go | 18 ++-- internal/pkg/util/useragent.go | 9 ++ 7 files changed, 79 insertions(+), 175 deletions(-) create mode 100644 internal/pkg/util/useragent.go diff --git a/cmd/mc/skin/add.go b/cmd/mc/skin/add.go index 2a0b61c..9e3846b 100644 --- a/cmd/mc/skin/add.go +++ b/cmd/mc/skin/add.go @@ -87,11 +87,11 @@ func (o *addSkinOpts) execute(args []string) error { return err } - client := mojang.NewProfileClient(o.app.Build.Version) + client := mojang.NewProfileClient(o.app.Build.Version, token) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - info, err := client.ProfileInformation(ctx, token) + info, err := client.ProfileInformation(ctx) if err != nil { return err } @@ -112,14 +112,14 @@ func (o *addSkinOpts) execute(args []string) error { skinData := args[0] - skin, err := o.app.SkinManager().CreateSkin(o.name, o.variant, skinData, o.cape, client, ctx) + skin, err := o.app.SkinManager().CreateSkin(ctx, client, o.name, o.variant, skinData, o.cape) if err != nil { return err } if o.apply { - err = o.app.SkinManager().ApplySkin(skin, client, ctx, token) + err = o.app.SkinManager().ApplySkin(ctx, client, skin) if err != nil { return err } diff --git a/cmd/mc/skin/apply.go b/cmd/mc/skin/apply.go index 73e23dd..b080526 100644 --- a/cmd/mc/skin/apply.go +++ b/cmd/mc/skin/apply.go @@ -67,9 +67,9 @@ func (o *applySkinOpts) execute(args []string) error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - client := mojang.NewProfileClient(o.app.Build.Version) + client := mojang.NewProfileClient(o.app.Build.Version, token) - err = o.app.SkinManager().ApplySkin(skin, client, ctx, token) + err = o.app.SkinManager().ApplySkin(ctx, client, skin) if err != nil { return err } diff --git a/internal/pkg/modrinth/modrinth.go b/internal/pkg/modrinth/modrinth.go index 1ca0490..f7c7003 100644 --- a/internal/pkg/modrinth/modrinth.go +++ b/internal/pkg/modrinth/modrinth.go @@ -8,11 +8,11 @@ import ( "net/http" "net/url" "time" + + "github.com/mworzala/mc/internal/pkg/util" ) const ( - userAgentFormat = "mworzala/mc/%s" - fabricApiProjectId = "P7dR8mSH" ) @@ -31,7 +31,7 @@ type Client struct { func NewClient(idVersion string) *Client { return &Client{ baseUrl: prodUrl, - userAgent: fmt.Sprintf(userAgentFormat, idVersion), + userAgent: util.MakeUserAgent(idVersion), httpClient: http.DefaultClient, timeout: 10 * time.Second, } @@ -40,7 +40,7 @@ func NewClient(idVersion string) *Client { func NewStagingClient() *Client { return &Client{ baseUrl: stagingUrl, - userAgent: fmt.Sprintf(userAgentFormat, "dev"), + userAgent: util.MakeUserAgent("dev"), httpClient: http.DefaultClient, timeout: 10 * time.Second, } diff --git a/internal/pkg/mojang/mojang.go b/internal/pkg/mojang/mojang.go index ebe88da..43d1285 100644 --- a/internal/pkg/mojang/mojang.go +++ b/internal/pkg/mojang/mojang.go @@ -8,44 +8,44 @@ import ( "io" "net/http" "time" -) -const ( - userAgentFormat = "mworzala/mc/%s" + "github.com/mworzala/mc/internal/pkg/util" ) var ( - mojangApiUrl = "https://api.mojang.com/" - sessionserverUrl = "https://sessionserver.mojang.com/" - servicesApiUrl = "https://api.minecraftservices.com/" - profileApiUrl = servicesApiUrl + "minecraft/profile" + mojangApiUrl = "https://api.mojang.com" + sessionserverUrl = "https://sessionserver.mojang.com" + servicesApiUrl = "https://api.minecraftservices.com" + profileApiUrl = servicesApiUrl + "/minecraft/profile" ) type Client struct { - baseUrl string - userAgent string - httpClient *http.Client - timeout time.Duration + baseUrl string + userAgent string + accountToken string + httpClient *http.Client + timeout time.Duration } -func NewProfileClient(idVersion string) *Client { +func NewProfileClient(idVersion string, accountToken string) *Client { return &Client{ - baseUrl: profileApiUrl, - userAgent: fmt.Sprintf(userAgentFormat, idVersion), - httpClient: http.DefaultClient, - timeout: 10 * time.Second, + baseUrl: profileApiUrl, + userAgent: util.MakeUserAgent(idVersion), + accountToken: accountToken, + httpClient: http.DefaultClient, + timeout: 10 * time.Second, } } -func get[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { +func do[T any](c *Client, ctx context.Context, method string, url string, headers http.Header, body io.Reader) (*T, error) { ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() - fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil) + req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, err } + req.Header = headers req.Header.Set("User-Agent", c.userAgent) @@ -61,6 +61,12 @@ func get[T any](c *Client, ctx context.Context, endpoint string, headers http.He return nil, fmt.Errorf("failed to decode response body: %w", err) } return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) + } else if res.StatusCode == http.StatusBadRequest { + var errorRes badRequestError + if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + return nil, fmt.Errorf("400 %s: %s", errorRes.Path, errorRes.Error) } else if res.StatusCode == http.StatusNotFound { var errorRes notFoundError if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { @@ -80,127 +86,24 @@ func get[T any](c *Client, ctx context.Context, endpoint string, headers http.He return &result, nil } -func delete[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { - ctx, cancel := context.WithTimeout(ctx, c.timeout) - defer cancel() - - fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fullUrl, nil) - if err != nil { - return nil, err - } - req.Header = headers - req.Header.Set("User-Agent", c.userAgent) - - res, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode == http.StatusUnauthorized { - var errorRes unauthorizedError - if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) - } else if res.StatusCode == http.StatusInternalServerError { - return nil, errors.New("500 internal server error") - } else if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) - } +func get[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { + url := c.baseUrl + endpoint + return do[T](c, ctx, http.MethodGet, url, headers, nil) +} - var result T - if err := json.NewDecoder(res.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return &result, nil +func delete[T any](c *Client, ctx context.Context, endpoint string, headers http.Header) (*T, error) { + url := c.baseUrl + endpoint + return do[T](c, ctx, http.MethodDelete, url, headers, nil) } func post[T any](c *Client, ctx context.Context, endpoint string, headers http.Header, body io.Reader) (*T, error) { - ctx, cancel := context.WithTimeout(ctx, c.timeout) - defer cancel() - - fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullUrl, body) - if err != nil { - return nil, err - } - req.Header = headers - req.Header.Set("User-Agent", c.userAgent) - - res, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode == http.StatusUnauthorized { - var errorRes unauthorizedError - if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) - } else if res.StatusCode == http.StatusBadRequest { - var errorRes badRequestError - if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return nil, fmt.Errorf("400 %s: %s", errorRes.Path, errorRes.Error) - } else if res.StatusCode == http.StatusInternalServerError { - return nil, errors.New("500 internal server error") - } else if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) - } - - var result T - if err := json.NewDecoder(res.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return &result, nil + url := c.baseUrl + endpoint + return do[T](c, ctx, http.MethodPost, url, headers, body) } func put[T any](c *Client, ctx context.Context, endpoint string, headers http.Header, body io.Reader) (*T, error) { - ctx, cancel := context.WithTimeout(ctx, c.timeout) - defer cancel() - - fullUrl := fmt.Sprintf("%s%s", c.baseUrl, endpoint) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, fullUrl, body) - if err != nil { - return nil, err - } - req.Header = headers - req.Header.Set("User-Agent", c.userAgent) - - res, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode == http.StatusUnauthorized { - var errorRes unauthorizedError - if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return nil, fmt.Errorf("401 %s: %s", errorRes.Path, errorRes.ErrorMessage) - } else if res.StatusCode == http.StatusBadRequest { - var errorRes badRequestError - if err := json.NewDecoder(res.Body).Decode(&errorRes); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return nil, fmt.Errorf("400 %s: %s", errorRes.Path, errorRes.Error) - } else if res.StatusCode == http.StatusInternalServerError { - return nil, errors.New("500 internal server error") - } else if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected response from server: %d", res.StatusCode) - } - - var result T - if err := json.NewDecoder(res.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - return &result, nil + url := c.baseUrl + endpoint + return do[T](c, ctx, http.MethodPut, url, headers, body) } type unauthorizedError struct { diff --git a/internal/pkg/mojang/profile.go b/internal/pkg/mojang/profile.go index 7ae68a2..0e99961 100644 --- a/internal/pkg/mojang/profile.go +++ b/internal/pkg/mojang/profile.go @@ -16,16 +16,16 @@ func isURL(s string) bool { return err == nil && (u.Scheme == "http" || u.Scheme == "https") } -func (c *Client) ProfileInformation(ctx context.Context, accountToken string) (*ProfileInformationResponse, error) { +func (c *Client) ProfileInformation(ctx context.Context) (*ProfileInformationResponse, error) { headers := http.Header{} - headers.Set("Authorization", "Bearer "+accountToken) + headers.Set("Authorization", "Bearer "+c.accountToken) - return get[ProfileInformationResponse](c, ctx, "", headers) + return get[ProfileInformationResponse](c, ctx, "/", headers) } -func (c *Client) ChangeSkin(ctx context.Context, accountToken string, texture string, variant string) (*ProfileInformationResponse, error) { +func (c *Client) ChangeSkin(ctx context.Context, texture string, variant string) (*ProfileInformationResponse, error) { var body *bytes.Buffer var contentType string @@ -76,15 +76,15 @@ func (c *Client) ChangeSkin(ctx context.Context, accountToken string, texture st headers := http.Header{} headers.Set("Content-Type", contentType) - headers.Set("Authorization", "Bearer "+accountToken) + headers.Set("Authorization", "Bearer "+c.accountToken) return post[ProfileInformationResponse](c, ctx, "/skins", headers, body) } -func (c *Client) ChangeCape(ctx context.Context, accountToken string, cape string) (*ProfileInformationResponse, error) { - endpoint := "capes/active" +func (c *Client) ChangeCape(ctx context.Context, cape string) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" headers := http.Header{} - headers.Set("Authorization", "Bearer "+accountToken) + headers.Set("Authorization", "Bearer "+c.accountToken) headers.Set("Content-Type", "application/json") requestData := map[string]string{ @@ -99,30 +99,22 @@ func (c *Client) ChangeCape(ctx context.Context, accountToken string, cape strin return put[ProfileInformationResponse](c, ctx, endpoint, headers, bytes.NewBuffer(jsonData)) } -func (c *Client) DeleteCape(ctx context.Context, accountToken string) (*ProfileInformationResponse, error) { - endpoint := "capes/active" +func (c *Client) DeleteCape(ctx context.Context) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" headers := http.Header{} - headers.Set("Authorization", "Bearer "+accountToken) + headers.Set("Authorization", "Bearer "+c.accountToken) return delete[ProfileInformationResponse](c, ctx, endpoint, headers) } func (c *Client) UsernameToUuid(ctx context.Context, username string) (*UsernameToUuidResponse, error) { - oldUrl := c.baseUrl - c.baseUrl = mojangApiUrl // i dont like this but i cant think of any other way atm :( - endpoint := "users/profiles/minecraft/" + username + url := mojangApiUrl + "/users/profiles/minecraft/" + username - response, err := get[UsernameToUuidResponse](c, ctx, endpoint, http.Header{}) - c.baseUrl = oldUrl - return response, err + return do[UsernameToUuidResponse](c, ctx, http.MethodGet, url, http.Header{}, nil) } func (c *Client) UuidToProfile(ctx context.Context, uuid string) (*UuidToProfileResponse, error) { - oldUrl := c.baseUrl - c.baseUrl = sessionserverUrl // i dont like this but i cant think of any other way atm :( - endpoint := "session/minecraft/profile/" + uuid + url := sessionserverUrl + "/session/minecraft/profile/" + uuid - response, err := get[UuidToProfileResponse](c, ctx, endpoint, http.Header{}) - c.baseUrl = oldUrl - return response, err + return do[UuidToProfileResponse](c, ctx, http.MethodGet, url, http.Header{}, nil) } diff --git a/internal/pkg/skin/skin.go b/internal/pkg/skin/skin.go index 3c8b46a..c86dd06 100644 --- a/internal/pkg/skin/skin.go +++ b/internal/pkg/skin/skin.go @@ -49,10 +49,10 @@ func isImage(data []byte) bool { } type Manager interface { - CreateSkin(name string, variant string, skinData string, capeData string, client *mojang.Client, ctx context.Context) (*Skin, error) + CreateSkin(ctx context.Context, client *mojang.Client, name string, variant string, skinData string, capeData string) (*Skin, error) Skins() []*Skin GetSkin(name string) (*Skin, error) - ApplySkin(s *Skin, client *mojang.Client, ctx context.Context, accountToken string) error + ApplySkin(ctx context.Context, client *mojang.Client, s *Skin) error Save() error } @@ -91,7 +91,7 @@ func NewManager(dataDir string) (Manager, error) { return &manager, nil } -func (m *fileManager) CreateSkin(name string, variant string, skinData string, capeData string, client *mojang.Client, ctx context.Context) (*Skin, error) { +func (m *fileManager) CreateSkin(ctx context.Context, client *mojang.Client, name string, variant string, skinData string, capeData string) (*Skin, error) { if !isValidName(name) { return nil, ErrInvalidName } @@ -116,7 +116,7 @@ func (m *fileManager) CreateSkin(name string, variant string, skinData string, c base64Str := base64.StdEncoding.EncodeToString(fileBytes) skin.Skin = base64Str } else { - texture, newVariant := getSkinInfo(skinData, variant, client, ctx) + texture, newVariant := getSkinInfo(ctx, client, skinData, variant) skin.Skin = texture skin.Variant = newVariant } @@ -133,7 +133,7 @@ func (m *fileManager) CreateSkin(name string, variant string, skinData string, c return skin, nil } -func getSkinInfo(skinData string, variant string, client *mojang.Client, ctx context.Context) (string, string) { +func getSkinInfo(ctx context.Context, client *mojang.Client, skinData string, variant string) (string, string) { if util.IsUUID(skinData) { profile, err := client.UuidToProfile(ctx, skinData) if err != nil { @@ -209,17 +209,17 @@ func (m *fileManager) GetSkin(name string) (*Skin, error) { return nil, ErrNotFound } -func (m *fileManager) ApplySkin(s *Skin, client *mojang.Client, ctx context.Context, accountToken string) error { +func (m *fileManager) ApplySkin(ctx context.Context, client *mojang.Client, s *Skin) error { var newCape bool if s.Cape == "none" { - _, err := client.DeleteCape(ctx, accountToken) + _, err := client.DeleteCape(ctx) if err != nil { return err } } - info, err := client.ChangeSkin(ctx, accountToken, s.Skin, s.Variant) + info, err := client.ChangeSkin(ctx, s.Skin, s.Variant) if err != nil { return err } @@ -233,7 +233,7 @@ func (m *fileManager) ApplySkin(s *Skin, client *mojang.Client, ctx context.Cont } if newCape { - _, err = client.ChangeCape(ctx, accountToken, s.Cape) + _, err = client.ChangeCape(ctx, s.Cape) if err != nil { return err } diff --git a/internal/pkg/util/useragent.go b/internal/pkg/util/useragent.go new file mode 100644 index 0000000..e812448 --- /dev/null +++ b/internal/pkg/util/useragent.go @@ -0,0 +1,9 @@ +package util + +import "fmt" + +const userAgentFormat = "mworzala/mc/%s" + +func MakeUserAgent(idVersion string) string { + return fmt.Sprintf(userAgentFormat, idVersion) +}