From 69abaf172991db52e0d40f68d38140b914108676 Mon Sep 17 00:00:00 2001 From: Nabil Adem Date: Sat, 29 Nov 2025 00:14:14 -0600 Subject: [PATCH 1/3] Connect to Gravatar API --- cmd/gitfit/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/gitfit/main.go b/cmd/gitfit/main.go index 959ba54..1027faf 100644 --- a/cmd/gitfit/main.go +++ b/cmd/gitfit/main.go @@ -56,7 +56,7 @@ func parseFlags() *Config { outputFormat := flag.String("format", "", "Output image format (jpeg, png, or gif)") quality := flag.Int("quality", 85, "JPEG compression quality (1-100; 85 by default)") verbose := flag.Bool("v", false, "Verbose logging enabled") -uploadGravatar := flag.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") + // uploadGravatar := flag.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") // custom usage message for flags flag.Usage = func() { From 40c9801ab8af00df2a752667e0b6493502a8d981 Mon Sep 17 00:00:00 2001 From: Nabil Adem Date: Sat, 29 Nov 2025 23:57:42 -0600 Subject: [PATCH 2/3] Fix Gravatar OAuth and upload --- cmd/gitfit/main.go | 90 +++++++++++++++-------------------- internal/gravatar/crop.go | 83 ++++++++++++++++++++++++++++++++ internal/gravatar/gravatar.go | 23 ++++++--- 3 files changed, 139 insertions(+), 57 deletions(-) create mode 100644 internal/gravatar/crop.go diff --git a/cmd/gitfit/main.go b/cmd/gitfit/main.go index 1027faf..cdbbbeb 100644 --- a/cmd/gitfit/main.go +++ b/cmd/gitfit/main.go @@ -1,14 +1,14 @@ package main import ( -"flag" -"fmt" -"os" -"path/filepath" -"strings" - -"github.com/nabiladem/git-fit/internal/compressor" -"github.com/nabiladem/git-fit/internal/gravatar" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/nabiladem/git-fit/internal/compressor" + "github.com/nabiladem/git-fit/internal/gravatar" ) // Config holds parsed command-line options @@ -24,6 +24,7 @@ type Config struct { Verbose bool UploadGravatar bool } + // main() - entry point func main() { cfg := parseFlags() @@ -49,43 +50,44 @@ func main() { // parseFlags() - extract flags into a Config struct func parseFlags() *Config { - // define command-line flags + // define command-line flags inputPath := flag.String("input", "", "Path to the input image file") outputPath := flag.String("output", "", "Path to save the compressed image") maxSize := flag.Int("maxsize", 1048576, "Maximum file size in bytes (default 1MB)") outputFormat := flag.String("format", "", "Output image format (jpeg, png, or gif)") quality := flag.Int("quality", 85, "JPEG compression quality (1-100; 85 by default)") verbose := flag.Bool("v", false, "Verbose logging enabled") - // uploadGravatar := flag.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") + uploadGravatar := flag.Bool("upload-gravatar", false, "Upload compressed image to Gravatar") - // custom usage message for flags + // custom usage message for flags flag.Usage = func() { fmt.Println("Usage: gitfit -input -output -maxsize " + - "-format -quality <0-100> -v [for verbose logging]") - fmt.Println("Example: gitfit -input input.jpeg -output output.jpeg -maxsize 1000000 -format jpeg -quality 85 -v") + "-format -quality <0-100> -v [for verbose logging] -upload-gravatar [to upload to Gravatar]") + fmt.Println("Example: gitfit -input input.jpeg -output output.jpeg -maxsize 1000000 -format jpeg -quality 85 -v -upload-gravatar") fmt.Println("Flags:") flag.PrintDefaults() } flag.Parse() - // Config struct populated with flag values - return &Config { - InputPath: *inputPath, - OutputPath: *outputPath, - MaxSize: *maxSize, - OutputFormat: *outputFormat, - Quality: *quality, - Verbose: *verbose, + // Config struct populated with flag values + return &Config{ + InputPath: *inputPath, + OutputPath: *outputPath, + MaxSize: *maxSize, + OutputFormat: *outputFormat, + Quality: *quality, + Verbose: *verbose, + UploadGravatar: *uploadGravatar, } } // validateConfig() - perform validations and sets defaults and returns if usage should be shown /* cfg (*Config) - configuration to validate */ func validateConfig(cfg *Config) (bool, error) { - // check if input and/or output path is missing - if cfg.InputPath == "" || cfg.OutputPath == "" { - // assume user knows about both flags if one is given + // check if input and/or output path is missing + if cfg.InputPath == "" || cfg.OutputPath == "" { + // assume user knows about both flags if one is given if cfg.InputPath == "" && cfg.OutputPath == "" { return true, nil } @@ -97,51 +99,37 @@ func validateConfig(cfg *Config) (bool, error) { return false, fmt.Errorf("input file %s does not exist", cfg.InputPath) } - // set default output format based on input file extension if not provided + // set default output format based on input file extension if not provided if cfg.OutputFormat == "" { extension := strings.ToLower(filepath.Ext(cfg.InputPath)) switch extension { + case ".jpg", ".jpeg": + cfg.OutputFormat = "jpeg" case ".png": cfg.OutputFormat = "png" case ".gif": cfg.OutputFormat = "gif" default: - cfg.OutputFormat = "jpeg" + return false, fmt.Errorf("unsupported input file extension: %s. Please specify format explicitly", extension) } } - // append appropriate file extension to output path if missing - if filepath.Ext(cfg.OutputPath) == "" { - cfg.OutputPath = cfg.OutputPath + "." + cfg.OutputFormat + if cfg.MaxSize <= 0 { + return false, fmt.Errorf("max size must be greater than 0") } if cfg.Quality <= 0 || cfg.Quality > 100 { return false, fmt.Errorf("value for -quality must be between 1 and 100 inclusive") } - if cfg.Verbose && cfg.Quality == 85 { - fmt.Println("Using default quality of 85 for JPEG compression.") - } - - if cfg.Verbose { - fmt.Printf("Input file: %s\nOutput file: %s\nMaximum size: %d\nOutput format: %s\n", - cfg.InputPath, cfg.OutputPath, cfg.MaxSize, cfg.OutputFormat) - - if cfg.OutputFormat == "jpeg" { - fmt.Println("Quality:", cfg.Quality) - } - } - return false, nil } -// runCompress() - call the compressor with the provided Config -/* cfg (*Config) - configuration for compression */ -// runCompress() - call the compressor with the provided Config +// runCompress() - call the compressor with the provided Config and returns any errors /* cfg (*Config) - configuration for compression */ func runCompress(cfg *Config) error { err := compressor.CompressImage(cfg.InputPath, cfg.OutputPath, cfg.MaxSize, -cfg.OutputFormat, cfg.Quality, cfg.Verbose) + cfg.OutputFormat, cfg.Quality, cfg.Verbose) if err != nil { return err } @@ -151,7 +139,7 @@ cfg.OutputFormat, cfg.Quality, cfg.Verbose) fmt.Println("Uploading to Gravatar...") } - // Load OAuth credentials from environment + // load OAuth credentials from environment clientID := os.Getenv("GRAVATAR_CLIENT_ID") clientSecret := os.Getenv("GRAVATAR_CLIENT_SECRET") redirectURI := os.Getenv("GRAVATAR_REDIRECT_URI") @@ -160,15 +148,15 @@ cfg.OutputFormat, cfg.Quality, cfg.Verbose) return fmt.Errorf("GRAVATAR_CLIENT_ID and GRAVATAR_CLIENT_SECRET environment variables must be set") } - // Use default redirect URI if not specified + // use default redirect URI if not specified if redirectURI == "" { redirectURI = "http://localhost:8080/callback" } - // Create OAuth client + // create OAuth client client := gravatar.NewClient(clientID, clientSecret, redirectURI, cfg.Verbose) - // Perform OAuth authentication + // perform OAuth authentication if cfg.Verbose { fmt.Println("Starting OAuth authentication...") fmt.Println("Your browser will open for authorization.") @@ -178,7 +166,7 @@ cfg.OutputFormat, cfg.Quality, cfg.Verbose) return fmt.Errorf("OAuth authentication failed: %v", err) } - // Upload avatar + // upload avatar if err := client.UploadAvatar(cfg.OutputPath); err != nil { return fmt.Errorf("failed to upload to Gravatar: %v", err) } diff --git a/internal/gravatar/crop.go b/internal/gravatar/crop.go new file mode 100644 index 0000000..7ec9b0c --- /dev/null +++ b/internal/gravatar/crop.go @@ -0,0 +1,83 @@ +package gravatar + +import ( + "fmt" + "image" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" +) + +// cropToSquare - takes an image path and creates a square version by center-cropping +// returns the path to the cropped image +func cropToSquare(imagePath string) (string, error) { + // Open the image + file, err := os.Open(imagePath) + if err != nil { + return "", fmt.Errorf("failed to open image: %v", err) + } + defer file.Close() + + // Decode the image + img, format, err := image.Decode(file) + if err != nil { + return "", fmt.Errorf("failed to decode image: %v", err) + } + + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // If already square, return original path + if width == height { + return imagePath, nil + } + + // Determine square size (use smaller dimension) + size := width + if height < width { + size = height + } + + // Calculate crop offsets to center the crop + xOffset := (width - size) / 2 + yOffset := (height - size) / 2 + + // Create cropped image + cropped := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + cropped.Set(x, y, img.At(x+xOffset, y+yOffset)) + } + } + + // Create output path + ext := filepath.Ext(imagePath) + base := strings.TrimSuffix(imagePath, ext) + croppedPath := base + "_square" + ext + + // Save cropped image + outFile, err := os.Create(croppedPath) + if err != nil { + return "", fmt.Errorf("failed to create output file: %v", err) + } + defer outFile.Close() + + // Encode based on format + switch format { + case "jpeg", "jpg": + err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 95}) + case "png": + err = png.Encode(outFile, cropped) + default: + err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 95}) + } + + if err != nil { + return "", fmt.Errorf("failed to encode cropped image: %v", err) + } + + return croppedPath, nil +} diff --git a/internal/gravatar/gravatar.go b/internal/gravatar/gravatar.go index ebed75b..dd70b9d 100644 --- a/internal/gravatar/gravatar.go +++ b/internal/gravatar/gravatar.go @@ -51,8 +51,19 @@ func (c *Client) UploadAvatar(imagePath string) error { return fmt.Errorf("not authenticated - call Authenticate() first") } + // Gravatar requires square images - crop if necessary + squareImagePath, err := cropToSquare(imagePath) + if err != nil { + return fmt.Errorf("failed to crop image to square: %v", err) + } + + // Clean up temporary square image if it's different from original + if squareImagePath != imagePath { + defer os.Remove(squareImagePath) + } + // Open the image file - file, err := os.Open(imagePath) + file, err := os.Open(squareImagePath) if err != nil { return fmt.Errorf("failed to open image file: %v", err) } @@ -62,8 +73,8 @@ func (c *Client) UploadAvatar(imagePath string) error { var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) - // Add the file to the form - part, err := writer.CreateFormFile("file", filepath.Base(imagePath)) + // Add the file to the form with field name "image" + part, err := writer.CreateFormFile("image", filepath.Base(imagePath)) if err != nil { return fmt.Errorf("failed to create form file: %v", err) } @@ -78,9 +89,9 @@ func (c *Client) UploadAvatar(imagePath string) error { return fmt.Errorf("failed to close multipart writer: %v", err) } - // Create the HTTP request - url := apiBaseURL + "/me/avatars" - req, err := http.NewRequest("POST", url, &requestBody) + // Create the HTTP request with select_avatar parameter + uploadURL := apiBaseURL + "/me/avatars?select_avatar=true" + req, err := http.NewRequest("POST", uploadURL, &requestBody) if err != nil { return fmt.Errorf("failed to create request: %v", err) } From e89ee0695f89c62cd176269c0ce7a90c1024df83 Mon Sep 17 00:00:00 2001 From: Nabil Adem Date: Sun, 30 Nov 2025 19:51:50 -0600 Subject: [PATCH 3/3] Implemented Gravatar OAuth flow --- cmd/gitfit/main.go | 4 +- cmd/gitfit/main_test.go | 178 ++++++++++++++--------------- internal/gravatar/crop.go | 23 ++-- internal/gravatar/gravatar.go | 41 ++++--- internal/gravatar/gravatar_test.go | 57 +++++---- internal/gravatar/oauth.go | 54 +++++---- 6 files changed, 191 insertions(+), 166 deletions(-) diff --git a/cmd/gitfit/main.go b/cmd/gitfit/main.go index cdbbbeb..a1967bb 100644 --- a/cmd/gitfit/main.go +++ b/cmd/gitfit/main.go @@ -82,7 +82,7 @@ func parseFlags() *Config { } } -// validateConfig() - perform validations and sets defaults and returns if usage should be shown +// validateConfig() - perform validations and sets defaults, returns if usage should be shown /* cfg (*Config) - configuration to validate */ func validateConfig(cfg *Config) (bool, error) { // check if input and/or output path is missing @@ -125,7 +125,7 @@ func validateConfig(cfg *Config) (bool, error) { return false, nil } -// runCompress() - call the compressor with the provided Config and returns any errors +// runCompress() - call the compressor with the provided Config /* cfg (*Config) - configuration for compression */ func runCompress(cfg *Config) error { err := compressor.CompressImage(cfg.InputPath, cfg.OutputPath, cfg.MaxSize, diff --git a/cmd/gitfit/main_test.go b/cmd/gitfit/main_test.go index fc05af7..ab5a1e3 100644 --- a/cmd/gitfit/main_test.go +++ b/cmd/gitfit/main_test.go @@ -1,130 +1,120 @@ package main import ( - "image" - "image/color" - "image/jpeg" - "os" - "path/filepath" - "testing" + "image" + "image/color" + "image/jpeg" + "os" + "path/filepath" + "testing" ) // helper to create a small JPEG file for tests func writeTestJPEG(path string, w, h, quality int) error { - img := image.NewRGBA(image.Rect(0, 0, w, h)) - col := color.RGBA{R: 180, G: 120, B: 80, A: 255} + img := image.NewRGBA(image.Rect(0, 0, w, h)) + col := color.RGBA{R: 180, G: 120, B: 80, A: 255} // fill with solid color - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - img.Set(x, y, col) - } - } - - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - return jpeg.Encode(f, img, &jpeg.Options{Quality: quality}) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, col) + } + } + + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + return jpeg.Encode(f, img, &jpeg.Options{Quality: quality}) } // TestValidateConfig_ShowUsageWhenEmpty() - test that validateConfig signals to show usage when config is empty -/* t (*testing.T) - testing object */ func TestValidateConfig_ShowUsageWhenEmpty(t *testing.T) { - cfg := &Config{} - show, err := validateConfig(cfg) - if !show || err != nil { - t.Fatalf("expected showUsage=true and err=nil for empty cfg; got show=%v err=%v", show, err) - } + cfg := &Config{} + show, err := validateConfig(cfg) + if !show || err != nil { + t.Fatalf("expected showUsage=true and err=nil for empty cfg; got show=%v err=%v", show, err) + } } // TestValidateConfig_MissingOneFlag() - test that validateConfig returns error when one of input/output is missing -/* t (*testing.T) - testing object */ func TestValidateConfig_MissingOneFlag(t *testing.T) { - cfg := &Config{OutputPath: "out.jpg"} - show, err := validateConfig(cfg) - if show || err == nil { - t.Fatalf("expected showUsage=false and err!=nil when one of input/output is missing; got show=%v err=%v", show, err) - } + cfg := &Config{OutputPath: "out.jpg"} + show, err := validateConfig(cfg) + if show || err == nil { + t.Fatalf("expected showUsage=false and err!=nil when one of input/output is missing; got show=%v err=%v", show, err) + } } // TestValidateConfig_InputFileDoesNotExist() - test that validateConfig returns error when input file does not exist -/* t (*testing.T) - testing object */ func TestValidateConfig_InputFileDoesNotExist(t *testing.T) { - cfg := &Config{InputPath: "no-such-file-xyz.jpg", OutputPath: "out.jpg"} - _, err := validateConfig(cfg) - if err == nil { - t.Fatalf("expected error for non-existent input file") - } + cfg := &Config{InputPath: "no-such-file-xyz.jpg", OutputPath: "out.jpg"} + _, err := validateConfig(cfg) + if err == nil { + t.Fatalf("expected error for non-existent input file") + } } // TestValidateConfig_DefaultFormatAndExtension() - test that validateConfig sets default output format and appends extension -/* t (*testing.T) - testing object */ func TestValidateConfig_DefaultFormatAndExtension(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.png") - - // create empty file to satisfy Stat - if err := os.WriteFile(in, []byte(""), 0644); err != nil { - t.Fatalf("failed to create input file: %v", err) - } - - out := filepath.Join(td, "out") - cfg := &Config{InputPath: in, OutputPath: out, OutputFormat: "", Quality: 85} - show, err := validateConfig(cfg) - if show || err != nil { - t.Fatalf("unexpected validateConfig result: show=%v err=%v", show, err) - } - - if cfg.OutputFormat != "png" { - t.Fatalf("expected OutputFormat=png, got %s", cfg.OutputFormat) - } - - if filepath.Ext(cfg.OutputPath) != ".png" { - t.Fatalf("expected output path to have .png extension; got %s", cfg.OutputPath) - } + td := t.TempDir() + in := filepath.Join(td, "in.png") + + // create empty file to satisfy Stat + if err := os.WriteFile(in, []byte(""), 0644); err != nil { + t.Fatalf("failed to create input file: %v", err) + } + + out := filepath.Join(td, "out") + cfg := &Config{InputPath: in, OutputPath: out, OutputFormat: "", MaxSize: 1048576, Quality: 85} + show, err := validateConfig(cfg) + if show || err != nil { + t.Fatalf("unexpected validateConfig result: show=%v err=%v", show, err) + } + + if cfg.OutputFormat != "png" { + t.Fatalf("expected OutputFormat=png, got %s", cfg.OutputFormat) + } } // TestValidateConfig_QualityRange() - test that validateConfig returns error for invalid quality range -/* t (*testing.T) - testing object */ func TestValidateConfig_QualityRange(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.jpg") - if err := writeTestJPEG(in, 20, 20, 80); err != nil { - t.Fatalf("failed to write in.jpg: %v", err) - } - - cfg := &Config{InputPath: in, OutputPath: filepath.Join(td, "out.jpg"), Quality: 0} - _, err := validateConfig(cfg) - if err == nil { - t.Fatalf("expected error for invalid quality range") - } + td := t.TempDir() + in := filepath.Join(td, "in.jpg") + if err := writeTestJPEG(in, 20, 20, 80); err != nil { + t.Fatalf("failed to write in.jpg: %v", err) + } + + cfg := &Config{InputPath: in, OutputPath: filepath.Join(td, "out.jpg"), Quality: 0} + _, err := validateConfig(cfg) + if err == nil { + t.Fatalf("expected error for invalid quality range") + } } // TestRunCompress_EndToEnd() - end-to-end test of runCompress() -/* t (*testing.T) - testing object */ func TestRunCompress_EndToEnd(t *testing.T) { - td := t.TempDir() - in := filepath.Join(td, "in.jpg") - out := filepath.Join(td, "out.jpg") - if err := writeTestJPEG(in, 320, 240, 100); err != nil { - t.Fatalf("failed to write test jpeg: %v", err) - } + td := t.TempDir() + in := filepath.Join(td, "in.jpg") + out := filepath.Join(td, "out.jpg") + if err := writeTestJPEG(in, 320, 240, 100); err != nil { + t.Fatalf("failed to write test jpeg: %v", err) + } // run compression - cfg := &Config{InputPath: in, OutputPath: out, MaxSize: 5 * 1024 * 1024, OutputFormat: "jpeg", Quality: 90, Verbose: false} - if err := runCompress(cfg); err != nil { - t.Fatalf("runCompress failed: %v", err) - } - - fi, err := os.Stat(out) - if err != nil { - t.Fatalf("expected output file, got error: %v", err) - } - - if fi.Size() == 0 { - t.Fatalf("output file is empty") - } + cfg := &Config{InputPath: in, OutputPath: out, MaxSize: 5 * 1024 * 1024, OutputFormat: "jpeg", Quality: 90, Verbose: false} + if err := runCompress(cfg); err != nil { + t.Fatalf("runCompress failed: %v", err) + } + + fi, err := os.Stat(out) + if err != nil { + t.Fatalf("expected output file, got error: %v", err) + } + + if fi.Size() == 0 { + t.Fatalf("output file is empty") + } } diff --git a/internal/gravatar/crop.go b/internal/gravatar/crop.go index 7ec9b0c..12d28c0 100644 --- a/internal/gravatar/crop.go +++ b/internal/gravatar/crop.go @@ -10,17 +10,15 @@ import ( "strings" ) -// cropToSquare - takes an image path and creates a square version by center-cropping -// returns the path to the cropped image +// cropToSquare() - takes an image path and creates a square version by center-cropping, returns the path to the cropped image +// imagePath (string) - path to the image to crop func cropToSquare(imagePath string) (string, error) { - // Open the image file, err := os.Open(imagePath) if err != nil { return "", fmt.Errorf("failed to open image: %v", err) } defer file.Close() - // Decode the image img, format, err := image.Decode(file) if err != nil { return "", fmt.Errorf("failed to decode image: %v", err) @@ -30,22 +28,22 @@ func cropToSquare(imagePath string) (string, error) { width := bounds.Dx() height := bounds.Dy() - // If already square, return original path + // if already square, return original path if width == height { return imagePath, nil } - // Determine square size (use smaller dimension) + // determine square size (use smaller dimension) size := width if height < width { size = height } - // Calculate crop offsets to center the crop + // calculate crop offsets to center the crop xOffset := (width - size) / 2 yOffset := (height - size) / 2 - // Create cropped image + // create cropped image cropped := image.NewRGBA(image.Rect(0, 0, size, size)) for y := 0; y < size; y++ { for x := 0; x < size; x++ { @@ -53,26 +51,25 @@ func cropToSquare(imagePath string) (string, error) { } } - // Create output path + // create output path ext := filepath.Ext(imagePath) base := strings.TrimSuffix(imagePath, ext) croppedPath := base + "_square" + ext - // Save cropped image outFile, err := os.Create(croppedPath) if err != nil { return "", fmt.Errorf("failed to create output file: %v", err) } defer outFile.Close() - // Encode based on format + // encode based on format, 100 is used because the image is already compressed and Gravatar might already have compressed it switch format { case "jpeg", "jpg": - err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 95}) + err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 100}) case "png": err = png.Encode(outFile, cropped) default: - err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 95}) + err = jpeg.Encode(outFile, cropped, &jpeg.Options{Quality: 100}) } if err != nil { diff --git a/internal/gravatar/gravatar.go b/internal/gravatar/gravatar.go index dd70b9d..67144d1 100644 --- a/internal/gravatar/gravatar.go +++ b/internal/gravatar/gravatar.go @@ -22,7 +22,11 @@ type Client struct { Verbose bool } -// NewClient creates a new Gravatar client with OAuth credentials +// NewClient() - creates a new Gravatar client with OAuth credentials +/* clientID (string) - client ID for OAuth authentication + clientSecret (string) - client secret for OAuth authentication + redirectURI (string) - redirect URI for OAuth authentication + verbose (bool) - enable verbose logging */ func NewClient(clientID, clientSecret, redirectURI string, verbose bool) *Client { return &Client{ ClientID: clientID, @@ -32,7 +36,8 @@ func NewClient(clientID, clientSecret, redirectURI string, verbose bool) *Client } } -// Authenticate performs OAuth flow and obtains an access token +// Authenticate() - performs OAuth flow and obtains an access token +// c (*Client) - Gravatar client to authenticate func (c *Client) Authenticate() error { oauth := NewOAuthConfig(c.ClientID, c.ClientSecret, c.RedirectURI) @@ -45,36 +50,48 @@ func (c *Client) Authenticate() error { return nil } -// UploadAvatar uploads an image to Gravatar using the REST API +// UploadAvatar() - uploads an image to Gravatar using the REST API +/* c (*Client) - Gravatar client to upload avatar + imagePath (string) - path to the image to upload */ func (c *Client) UploadAvatar(imagePath string) error { if c.AccessToken == "" { return fmt.Errorf("not authenticated - call Authenticate() first") } // Gravatar requires square images - crop if necessary + if c.Verbose { + fmt.Println("Checking if image needs to be cropped to square...") + } + squareImagePath, err := cropToSquare(imagePath) if err != nil { return fmt.Errorf("failed to crop image to square: %v", err) } - // Clean up temporary square image if it's different from original + if c.Verbose { + if squareImagePath != imagePath { + fmt.Printf("Cropped image to square: %s\n", squareImagePath) + } else { + fmt.Println("Image is already square, no cropping needed") + } + } + + // clean up temporary square image if it's different from original if squareImagePath != imagePath { defer os.Remove(squareImagePath) } - // Open the image file file, err := os.Open(squareImagePath) if err != nil { return fmt.Errorf("failed to open image file: %v", err) } defer file.Close() - // Create multipart form data + // create multipart form data var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) - // Add the file to the form with field name "image" - part, err := writer.CreateFormFile("image", filepath.Base(imagePath)) + part, err := writer.CreateFormFile("image", filepath.Base(squareImagePath)) if err != nil { return fmt.Errorf("failed to create form file: %v", err) } @@ -89,18 +106,17 @@ func (c *Client) UploadAvatar(imagePath string) error { return fmt.Errorf("failed to close multipart writer: %v", err) } - // Create the HTTP request with select_avatar parameter + // create the HTTP request with select_avatar parameter uploadURL := apiBaseURL + "/me/avatars?select_avatar=true" req, err := http.NewRequest("POST", uploadURL, &requestBody) if err != nil { return fmt.Errorf("failed to create request: %v", err) } - // Set headers + // set headers req.Header.Set("Authorization", "Bearer "+c.AccessToken) req.Header.Set("Content-Type", writer.FormDataContentType()) - // Send the request client := &http.Client{} resp, err := client.Do(req) if err != nil { @@ -108,13 +124,12 @@ func (c *Client) UploadAvatar(imagePath string) error { } defer resp.Body.Close() - // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %v", err) } - // Check response status + // check response status if resp.StatusCode < 200 || resp.StatusCode >= 300 { var errorResp map[string]interface{} json.Unmarshal(body, &errorResp) diff --git a/internal/gravatar/gravatar_test.go b/internal/gravatar/gravatar_test.go index da585d2..351b4b3 100644 --- a/internal/gravatar/gravatar_test.go +++ b/internal/gravatar/gravatar_test.go @@ -1,6 +1,9 @@ package gravatar import ( + "image" + "image/color" + "image/jpeg" "io" "net/http" "net/http/httptest" @@ -9,51 +12,64 @@ import ( "testing" ) +// TestUploadAvatar() - tests the UploadAvatar() function +/* t (*testing.T) - test context */ func TestUploadAvatar(t *testing.T) { - // Create a temporary image file tmpFile, err := os.CreateTemp("", "test-image-*.jpg") if err != nil { - t.Fatalf("Failed to create temp file: %v", err) + t.Fatalf("failed to create temp file: %v", err) } defer os.Remove(tmpFile.Name()) - content := []byte("fake image content") - if _, err := tmpFile.Write(content); err != nil { - t.Fatalf("Failed to write to temp file: %v", err) + // write a real square JPEG image (100x100) to temp file for testing + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + col := color.RGBA{R: 255, G: 0, B: 0, A: 255} + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + img.Set(x, y, col) + } + } + + if err := jpeg.Encode(tmpFile, img, &jpeg.Options{Quality: 90}); err != nil { + t.Fatalf("failed to write JPEG to temp file: %v", err) } tmpFile.Close() - // Mock Gravatar REST API server + content, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("failed to read back temp file: %v", err) + } + + // mock Gravatar REST API server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) + t.Errorf("expected POST request, got %s", r.Method) } - // Check authorization header + // check authorization header authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - t.Errorf("Missing or invalid Authorization header: %s", authHeader) + t.Errorf("missing or invalid Authorization header: %s", authHeader) } if authHeader != "Bearer test-access-token" { - t.Errorf("Wrong access token in header: %s", authHeader) + t.Errorf("wrong access token in header: %s", authHeader) } - // Check content type contentType := r.Header.Get("Content-Type") if !strings.Contains(contentType, "multipart/form-data") { - t.Errorf("Wrong content type: %s", contentType) + t.Errorf("wrong content type: %s", contentType) } - // Parse multipart form + // parse multipart form err := r.ParseMultipartForm(10 << 20) // 10 MB if err != nil { - t.Errorf("Failed to parse multipart form: %v", err) + t.Errorf("failed to parse multipart form: %v", err) } - file, _, err := r.FormFile("file") + file, _, err := r.FormFile("image") if err != nil { - t.Errorf("Failed to get file from form: %v", err) + t.Errorf("failed to get file from form: %v", err) http.Error(w, "Bad Request", http.StatusBadRequest) return } @@ -61,25 +77,24 @@ func TestUploadAvatar(t *testing.T) { fileContent, _ := io.ReadAll(file) if string(fileContent) != string(content) { - t.Errorf("File content mismatch") + t.Errorf("file content mismatch") } - // Return success response w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) })) defer server.Close() - // Override API base URL for testing + // override API base URL for testing originalURL := apiBaseURL apiBaseURL = server.URL defer func() { apiBaseURL = originalURL }() + // manually set access token for testing (skip OAuth flow) client := NewClient("test-client-id", "test-client-secret", "http://localhost:8080/callback", false) - // Manually set access token for testing (skip OAuth flow) client.AccessToken = "test-access-token" if err := client.UploadAvatar(tmpFile.Name()); err != nil { - t.Errorf("UploadAvatar failed: %v", err) + t.Errorf("uploadAvatar failed: %v", err) } } diff --git a/internal/gravatar/oauth.go b/internal/gravatar/oauth.go index 8595d50..0cc62cb 100644 --- a/internal/gravatar/oauth.go +++ b/internal/gravatar/oauth.go @@ -39,7 +39,10 @@ type TokenResponse struct { BlogURL string `json:"blog_url"` } -// NewOAuthConfig creates a new OAuth configuration +// NewOAuthConfig() - creates a new OAuth configuration +/* clientID (string) - client ID for OAuth authentication + clientSecret (string) - client secret for OAuth authentication + redirectURI (string) - redirect URI for OAuth authentication */ func NewOAuthConfig(clientID, clientSecret, redirectURI string) *OAuthConfig { return &OAuthConfig{ ClientID: clientID, @@ -51,26 +54,26 @@ func NewOAuthConfig(clientID, clientSecret, redirectURI string) *OAuthConfig { } } -// StartOAuthFlow initiates the OAuth flow and returns an access token +// StartOAuthFlow() - initiates the OAuth flow and returns an access token +// verbose (bool) - enable verbose logging func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { - // Generate random state for CSRF protection + // generate random state for CSRF protection c.state = generateRandomState() - // Start local server + // start local server server := &http.Server{Addr: ":8080"} http.HandleFunc("/callback", c.callbackHandler) - // Start server in background + // start server in background go func() { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { c.errChan <- fmt.Errorf("failed to start callback server: %v", err) } }() - - // Give server time to start + time.Sleep(100 * time.Millisecond) - // Build authorization URL + // build authorization URL authURL := c.buildAuthURL() if verbose { @@ -78,17 +81,17 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { fmt.Printf("If browser doesn't open, visit: %s\n", authURL) } - // Open browser + // open browser if err := openBrowser(authURL); err != nil { fmt.Printf("Failed to open browser automatically: %v\n", err) fmt.Printf("Please visit this URL manually: %s\n", authURL) } - // Wait for callback or timeout + // wait for callback or timeout var code string select { case code = <-c.codeChan: - // Got authorization code + // got authorization code case err := <-c.errChan: server.Shutdown(context.Background()) return "", err @@ -97,14 +100,14 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { return "", fmt.Errorf("authorization timeout after 5 minutes") } - // Shutdown server + // shutdown server server.Shutdown(context.Background()) if verbose { fmt.Println("Authorization successful! Exchanging code for token...") } - // Exchange code for token + // exchange code for token token, err := c.exchangeCodeForToken(code) if err != nil { return "", fmt.Errorf("failed to exchange code for token: %v", err) @@ -113,7 +116,8 @@ func (c *OAuthConfig) StartOAuthFlow(verbose bool) (string, error) { return token, nil } -// buildAuthURL constructs the OAuth authorization URL +// buildAuthURL() - constructs the OAuth authorization URL +//c (*OAuthConfig) - OAuth configuration func (c *OAuthConfig) buildAuthURL() string { params := url.Values{} params.Set("client_id", c.ClientID) @@ -125,11 +129,13 @@ func (c *OAuthConfig) buildAuthURL() string { return authorizationEndpoint + "?" + params.Encode() } -// callbackHandler handles the OAuth callback +// callbackHandler() - handles the OAuth callback +/* w (*http.ResponseWriter) - HTTP response writer + r (*http.Request) - HTTP request */ func (c *OAuthConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() - // Check for errors + // check for errors if errMsg := query.Get("error"); errMsg != "" { c.errChan <- fmt.Errorf("authorization denied: %s", errMsg) w.WriteHeader(http.StatusBadRequest) @@ -137,7 +143,7 @@ func (c *OAuthConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { return } - // Verify state + // verify state state := query.Get("state") if state != c.state { c.errChan <- fmt.Errorf("invalid state parameter (CSRF protection)") @@ -146,7 +152,7 @@ func (c *OAuthConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { return } - // Get authorization code + // get authorization code code := query.Get("code") if code == "" { c.errChan <- fmt.Errorf("no authorization code received") @@ -155,15 +161,16 @@ func (c *OAuthConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { return } - // Send success response to user + // send success response to user w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "

Success!

Authorization successful. You can close this window and return to the terminal.

") - // Send code to main flow + // send code to main flow c.codeChan <- code } -// exchangeCodeForToken exchanges the authorization code for an access token +// exchangeCodeForToken() - exchanges the authorization code for an access token +// code (string) - authorization code to exchange for access token func (c *OAuthConfig) exchangeCodeForToken(code string) (string, error) { data := url.Values{} data.Set("client_id", c.ClientID) @@ -202,7 +209,8 @@ func (c *OAuthConfig) exchangeCodeForToken(code string) (string, error) { return tokenResp.AccessToken, nil } -// openBrowser opens the default browser to the specified URL +// openBrowser() - opens the default browser to the specified URL +// url (string) - URL to open in the browser func openBrowser(url string) error { var cmd string var args []string @@ -224,7 +232,7 @@ func openBrowser(url string) error { return exec.Command(cmd, args...).Start() } -// generateRandomState generates a random state string for CSRF protection +// generateRandomState() - generates a random state string for CSRF protection func generateRandomState() string { b := make([]byte, 16) rand.Read(b)