generated from habedi/template-go-project
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
23 changed files
with
2,006 additions
and
94 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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](https://img.shields.io/github/release/habedi/template-go-project.svg?style=flat-square)](https://github.com/habedi/template-go-project/releases/latest)) | ||
[//]: # ([![Release](https://img.shields.io/github/release/habedi/gogg.svg?style=flat-square)](https://github.com/habedi/gogg/releases/latest)) | ||
|
||
[//]: # ([![License](https://img.shields.io/github/license/habedi/template-go-project)](https://github.com/habedi/template-go-project/blob/main/LICENSE)) | ||
[//]: # ([![License](https://img.shields.io/github/license/habedi/gogg)](https://github.com/habedi/gogg/blob/main/LICENSE)) | ||
|
||
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(¤tURL).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) | ||
} |
Oops, something went wrong.