mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
feat: migrate to DragonflyDB and clean up environment configuration
- Replace Redis with DragonflyDB for better performance and memory efficiency - Remove redundant environment variables (POSTGRES_*, ENCRYPTION_KEY, OAUTH_SERVICE_URL) - Consolidate database configuration to use single DB_* variables - Use JWT_SECRET for both JWT tokens and encryption - Remove PORT variable redundancy, use BACKEND_PORT consistently - Clean up docker-compose configurations for dev/prod consistency - Add DragonflyDB configuration with optimized memory usage - Remove redis.conf as it's no longer needed - Update health checks to use Redis-compatible CLI for DragonflyDB - Add missing VITE_API_URL to production frontend - Fix GitHub Actions to use correct go.sum path - Clean up development directories and unused files
This commit is contained in:
+41
-10
@@ -11,16 +11,16 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -60,20 +60,51 @@ type PasswordResetCode struct {
|
||||
|
||||
// JWT Claims structure
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
GitHubID int `json:"github_id,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// getDurationEnv parses duration from environment variable with fallback
|
||||
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// GenerateJWT creates a new JWT token for a user
|
||||
func GenerateJWT(user models.User) (string, error) {
|
||||
return generateJWT(user, "")
|
||||
}
|
||||
|
||||
func GenerateJWTWithGitHubAccessToken(user models.User, accessToken string) (string, error) {
|
||||
return generateJWT(user, accessToken)
|
||||
}
|
||||
|
||||
func generateJWT(user models.User, accessToken string) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
GitHubID: user.GitHubID,
|
||||
AccessToken: accessToken,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "trackeep",
|
||||
},
|
||||
|
||||
+64
-105
@@ -8,13 +8,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
@@ -53,6 +52,8 @@ type GitHubRepo struct {
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
Private bool `json:"private"`
|
||||
Stargazers int `json:"stargazers_count"`
|
||||
Forks int `json:"forks_count"`
|
||||
Watchers int `json:"watchers_count"`
|
||||
@@ -66,6 +67,14 @@ type GitHubRepo struct {
|
||||
|
||||
// GitHubLogin initiates the GitHub OAuth flow
|
||||
func GitHubLogin(c *gin.Context) {
|
||||
frontendRedirect := resolveFrontendRedirectURL(c.Request)
|
||||
callbackURL := buildOAuthCallbackURL(c.Request, frontendRedirect)
|
||||
if oauthServiceURL := getOAuthServiceURL(); oauthServiceURL != "" && callbackURL != "" {
|
||||
redirectURL := fmt.Sprintf("%s/auth/github?redirect_uri=%s", oauthServiceURL, url.QueryEscape(callbackURL))
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
return
|
||||
}
|
||||
|
||||
if githubOAuthConfig == nil {
|
||||
initGitHubOAuth()
|
||||
}
|
||||
@@ -119,55 +128,35 @@ func GitHubCallback(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Get or create user in database
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
var existingUser models.User
|
||||
|
||||
// First try to find by GitHub ID
|
||||
err = db.Where("github_id = ?", user.ID).First(&existingUser).Error
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
existingUser, err := upsertCentralizedOAuthUser(db, centralizedOAuthUser{
|
||||
GitHubID: user.ID,
|
||||
Username: user.Login,
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
// If not found by GitHub ID, try by email
|
||||
err = db.Where("email = ?", user.Email).First(&existingUser).Error
|
||||
if err != nil {
|
||||
// Create new user
|
||||
newUser := models.User{
|
||||
Username: user.Login,
|
||||
Email: user.Email,
|
||||
FullName: user.Name,
|
||||
GitHubID: user.ID,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Provider: "github",
|
||||
}
|
||||
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
existingUser = newUser
|
||||
} else {
|
||||
// Update existing user with GitHub info
|
||||
existingUser.GitHubID = user.ID
|
||||
existingUser.AvatarURL = user.AvatarURL
|
||||
existingUser.Provider = "github"
|
||||
db.Save(&existingUser)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": existingUser.ID,
|
||||
"email": existingUser.Email,
|
||||
"username": existingUser.Username,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||
})
|
||||
|
||||
tokenString, err := jwtToken.SignedString([]byte(config.JWTSecret))
|
||||
tokenString, err := GenerateJWTWithGitHubAccessToken(*existingUser, token.AccessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), tokenString)
|
||||
redirectURL := buildFrontendCallbackRedirectURL("", tokenString)
|
||||
if redirectURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
@@ -259,69 +248,41 @@ func HandleOAuthCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the JWT from the OAuth service
|
||||
claims := jwt.MapClaims{}
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||
// Use the OAuth service's JWT secret (should be shared)
|
||||
return []byte(os.Getenv("OAUTH_JWT_SECRET")), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsedToken.Valid {
|
||||
validationResponse, err := validateCentralizedOAuthToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user information from OAuth service
|
||||
username, _ := claims["username"].(string)
|
||||
email, _ := claims["email"].(string)
|
||||
githubID, _ := claims["github_id"]
|
||||
accessToken, _ := claims["access_token"].(string)
|
||||
|
||||
// Get database
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
|
||||
// Find or create user in local database
|
||||
var user models.User
|
||||
err = db.Where("email = ?", email).First(&user).Error
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
localUser, err := upsertCentralizedOAuthUser(db, validationResponse.User)
|
||||
if err != nil {
|
||||
// Create new user
|
||||
newUser := models.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
GitHubID: int(githubID.(float64)), // JWT numbers are float64
|
||||
Provider: "github",
|
||||
}
|
||||
|
||||
if err := db.Create(&newUser).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
user = newUser
|
||||
} else {
|
||||
// Update existing user with GitHub info
|
||||
user.GitHubID = int(githubID.(float64))
|
||||
user.Provider = "github"
|
||||
db.Save(&user)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate Trackeep JWT token
|
||||
trackeepToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"github_id": user.GitHubID,
|
||||
"access_token": accessToken, // Pass through the GitHub access token
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
||||
})
|
||||
claims, err := parseOAuthTokenClaimsUnverified(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token claims"})
|
||||
return
|
||||
}
|
||||
|
||||
trackeepTokenString, err := trackeepToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
trackeepTokenString, err := GenerateJWTWithGitHubAccessToken(*localUser, getAccessTokenFromOAuthClaims(claims))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with Trackeep token
|
||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), trackeepTokenString)
|
||||
redirectURL := buildFrontendCallbackRedirectURL(c.Query("frontend_redirect"), trackeepTokenString)
|
||||
if redirectURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
@@ -343,7 +304,11 @@ func GetCurrentUserWithGitHub(c *gin.Context) {
|
||||
func GetGitHubRepos(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
|
||||
db := c.MustGet("db").(*gorm.DB)
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
@@ -368,26 +333,20 @@ func GetGitHubRepos(c *gin.Context) {
|
||||
tokenString = authHeader[7:]
|
||||
}
|
||||
|
||||
// Parse the JWT to get the GitHub access token from the centralized OAuth service
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
claims, err := ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract GitHub access token from the OAuth service JWT
|
||||
githubAccessToken, ok := claims["access_token"]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found"})
|
||||
githubAccessToken := strings.TrimSpace(claims.AccessToken)
|
||||
if githubAccessToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found. Please reconnect GitHub."})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch repositories using the GitHub access token
|
||||
repos, err := fetchGitHubRepos(githubAccessToken.(string))
|
||||
repos, err := fetchGitHubRepos(githubAccessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var githubRepoFullNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
||||
|
||||
type gitHubInstallationReposResponse struct {
|
||||
Repositories []GitHubRepo `json:"repositories"`
|
||||
}
|
||||
|
||||
type gitHubAppInstallationDetails struct {
|
||||
ID int64 `json:"id"`
|
||||
Account struct {
|
||||
Login string `json:"login"`
|
||||
Type string `json:"type"`
|
||||
} `json:"account"`
|
||||
}
|
||||
|
||||
type gitHubInstallationTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type gitHubBackupRequest struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type gitHubBackupResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Status string `json:"status"`
|
||||
LocalPath string `json:"local_path"`
|
||||
Source string `json:"source"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GetGitHubAppStatus returns install/configuration status for GitHub App integration.
|
||||
func GetGitHubAppStatus(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
response := gin.H{
|
||||
"app_slug": getGitHubAppSlug(),
|
||||
"install_enabled": isGitHubAppInstallEnabled(),
|
||||
"credentials_configured": hasGitHubAppCredentials(),
|
||||
"installed": false,
|
||||
}
|
||||
|
||||
if db == nil {
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
installation, err := getUserGitHubInstallation(db, userID)
|
||||
if err == nil {
|
||||
response["installed"] = true
|
||||
response["installation"] = installation
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetGitHubAppInstallURL creates a one-time state and returns an install URL for the configured GitHub App.
|
||||
func GetGitHubAppInstallURL(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isGitHubAppInstallEnabled() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App slug is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
state := generateRandomString(24)
|
||||
expiresAt := time.Now().Add(15 * time.Minute)
|
||||
stateRecord := models.GitHubAppInstallState{
|
||||
UserID: userID,
|
||||
State: state,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := db.Create(&stateRecord).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create install state"})
|
||||
return
|
||||
}
|
||||
|
||||
installURL := fmt.Sprintf(
|
||||
"https://github.com/apps/%s/installations/new?state=%s",
|
||||
url.PathEscape(getGitHubAppSlug()),
|
||||
url.QueryEscape(state),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"install_url": installURL,
|
||||
"expires_at": expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GitHubAppInstallCallback handles GitHub App setup callback and links installation to a Trackeep user.
|
||||
func GitHubAppInstallCallback(c *gin.Context) {
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
installationRaw := strings.TrimSpace(c.Query("installation_id"))
|
||||
setupAction := strings.TrimSpace(c.Query("setup_action"))
|
||||
|
||||
if state == "" || installationRaw == "" {
|
||||
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "missing_state_or_installation")
|
||||
return
|
||||
}
|
||||
|
||||
installationID, err := strconv.ParseInt(installationRaw, 10, 64)
|
||||
if err != nil || installationID <= 0 {
|
||||
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "invalid_installation_id")
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "database_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var stateRecord models.GitHubAppInstallState
|
||||
if err := db.Where("state = ?", state).First(&stateRecord).Error; err != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
if stateRecord.UsedAt != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_already_used")
|
||||
return
|
||||
}
|
||||
if time.Now().After(stateRecord.ExpiresAt) {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_expired")
|
||||
return
|
||||
}
|
||||
|
||||
accountLogin := ""
|
||||
accountType := ""
|
||||
lastValidated := (*time.Time)(nil)
|
||||
if hasGitHubAppCredentials() {
|
||||
details, detailsErr := fetchGitHubAppInstallationDetails(c.Request.Context(), installationID)
|
||||
if detailsErr == nil && details != nil {
|
||||
accountLogin = details.Account.Login
|
||||
accountType = details.Account.Type
|
||||
now := time.Now()
|
||||
lastValidated = &now
|
||||
}
|
||||
}
|
||||
|
||||
var installation models.GitHubAppInstallation
|
||||
lookupErr := db.Where("installation_id = ?", installationID).First(&installation).Error
|
||||
switch {
|
||||
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
||||
installation = models.GitHubAppInstallation{
|
||||
UserID: stateRecord.UserID,
|
||||
InstallationID: installationID,
|
||||
AppSlug: getGitHubAppSlug(),
|
||||
AccountLogin: accountLogin,
|
||||
AccountType: accountType,
|
||||
LastValidated: lastValidated,
|
||||
}
|
||||
if err := db.Create(&installation).Error; err != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_store_installation")
|
||||
return
|
||||
}
|
||||
case lookupErr != nil:
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_lookup_failed")
|
||||
return
|
||||
default:
|
||||
updates := map[string]interface{}{
|
||||
"user_id": stateRecord.UserID,
|
||||
"app_slug": getGitHubAppSlug(),
|
||||
"account_login": accountLogin,
|
||||
"account_type": accountType,
|
||||
}
|
||||
if lastValidated != nil {
|
||||
updates["last_validated"] = lastValidated
|
||||
}
|
||||
if err := db.Model(&installation).Updates(updates).Error; err != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_update_installation")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usedAt := time.Now()
|
||||
if err := db.Model(&stateRecord).Update("used_at", usedAt).Error; err != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_finalize_state")
|
||||
return
|
||||
}
|
||||
|
||||
redirectToGitHubIntegrationPage(c, true, installationID, setupAction, "")
|
||||
}
|
||||
|
||||
// GetGitHubAppRepos returns repositories available through the user's GitHub App installation.
|
||||
func GetGitHubAppRepos(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
installation, err := getUserGitHubInstallation(db, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App is not installed for this user"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, _, err := createGitHubInstallationAccessToken(c.Request.Context(), installation.InstallationID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to create GitHub App installation token: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
repos, err := fetchGitHubInstallationRepos(accessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch installation repos: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": "github_app",
|
||||
"installation_id": installation.InstallationID,
|
||||
"repos": repos,
|
||||
})
|
||||
}
|
||||
|
||||
// GetGitHubBackups lists local GitHub repository backups for the authenticated user.
|
||||
func GetGitHubBackups(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var backups []models.GitHubRepoBackup
|
||||
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").Find(&backups).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository backups"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"backup_root": getGitHubBackupRoot(),
|
||||
"backups": backups,
|
||||
})
|
||||
}
|
||||
|
||||
// BackupGitHubRepositories clones or updates selected repositories in local mirror storage.
|
||||
func BackupGitHubRepositories(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var req gitHubBackupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(req.Repositories) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "At least one repository must be provided"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, source, installationID, err := resolveGitHubBackupToken(c, db, userID, req.Source)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(getGitHubBackupRoot(), 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup directory"})
|
||||
return
|
||||
}
|
||||
|
||||
knownRepos := make(map[string]GitHubRepo)
|
||||
switch source {
|
||||
case "oauth":
|
||||
repos, reposErr := fetchGitHubRepos(accessToken)
|
||||
if reposErr == nil {
|
||||
for _, repo := range repos {
|
||||
knownRepos[strings.ToLower(repo.FullName)] = repo
|
||||
}
|
||||
}
|
||||
case "github_app":
|
||||
repos, reposErr := fetchGitHubInstallationRepos(accessToken)
|
||||
if reposErr == nil {
|
||||
for _, repo := range repos {
|
||||
knownRepos[strings.ToLower(repo.FullName)] = repo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]gitHubBackupResult, 0, len(req.Repositories))
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
for _, rawRepo := range req.Repositories {
|
||||
repoFullName, normalizeErr := normalizeGitHubRepoFullName(rawRepo)
|
||||
if normalizeErr != nil {
|
||||
failedCount++
|
||||
results = append(results, gitHubBackupResult{
|
||||
Repository: strings.TrimSpace(rawRepo),
|
||||
Status: "error",
|
||||
Source: source,
|
||||
Error: normalizeErr.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[repoFullName]; exists {
|
||||
continue
|
||||
}
|
||||
seen[repoFullName] = struct{}{}
|
||||
|
||||
repoInfo, hasInfo := knownRepos[strings.ToLower(repoFullName)]
|
||||
if !hasInfo {
|
||||
repoDetails, fetchErr := fetchGitHubRepoByFullName(accessToken, repoFullName)
|
||||
if fetchErr == nil && repoDetails != nil {
|
||||
repoInfo = *repoDetails
|
||||
}
|
||||
}
|
||||
if repoInfo.FullName == "" {
|
||||
repoInfo.FullName = repoFullName
|
||||
parts := strings.SplitN(repoFullName, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
repoInfo.Name = parts[1]
|
||||
}
|
||||
repoInfo.CloneURL = fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
||||
}
|
||||
|
||||
localPath := buildGitHubBackupPath(userID, repoFullName)
|
||||
|
||||
repoCtx, cancel := context.WithTimeout(c.Request.Context(), getGitHubBackupTimeout())
|
||||
sizeBytes, backupErr := backupGitHubRepositoryMirror(repoCtx, accessToken, repoFullName, localPath)
|
||||
cancel()
|
||||
|
||||
result := gitHubBackupResult{
|
||||
Repository: repoFullName,
|
||||
LocalPath: localPath,
|
||||
Source: source,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
record := models.GitHubRepoBackup{
|
||||
UserID: userID,
|
||||
RepositoryID: int64(repoInfo.ID),
|
||||
RepositoryName: repoInfo.Name,
|
||||
RepositoryFullName: repoFullName,
|
||||
DefaultBranch: repoInfo.DefaultBranch,
|
||||
CloneURL: repoInfo.CloneURL,
|
||||
LocalPath: localPath,
|
||||
Source: source,
|
||||
InstallationID: installationID,
|
||||
LastBackupAt: &now,
|
||||
}
|
||||
|
||||
if backupErr != nil {
|
||||
failedCount++
|
||||
result.Status = "error"
|
||||
result.Error = backupErr.Error()
|
||||
record.LastBackupStatus = "error"
|
||||
record.LastBackupError = backupErr.Error()
|
||||
record.LastBackupSize = 0
|
||||
} else {
|
||||
successCount++
|
||||
result.Status = "success"
|
||||
result.SizeBytes = sizeBytes
|
||||
record.LastBackupStatus = "success"
|
||||
record.LastBackupError = ""
|
||||
record.LastBackupSize = sizeBytes
|
||||
}
|
||||
|
||||
if upsertErr := upsertGitHubBackupRecord(db, record); upsertErr != nil {
|
||||
if result.Status == "success" {
|
||||
result.Status = "error"
|
||||
result.Error = "backup persisted but metadata update failed: " + upsertErr.Error()
|
||||
successCount--
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": source,
|
||||
"installation_id": installationID,
|
||||
"backed_up": successCount,
|
||||
"failed": failedCount,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
func getGitHubRequestUserID(c *gin.Context) uint {
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
userID = c.GetUint("userID")
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
func getUserGitHubInstallation(db *gorm.DB, userID uint) (*models.GitHubAppInstallation, error) {
|
||||
var installation models.GitHubAppInstallation
|
||||
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").First(&installation).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &installation, nil
|
||||
}
|
||||
|
||||
func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
|
||||
source := strings.ToLower(strings.TrimSpace(requestedSource))
|
||||
switch source {
|
||||
case "", "oauth":
|
||||
accessToken, err := getGitHubOAuthAccessTokenFromHeader(c)
|
||||
if err == nil {
|
||||
return accessToken, "oauth", nil, nil
|
||||
}
|
||||
|
||||
accessToken, installationID, appErr := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if appErr == nil {
|
||||
return accessToken, "github_app", &installationID, nil
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("no usable GitHub OAuth token and GitHub App fallback failed")
|
||||
case "github_app", "app":
|
||||
accessToken, installationID, err := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return accessToken, "github_app", &installationID, nil
|
||||
default:
|
||||
return "", "", nil, fmt.Errorf("unsupported source '%s'", requestedSource)
|
||||
}
|
||||
}
|
||||
|
||||
func getGitHubOAuthAccessTokenFromHeader(c *gin.Context) (string, error) {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader == "" {
|
||||
return "", errors.New("authorization header required")
|
||||
}
|
||||
|
||||
tokenString := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
tokenString = strings.TrimSpace(authHeader[7:])
|
||||
}
|
||||
if tokenString == "" {
|
||||
return "", errors.New("invalid authorization header")
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
accessToken := strings.TrimSpace(claims.AccessToken)
|
||||
if accessToken == "" {
|
||||
return "", errors.New("github oauth token missing in jwt")
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func getGitHubAppAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, int64, error) {
|
||||
installation, err := getUserGitHubInstallation(db, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", 0, errors.New("GitHub App not installed for this user")
|
||||
}
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
accessToken, _, err := createGitHubInstallationAccessToken(ctx, installation.InstallationID)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return accessToken, installation.InstallationID, nil
|
||||
}
|
||||
|
||||
func upsertGitHubBackupRecord(db *gorm.DB, record models.GitHubRepoBackup) error {
|
||||
var existing models.GitHubRepoBackup
|
||||
err := db.Where("user_id = ? AND repository_full_name = ?", record.UserID, record.RepositoryFullName).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return db.Create(&record).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
updates := map[string]interface{}{
|
||||
"repository_id": record.RepositoryID,
|
||||
"repository_name": record.RepositoryName,
|
||||
"default_branch": record.DefaultBranch,
|
||||
"clone_url": record.CloneURL,
|
||||
"local_path": record.LocalPath,
|
||||
"source": record.Source,
|
||||
"installation_id": record.InstallationID,
|
||||
"last_backup_at": record.LastBackupAt,
|
||||
"last_backup_status": record.LastBackupStatus,
|
||||
"last_backup_error": record.LastBackupError,
|
||||
"last_backup_size": record.LastBackupSize,
|
||||
}
|
||||
return db.Model(&existing).Updates(updates).Error
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGitHubRepoFullName(raw string) (string, error) {
|
||||
normalized := strings.TrimSpace(raw)
|
||||
normalized = strings.TrimSuffix(normalized, ".git")
|
||||
normalized = strings.TrimPrefix(normalized, "https://github.com/")
|
||||
normalized = strings.TrimPrefix(normalized, "http://github.com/")
|
||||
normalized = strings.TrimPrefix(normalized, "github.com/")
|
||||
normalized = strings.Trim(normalized, "/")
|
||||
if !githubRepoFullNamePattern.MatchString(normalized) {
|
||||
return "", fmt.Errorf("invalid repository '%s', expected owner/repo", raw)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func buildGitHubBackupPath(userID uint, repoFullName string) string {
|
||||
parts := strings.SplitN(repoFullName, "/", 2)
|
||||
owner := "unknown"
|
||||
repo := repoFullName
|
||||
if len(parts) == 2 {
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
}
|
||||
return filepath.Join(getGitHubBackupRoot(), fmt.Sprintf("user-%d", userID), owner, repo+".git")
|
||||
}
|
||||
|
||||
func getGitHubBackupRoot() string {
|
||||
root := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_ROOT"))
|
||||
if root == "" {
|
||||
root = filepath.Join("data", "github-backups")
|
||||
}
|
||||
absolutePath, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return root
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
func getGitHubBackupTimeout() time.Duration {
|
||||
timeoutRaw := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_TIMEOUT"))
|
||||
if timeoutRaw == "" {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
parsed, err := time.ParseDuration(timeoutRaw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func backupGitHubRepositoryMirror(ctx context.Context, accessToken, repoFullName, localPath string) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||
return 0, fmt.Errorf("failed to create backup parent directory: %w", err)
|
||||
}
|
||||
|
||||
repoURL := fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
||||
gitAuthHeader := "http.extraHeader=Authorization: Bearer " + accessToken
|
||||
cloneRequired := true
|
||||
|
||||
if info, err := os.Stat(localPath); err == nil {
|
||||
if !info.IsDir() {
|
||||
return 0, fmt.Errorf("backup path exists and is not a directory: %s", localPath)
|
||||
}
|
||||
if _, configErr := os.Stat(filepath.Join(localPath, "config")); configErr == nil {
|
||||
cloneRequired = false
|
||||
} else if errors.Is(configErr, os.ErrNotExist) {
|
||||
if removeErr := os.RemoveAll(localPath); removeErr != nil {
|
||||
return 0, fmt.Errorf("failed to reset invalid backup directory: %w", removeErr)
|
||||
}
|
||||
} else {
|
||||
return 0, fmt.Errorf("failed to inspect existing backup directory: %w", configErr)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return 0, fmt.Errorf("failed to access backup path: %w", err)
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if cloneRequired {
|
||||
cmd = exec.CommandContext(ctx, "git", "-c", gitAuthHeader, "clone", "--mirror", repoURL, localPath)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "git", "-C", localPath, "-c", gitAuthHeader, "remote", "update", "--prune")
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
commandOutput := strings.TrimSpace(string(output))
|
||||
if commandOutput == "" {
|
||||
commandOutput = err.Error()
|
||||
}
|
||||
return 0, fmt.Errorf("git backup failed: %s", commandOutput)
|
||||
}
|
||||
|
||||
sizeBytes, err := calculateDirectorySize(localPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("backup completed but failed to calculate size: %w", err)
|
||||
}
|
||||
|
||||
return sizeBytes, nil
|
||||
}
|
||||
|
||||
func calculateDirectorySize(root string) (int64, error) {
|
||||
var totalSize int64
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
func fetchGitHubInstallationRepos(accessToken string) ([]GitHubRepo, error) {
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/installation/repositories?per_page=100", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var response gitHubInstallationReposResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Repositories, nil
|
||||
}
|
||||
|
||||
func fetchGitHubRepoByFullName(accessToken, repoFullName string) (*GitHubRepo, error) {
|
||||
parts := strings.SplitN(repoFullName, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("invalid repository full name")
|
||||
}
|
||||
repoURL := fmt.Sprintf(
|
||||
"https://api.github.com/repos/%s/%s",
|
||||
url.PathEscape(parts[0]),
|
||||
url.PathEscape(parts[1]),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", repoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var repo GitHubRepo
|
||||
if err := json.Unmarshal(body, &repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
func isGitHubAppInstallEnabled() bool {
|
||||
return getGitHubAppSlug() != ""
|
||||
}
|
||||
|
||||
func hasGitHubAppCredentials() bool {
|
||||
return strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" &&
|
||||
strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) != ""
|
||||
}
|
||||
|
||||
func getGitHubAppSlug() string {
|
||||
return strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
|
||||
}
|
||||
|
||||
func createGitHubInstallationAccessToken(ctx context.Context, installationID int64) (string, time.Time, error) {
|
||||
if !hasGitHubAppCredentials() {
|
||||
return "", time.Time{}, errors.New("GitHub App credentials are not fully configured")
|
||||
}
|
||||
|
||||
appJWT, err := createGitHubAppJWT()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+appJWT)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", time.Time{}, fmt.Errorf("GitHub token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var payload gitHubInstallationTokenResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Token) == "" {
|
||||
return "", time.Time{}, errors.New("GitHub returned an empty installation token")
|
||||
}
|
||||
|
||||
var expiresAt time.Time
|
||||
if payload.ExpiresAt != "" {
|
||||
parsed, parseErr := time.Parse(time.RFC3339, payload.ExpiresAt)
|
||||
if parseErr == nil {
|
||||
expiresAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return payload.Token, expiresAt, nil
|
||||
}
|
||||
|
||||
func fetchGitHubAppInstallationDetails(ctx context.Context, installationID int64) (*gitHubAppInstallationDetails, error) {
|
||||
if !hasGitHubAppCredentials() {
|
||||
return nil, errors.New("GitHub App credentials are not configured")
|
||||
}
|
||||
|
||||
appJWT, err := createGitHubAppJWT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d", installationID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+appJWT)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub installation endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var details gitHubAppInstallationDetails
|
||||
if err := json.Unmarshal(body, &details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func createGitHubAppJWT() (string, error) {
|
||||
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
|
||||
if appID == "" {
|
||||
return "", errors.New("GITHUB_APP_ID is not configured")
|
||||
}
|
||||
|
||||
privateKeyPEM, err := loadGitHubAppPrivateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse GitHub App private key: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.RegisteredClaims{
|
||||
Issuer: appID,
|
||||
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
signedToken, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign GitHub App JWT: %w", err)
|
||||
}
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
func loadGitHubAppPrivateKey() ([]byte, error) {
|
||||
raw := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
|
||||
if raw == "" {
|
||||
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is not configured")
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(raw, "\\n", "\n")
|
||||
if strings.Contains(normalized, "BEGIN ") {
|
||||
return []byte(normalized), nil
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(normalized)
|
||||
if err != nil {
|
||||
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is neither PEM nor base64-encoded PEM")
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func redirectToGitHubIntegrationPage(c *gin.Context, success bool, installationID int64, setupAction, errorCode string) {
|
||||
frontendURL := strings.TrimSpace(os.Getenv("FRONTEND_URL"))
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://localhost:3000"
|
||||
}
|
||||
frontendURL = strings.TrimRight(frontendURL, "/")
|
||||
|
||||
params := url.Values{}
|
||||
if success {
|
||||
params.Set("github_app_installed", "1")
|
||||
params.Set("installation_id", strconv.FormatInt(installationID, 10))
|
||||
if setupAction != "" {
|
||||
params.Set("setup_action", setupAction)
|
||||
}
|
||||
} else {
|
||||
params.Set("github_app_error", errorCode)
|
||||
if installationID > 0 {
|
||||
params.Set("installation_id", strconv.FormatInt(installationID, 10))
|
||||
}
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/app/github?%s", frontendURL, params.Encode())
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
func truncateString(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit < 4 {
|
||||
return value[:limit]
|
||||
}
|
||||
return value[:limit-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
const defaultOAuthServiceURL = "https://oauth.trackeep.org"
|
||||
|
||||
type centralizedOAuthUser struct {
|
||||
ID int `json:"id"`
|
||||
GitHubID int `json:"github_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type centralizedOAuthValidationResponse struct {
|
||||
Token string `json:"token"`
|
||||
User centralizedOAuthUser `json:"user"`
|
||||
}
|
||||
|
||||
func getOAuthServiceURL() string {
|
||||
value := strings.TrimSpace(os.Getenv("OAUTH_SERVICE_URL"))
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("VITE_OAUTH_SERVICE_URL"))
|
||||
}
|
||||
if value == "" {
|
||||
value = defaultOAuthServiceURL
|
||||
}
|
||||
return strings.TrimRight(value, "/")
|
||||
}
|
||||
|
||||
func headerValue(headers http.Header, key string) string {
|
||||
raw := strings.TrimSpace(headers.Get(key))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
candidate := strings.TrimSpace(part)
|
||||
if candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func backendPublicBaseURL(r *http.Request) string {
|
||||
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_API_URL")); baseURL != "" {
|
||||
return strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); baseURL != "" {
|
||||
return strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if forwardedProto := headerValue(r.Header, "X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
} else if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
host := headerValue(r.Header, "X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(r.Host)
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
func normalizeFrontendRedirectURL(raw string) string {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
parsed.Path = "/auth/callback"
|
||||
}
|
||||
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func resolveFrontendRedirectURL(r *http.Request) string {
|
||||
if value := normalizeFrontendRedirectURL(r.URL.Query().Get("frontend_redirect")); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
if value := normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL")); value != "" {
|
||||
return value
|
||||
}
|
||||
|
||||
if origin := normalizeFrontendRedirectURL(r.Header.Get("Origin")); origin != "" {
|
||||
return origin
|
||||
}
|
||||
|
||||
referer := strings.TrimSpace(r.Header.Get("Referer"))
|
||||
if referer != "" {
|
||||
if parsed, err := url.Parse(referer); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
return normalizeFrontendRedirectURL((&url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
Host: parsed.Host,
|
||||
Path: "/auth/callback",
|
||||
}).String())
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildOAuthCallbackURL(r *http.Request, frontendRedirect string) string {
|
||||
baseURL := backendPublicBaseURL(r)
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/oauth/callback")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if frontendRedirect != "" {
|
||||
query := callbackURL.Query()
|
||||
query.Set("frontend_redirect", frontendRedirect)
|
||||
callbackURL.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
return callbackURL.String()
|
||||
}
|
||||
|
||||
func buildFrontendCallbackRedirectURL(frontendRedirect, token string) string {
|
||||
redirectTarget := normalizeFrontendRedirectURL(frontendRedirect)
|
||||
if redirectTarget == "" {
|
||||
redirectTarget = normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL"))
|
||||
}
|
||||
if redirectTarget == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(redirectTarget)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("token", token)
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func validateCentralizedOAuthToken(ctx context.Context, token string) (*centralizedOAuthValidationResponse, error) {
|
||||
serviceURL := getOAuthServiceURL()
|
||||
if serviceURL == "" {
|
||||
return nil, fmt.Errorf("oauth service url not configured")
|
||||
}
|
||||
|
||||
requestBody, err := json.Marshal(map[string]string{"token": token})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/api/v1/auth/oauth/callback", bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
message := strings.TrimSpace(string(body))
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("oauth service validation failed: %s", message)
|
||||
}
|
||||
|
||||
var response centralizedOAuthValidationResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func parseOAuthTokenClaimsUnverified(token string) (jwt.MapClaims, error) {
|
||||
parser := jwt.NewParser()
|
||||
parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func getAccessTokenFromOAuthClaims(claims jwt.MapClaims) string {
|
||||
accessToken, _ := claims["access_token"].(string)
|
||||
return strings.TrimSpace(accessToken)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func uniqueUsername(base string, db *gorm.DB, excludeUserID uint) string {
|
||||
candidate := strings.TrimSpace(base)
|
||||
if candidate == "" {
|
||||
candidate = "user"
|
||||
}
|
||||
|
||||
for suffix := 0; ; suffix++ {
|
||||
username := candidate
|
||||
if suffix > 0 {
|
||||
username = fmt.Sprintf("%s-%d", candidate, suffix+1)
|
||||
}
|
||||
|
||||
var existing models.User
|
||||
err := db.Where("username = ?", username).First(&existing).Error
|
||||
if err == nil {
|
||||
if excludeUserID != 0 && existing.ID == excludeUserID {
|
||||
return username
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return username
|
||||
}
|
||||
return username
|
||||
}
|
||||
}
|
||||
|
||||
func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser) (*models.User, error) {
|
||||
var user models.User
|
||||
var err error
|
||||
|
||||
normalizedEmail := strings.TrimSpace(controllerUser.Email)
|
||||
normalizedUsername := firstNonEmpty(controllerUser.Username, strings.Split(normalizedEmail, "@")[0], "user")
|
||||
fullName := firstNonEmpty(controllerUser.Name, controllerUser.Username, normalizedEmail)
|
||||
provider := "email"
|
||||
if controllerUser.GitHubID != 0 {
|
||||
provider = "github"
|
||||
err = db.Where("github_id = ?", controllerUser.GitHubID).First(&user).Error
|
||||
} else {
|
||||
err = gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
if err != nil && normalizedEmail != "" {
|
||||
err = db.Where("email = ?", normalizedEmail).First(&user).Error
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
updates := map[string]interface{}{
|
||||
"email": normalizedEmail,
|
||||
"username": uniqueUsername(normalizedUsername, db, user.ID),
|
||||
"full_name": fullName,
|
||||
"avatar_url": controllerUser.AvatarURL,
|
||||
"provider": provider,
|
||||
}
|
||||
if controllerUser.GitHubID != 0 {
|
||||
updates["github_id"] = controllerUser.GitHubID
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates["last_login_at"] = &now
|
||||
|
||||
if err := db.Model(&user).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.First(&user, user.ID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var userCount int64
|
||||
if err := db.Model(&models.User{}).Count(&userCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
randomPassword := generateRandomString(32)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := "user"
|
||||
if userCount == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user = models.User{
|
||||
Email: normalizedEmail,
|
||||
Username: uniqueUsername(normalizedUsername, db, 0),
|
||||
Password: string(hashedPassword),
|
||||
FullName: fullName,
|
||||
Role: role,
|
||||
Theme: "dark",
|
||||
GitHubID: controllerUser.GitHubID,
|
||||
AvatarURL: controllerUser.AvatarURL,
|
||||
Provider: provider,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = ensureMessagingDefaults(db, user.ID)
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestValidateCentralizedOAuthToken(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/auth/oauth/callback" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("failed to decode request body: %v", err)
|
||||
}
|
||||
if body["token"] != "controller-token" {
|
||||
t.Fatalf("unexpected token payload: %#v", body)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(centralizedOAuthValidationResponse{
|
||||
Token: "controller-token",
|
||||
User: centralizedOAuthUser{
|
||||
ID: 42,
|
||||
GitHubID: 99,
|
||||
Username: "octocat",
|
||||
Email: "octocat@example.com",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("OAUTH_SERVICE_URL", server.URL)
|
||||
t.Setenv("VITE_OAUTH_SERVICE_URL", "")
|
||||
|
||||
response, err := validateCentralizedOAuthToken(context.Background(), "controller-token")
|
||||
if err != nil {
|
||||
t.Fatalf("validateCentralizedOAuthToken returned error: %v", err)
|
||||
}
|
||||
|
||||
if response.User.Username != "octocat" {
|
||||
t.Fatalf("unexpected user returned: %#v", response.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOAuthCallbackURLPreservesFrontendRedirect(t *testing.T) {
|
||||
frontendRedirect := "https://app.example.com/auth/callback?next=%2Fapp"
|
||||
req := httptest.NewRequest(http.MethodGet, "http://internal/api/v1/auth/github?frontend_redirect="+url.QueryEscape(frontendRedirect), nil)
|
||||
req.Host = "api.example.com"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "api.example.com")
|
||||
|
||||
resolvedFrontendRedirect := resolveFrontendRedirectURL(req)
|
||||
if resolvedFrontendRedirect != frontendRedirect {
|
||||
t.Fatalf("unexpected frontend redirect: %s", resolvedFrontendRedirect)
|
||||
}
|
||||
|
||||
callbackURL := buildOAuthCallbackURL(req, resolvedFrontendRedirect)
|
||||
expected := "https://api.example.com/api/v1/auth/oauth/callback?frontend_redirect=" + url.QueryEscape(frontendRedirect)
|
||||
if callbackURL != expected {
|
||||
t.Fatalf("unexpected callback URL: got %s want %s", callbackURL, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOAuthTokenClaimsUnverified(t *testing.T) {
|
||||
signedToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": 1,
|
||||
"access_token": "gho_test_token",
|
||||
}).SignedString([]byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
claims, err := parseOAuthTokenClaimsUnverified(signedToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parseOAuthTokenClaimsUnverified returned error: %v", err)
|
||||
}
|
||||
|
||||
if accessToken := getAccessTokenFromOAuthClaims(claims); accessToken != "gho_test_token" {
|
||||
t.Fatalf("unexpected access token: %s", accessToken)
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func GetUpdateSettingsForAPI(userID int) (UpdateSettings, error) {
|
||||
|
||||
func getDefaultUpdateSettings() UpdateSettings {
|
||||
return UpdateSettings{
|
||||
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.tdvorak.dev"),
|
||||
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.trackeep.org"),
|
||||
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
|
||||
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
|
||||
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
||||
|
||||
Reference in New Issue
Block a user