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..9e3846b --- /dev/null +++ b/cmd/mc/skin/add.go @@ -0,0 +1,137 @@ +package skin + +import ( + "context" + "errors" + "fmt" + "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" +) + +type addSkinOpts struct { + app *cli.App + + account string + variant string + cape string + name string + apply bool +} + +var ( + ErrInvalidVariant = errors.New("invalid variant") + + validationMap = map[string]bool{"classic": true, "slim": true, "": true} +) + +func newAddCmd(app *cli.App, account string) *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) + }, + } + + o.account = account + + 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") + + 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 !validationMap[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 + } + + client := mojang.NewProfileClient(o.app.Build.Version, token) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + info, err := client.ProfileInformation(ctx) + 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(ctx, client, o.name, o.variant, skinData, o.cape) + if err != nil { + return err + } + + if o.apply { + + err = o.app.SkinManager().ApplySkin(ctx, client, skin) + if err != nil { + return err + } + if !o.app.Config.NonInteractive { + fmt.Printf("skin %s applied", skin.Name) + } + } + + 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 new file mode 100644 index 0000000..b080526 --- /dev/null +++ b/cmd/mc/skin/apply.go @@ -0,0 +1,80 @@ +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" +) + +type applySkinOpts struct { + app *cli.App + + account string +} + +func newApplyCmd(app *cli.App, account string) *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) + }, + } + + o.account = account + + 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 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + client := mojang.NewProfileClient(o.app.Build.Version, token) + + err = o.app.SkinManager().ApplySkin(ctx, client, skin) + 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 new file mode 100644 index 0000000..e451398 --- /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, + Modified: 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..24ae0c7 --- /dev/null +++ b/cmd/mc/skin/skin.go @@ -0,0 +1,23 @@ +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", + } + + var account string + + cmd.Flags().StringVar(&account, "account", "", "Account to use") + + cmd.AddCommand(newListCmd(app)) + cmd.AddCommand(newAddCmd(app, account)) + cmd.AddCommand(newApplyCmd(app, account)) + + return cmd +} diff --git a/go.mod b/go.mod index 103c526..b116a0a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.2 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.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 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..d71647c --- /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 + Modified time.Time +} + +func (i *Skin) String() string { + return fmt.Sprintf("%s\t%s", i.Name, i.Modified) +} + +type SkinList []*Skin + +func (l SkinList) String() string { + table := uitable.New() + table.AddRow("NAME", "MODIFIED") + for _, skin := range l { + table.AddRow(skin.Name, skin.Modified) + } + return table.String() +} 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 new file mode 100644 index 0000000..43d1285 --- /dev/null +++ b/internal/pkg/mojang/mojang.go @@ -0,0 +1,129 @@ +package mojang + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "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" +) + +type Client struct { + baseUrl string + userAgent string + accountToken string + httpClient *http.Client + timeout time.Duration +} + +func NewProfileClient(idVersion string, accountToken string) *Client { + return &Client{ + baseUrl: profileApiUrl, + userAgent: util.MakeUserAgent(idVersion), + accountToken: accountToken, + httpClient: http.DefaultClient, + timeout: 10 * time.Second, + } +} + +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() + + req, err := http.NewRequestWithContext(ctx, method, url, 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.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 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) +} + +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) { + 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) { + url := c.baseUrl + endpoint + return do[T](c, ctx, http.MethodPut, url, headers, body) +} + +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..0e99961 --- /dev/null +++ b/internal/pkg/mojang/profile.go @@ -0,0 +1,120 @@ +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) (*ProfileInformationResponse, error) { + + headers := http.Header{} + + headers.Set("Authorization", "Bearer "+c.accountToken) + + return get[ProfileInformationResponse](c, ctx, "/", headers) +} + +func (c *Client) ChangeSkin(ctx context.Context, 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 "+c.accountToken) + + return post[ProfileInformationResponse](c, ctx, "/skins", headers, body) +} + +func (c *Client) ChangeCape(ctx context.Context, cape string) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" + headers := http.Header{} + headers.Set("Authorization", "Bearer "+c.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) (*ProfileInformationResponse, error) { + endpoint := "/capes/active" + headers := http.Header{} + headers.Set("Authorization", "Bearer "+c.accountToken) + + return delete[ProfileInformationResponse](c, ctx, endpoint, headers) +} + +func (c *Client) UsernameToUuid(ctx context.Context, username string) (*UsernameToUuidResponse, error) { + url := mojangApiUrl + "/users/profiles/minecraft/" + username + + return do[UsernameToUuidResponse](c, ctx, http.MethodGet, url, http.Header{}, nil) +} + +func (c *Client) UuidToProfile(ctx context.Context, uuid string) (*UuidToProfileResponse, error) { + url := sessionserverUrl + "/session/minecraft/profile/" + uuid + + return do[UuidToProfileResponse](c, ctx, http.MethodGet, url, http.Header{}, nil) +} diff --git a/internal/pkg/mojang/types.go b/internal/pkg/mojang/types.go new file mode 100644 index 0000000..6ab4772 --- /dev/null +++ b/internal/pkg/mojang/types.go @@ -0,0 +1,62 @@ +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"` +} + +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 new file mode 100644 index 0000000..c86dd06 --- /dev/null +++ b/internal/pkg/skin/skin.go @@ -0,0 +1,257 @@ +package skin + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path" + "regexp" + "strings" + "time" + + "github.com/mworzala/mc/internal/pkg/mojang" + "github.com/mworzala/mc/internal/pkg/util" +) + +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 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(ctx context.Context, client *mojang.Client, name string, variant string, skinData string, capeData string) (*Skin, error) + Skins() []*Skin + GetSkin(name string) (*Skin, error) + ApplySkin(ctx context.Context, client *mojang.Client, s *Skin) 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(ctx context.Context, client *mojang.Client, 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, + } + 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 { + texture, newVariant := getSkinInfo(ctx, client, skinData, variant) + skin.Skin = texture + skin.Variant = newVariant + } + + if variant == "" && skin.Variant == "" { + skin.Variant = "classic" + } else if skin.Variant == "" { + skin.Variant = variant + } + + skin.AddedDate = time.Now() + + m.AllSkins[strings.ToLower(name)] = skin + return skin, nil +} + +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 { + 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) + } + 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) ApplySkin(ctx context.Context, client *mojang.Client, s *Skin) error { + var newCape bool + + if s.Cape == "none" { + _, err := client.DeleteCape(ctx) + if err != nil { + return err + } + } + + info, err := client.ChangeSkin(ctx, s.Skin, s.Variant) + if err != nil { + return 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, s.Cape) + if err != nil { + return err + } + } + + return nil +} + +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 +} diff --git a/internal/pkg/skin/types.go b/internal/pkg/skin/types.go new file mode 100644 index 0000000..8d70241 --- /dev/null +++ b/internal/pkg/skin/types.go @@ -0,0 +1,13 @@ +package skin + +import ( + "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"` +} 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) +}