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:
Tomas Dvorak
2026-03-05 23:51:34 +01:00
parent f3a835caa2
commit 954a1a1080
146 changed files with 5801 additions and 25847 deletions
+41 -10
View File
@@ -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
View File
@@ -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
+944
View File
@@ -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] + "..."
}
+365
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+1 -1
View File
@@ -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),