Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
habedi committed Jan 15, 2025
1 parent abf243c commit 45a7e6d
Show file tree
Hide file tree
Showing 23 changed files with 2,006 additions and 94 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,5 @@ poetry.lock
# Add any additional file patterns a directory names that should be ignored down here
*.log
bin/
download/
catalogue.csv
37 changes: 19 additions & 18 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,43 +1,44 @@
# Declare PHONY targets
.PHONY: test test-cover format build clean
.PHONY: build format test test-cover clean run

# Variables
PKG = github.com/habedi/template-go-project
PKG = github.com/habedi/gogg
BINARY = bin/$(notdir $(PKG))
COVER_PROFILE = cover.out
GO_FILES = $(shell find . -type f -name '*.go')
COVER_FLAGS = --cover --coverprofile=$(COVER_PROFILE)

# Test with coverage and view HTML report
test-cover: format
@echo "Running tests with coverage..."
go test -v ./... --race $(COVER_FLAGS)
go tool cover -html=$(COVER_PROFILE)
# Building the project
build: format
@echo "Tidying dependencies..."
go mod tidy
@echo "Building the project..."
go build -o $(BINARY)

# Format Go files
# Formatting Go files
format:
@echo "Formatting Go files..."
go fmt ./...

# Run tests
# Running tests
test: format
@echo "Running tests..."
go test -v ./... --race

# Build the project's executable
build: format
@echo "Tidying dependencies..."
go mod tidy
@echo "Building the project..."
go build -o bin/$(notdir $(PKG))
test-cover: format
@echo "Running tests with coverage..."
go test -v ./... --race $(COVER_FLAGS)
@echo "Generating HTML coverage report..."
go tool cover -html=$(COVER_PROFILE)

# Clean temporary and output files
# Cleaning generated and temporary files
clean:
@echo "Cleaning up..."
find . -type f -name '*.got.*' -delete
find . -type f -name '*.out' -delete
rm -rf bin/

# Run the built executable
# Running the built executable
run: build
@echo "Running the project..."
./bin/$(notdir $(PKG))
./$(BINARY)
22 changes: 8 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
# A Template Repository for Go Projects
# Gogg: A Downloader for GOG

<img src="assets/logo-v1.svg" align="right" width="25%"/>

[![Tests](https://github.com/habedi/template-go-project/actions/workflows/tests.yml/badge.svg)](https://github.com/habedi/template-go-project/actions/workflows/tests.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/habedi/template-go-project)](https://goreportcard.com/report/github.com/habedi/template-go-project)
[![Go Reference](https://pkg.go.dev/badge/github.com/habedi/template-go-project.svg)](https://pkg.go.dev/github.com/habedi/template-go-project)
[![Tests](https://github.com/habedi/gogg/actions/workflows/tests.yml/badge.svg)](https://github.com/habedi/gogg/actions/workflows/tests.yml)
[![Go Report Card](https://goreportcard.com/badge/github.com/habedi/gogg)](https://goreportcard.com/report/github.com/habedi/gogg)
[![Go Reference](https://pkg.go.dev/badge/github.com/habedi/gogg.svg)](https://pkg.go.dev/github.com/habedi/gogg)

[//]: # ([![Release]&#40;https://img.shields.io/github/release/habedi/template-go-project.svg?style=flat-square&#41;]&#40;https://github.com/habedi/template-go-project/releases/latest&#41;)
[//]: # ([![Release]&#40;https://img.shields.io/github/release/habedi/gogg.svg?style=flat-square&#41;]&#40;https://github.com/habedi/gogg/releases/latest&#41;)

[//]: # ([![License]&#40;https://img.shields.io/github/license/habedi/template-go-project&#41;]&#40;https://github.com/habedi/template-go-project/blob/main/LICENSE&#41;)
[//]: # ([![License]&#40;https://img.shields.io/github/license/habedi/gogg&#41;]&#40;https://github.com/habedi/gogg/blob/main/LICENSE&#41;)

This is a template repository with a minimalistic structure to make it easier to start a new Go project,
like for developing a console or a web application.

I made this template to provide a starting point for my Go projects and save time.
It is inspired by the recommendations
in [golang-standards/project-layout](https://github.com/golang-standards/project-layout).
I hope it will be useful for others as well.
Gogg is a minimalistic CLI tool for downloading game files from [GOG.com](https://www.gog.com/).

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the [MIT License](LICENSE).
Empty file added assets/workflow.dot
Empty file.
213 changes: 213 additions & 0 deletions client/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package client

import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/chromedp/chromedp"
"github.com/habedi/gogg/db"
"github.com/rs/zerolog/log"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// RefreshToken refreshes the access token using the refresh token.
func RefreshToken(user *db.User) error {
tokenURL := "https://auth.gog.com/token"
query := url.Values{
"client_id": {"46899977096215655"},
"client_secret": {"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"},
"grant_type": {"refresh_token"},
"refresh_token": {user.RefreshToken},
}

resp, err := http.PostForm(tokenURL, query)
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read token response: %w", err)
}

var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}

if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("failed to parse token response: %w", err)
}

user.AccessToken = result.AccessToken
user.RefreshToken = result.RefreshToken
user.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
return db.UpsertUserData(user)
}

// IsTokenValid checks if the access token (stored in the database) is still valid.
func IsTokenValid(user *db.User) bool {
if user.AccessToken == "" || user.ExpiresAt == "" {
return false
}

expiresAt, err := time.Parse(time.RFC3339, user.ExpiresAt)
if err != nil {
log.Error().Err(err).Msg("Invalid expiration time format")
return false
}

return time.Now().Before(expiresAt)
}

// AuthGOG logs in to GOG.com and retrieves an access token for the user.
func AuthGOG(authURL string, user *db.User, headless bool) error {
if IsTokenValid(user) {
log.Info().Msg("Access token is still valid")
return nil
}

if user.RefreshToken != "" {
log.Info().Msg("Refreshing access token")
return RefreshToken(user)
}

ctx, cancel := createChromeContext(headless)
defer cancel()

finalURL, err := performLogin(ctx, authURL, user)
if err != nil {
return fmt.Errorf("failed during automated login: %w", err)
}

code, err := extractAuthCode(finalURL)
if err != nil {
return err
}

token, refreshToken, expiresAt, err := exchangeCodeForToken(code)
if err != nil {
return fmt.Errorf("failed to exchange authorization code for token: %w", err)
}

user.AccessToken = token
user.RefreshToken = refreshToken
user.ExpiresAt = expiresAt
return db.UpsertUserData(user)
}

// createChromeContext creates a ChromeDP context with the specified headless option.
func createChromeContext(headless bool) (context.Context, context.CancelFunc) {
opts := chromedp.DefaultExecAllocatorOptions[:]
if !headless {
opts = append(opts, chromedp.Flag("headless", false), chromedp.Flag("disable-gpu", false),
chromedp.Flag("start-maximized", true))
}

allocatorCtx, cancelAllocator := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancelContext := chromedp.NewContext(allocatorCtx, chromedp.WithLogf(log.Info().Msgf))

return ctx, func() {
cancelContext()
cancelAllocator()
}
}

// performLogin performs the login process using ChromeDP.
func performLogin(ctx context.Context, authURL string, user *db.User) (string, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()

var finalURL string
err := chromedp.Run(timeoutCtx,
chromedp.Navigate(authURL),
chromedp.WaitVisible(`#login_username`, chromedp.ByID),
chromedp.SendKeys(`#login_username`, user.Username, chromedp.ByID),
chromedp.SendKeys(`#login_password`, user.Password, chromedp.ByID),
chromedp.Click(`#login_login`, chromedp.ByID),
chromedp.ActionFunc(func(ctx context.Context) error {
for {
var currentURL string
if err := chromedp.Location(&currentURL).Do(ctx); err != nil {
return err
}
if strings.Contains(currentURL, "on_login_success") && strings.Contains(currentURL, "code=") {
finalURL = currentURL
return nil
}
time.Sleep(500 * time.Millisecond)
}
}),
)
return finalURL, err
}

// extractAuthCode extracts the authorization code from the URL.
func extractAuthCode(authURL string) (string, error) {
parsedURL, err := url.Parse(authURL)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}

code := parsedURL.Query().Get("code")
if code == "" {
return "", errors.New("authorization code not found in URL")
}

return code, nil
}

// exchangeCodeForToken exchanges the authorization code for an access token and a refresh token.
func exchangeCodeForToken(code string) (string, string, string, error) {
tokenURL := "https://auth.gog.com/token"
query := url.Values{
"client_id": {"46899977096215655"},
"client_secret": {"9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"},
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {"https://embed.gog.com/on_login_success?origin=client"},
}

resp, err := http.PostForm(tokenURL, query)
if err != nil {
return "", "", "", fmt.Errorf("failed to exchange code for token: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read token response: %w", err)
}

var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}

if err := json.Unmarshal(body, &result); err != nil {
return "", "", "", fmt.Errorf("failed to parse token response: %w", err)
}

expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
return result.AccessToken, result.RefreshToken, expiresAt, nil
}

// SaveUserCredentials saves the user's credentials in the database.
func SaveUserCredentials(username, password string) error {
user := &db.User{
Username: username,
Password: password,
AccessToken: "",
RefreshToken: "",
ExpiresAt: "",
}
return db.UpsertUserData(user)
}
Loading

0 comments on commit 45a7e6d

Please sign in to comment.