mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
small fix, don't worry about it
This commit is contained in:
+110
-21
@@ -89,20 +89,19 @@ func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
|
||||
// GenerateJWT creates a new JWT token for a user
|
||||
func GenerateJWT(user models.User) (string, error) {
|
||||
return generateJWT(user, "")
|
||||
return generateJWT(user)
|
||||
}
|
||||
|
||||
func GenerateJWTWithGitHubAccessToken(user models.User, accessToken string) (string, error) {
|
||||
return generateJWT(user, accessToken)
|
||||
func GenerateJWTWithGitHubAccessToken(user models.User, _ string) (string, error) {
|
||||
return generateJWT(user)
|
||||
}
|
||||
|
||||
func generateJWT(user models.User, accessToken string) (string, error) {
|
||||
func generateJWT(user models.User) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
GitHubID: user.GitHubID,
|
||||
AccessToken: accessToken,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
GitHubID: user.GitHubID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -158,6 +157,81 @@ func getAuthenticatedUserFromHeader(c *gin.Context, db *gorm.DB) (*models.User,
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func hasAPIKeyPermission(permissions []string, required string) bool {
|
||||
if required == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, permission := range permissions {
|
||||
if permission == "*" || permission == required {
|
||||
return true
|
||||
}
|
||||
if required == "files:share" && permission == "files:write" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func requiredAPIKeyPermission(method, path string) (string, bool) {
|
||||
if strings.Contains(path, "/api/v1/browser-extension/validate") {
|
||||
return "", true
|
||||
}
|
||||
|
||||
if !strings.Contains(path, "/api/v1/files") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/share") {
|
||||
return "files:share", true
|
||||
}
|
||||
|
||||
switch strings.ToUpper(method) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return "files:read", true
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
return "files:write", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAPIKeyForRequest(tokenString, method, path string) (*models.User, error) {
|
||||
requiredPermission, supported := requiredAPIKeyPermission(method, path)
|
||||
if !supported {
|
||||
return nil, errors.New("api keys are not allowed for this endpoint")
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
|
||||
var keyRecord models.APIKey
|
||||
if err := db.Where("key = ? AND is_active = ?", tokenString, true).Preload("User").First(&keyRecord).Error; err != nil {
|
||||
return nil, errors.New("invalid API key")
|
||||
}
|
||||
|
||||
if keyRecord.ExpiresAt != nil && keyRecord.ExpiresAt.Before(time.Now()) {
|
||||
return nil, errors.New("api key expired")
|
||||
}
|
||||
|
||||
if !hasAPIKeyPermission(keyRecord.Permissions, requiredPermission) {
|
||||
return nil, errors.New("insufficient API key permissions")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
keyRecord.LastUsed = &now
|
||||
_ = db.Model(&keyRecord).Update("last_used", now).Error
|
||||
|
||||
user := keyRecord.User
|
||||
if user.ID == 0 {
|
||||
if err := db.First(&user, keyRecord.UserID).Error; err != nil {
|
||||
return nil, errors.New("user not found for API key")
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// AuthMiddleware validates JWT tokens
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
@@ -218,24 +292,39 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(401, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
if err == nil {
|
||||
// Get user from database
|
||||
var user models.User
|
||||
if err := config.GetDB().First(&user, claims.UserID).Error; err != nil {
|
||||
c.JSON(401, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("userID", user.ID) // Add this for compatibility with handlers
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user models.User
|
||||
if err := config.GetDB().First(&user, claims.UserID).Error; err != nil {
|
||||
c.JSON(401, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
if strings.HasPrefix(tokenString, "tk_") {
|
||||
user, apiKeyErr := validateAPIKeyForRequest(tokenString, c.Request.Method, c.Request.URL.Path)
|
||||
if apiKeyErr != nil {
|
||||
c.JSON(401, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", *user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("userID", user.ID) // Add this for compatibility with handlers
|
||||
c.Next()
|
||||
c.JSON(401, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,18 +33,6 @@ type APIKeyResponse struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// BrowserExtensionAuth represents browser extension authentication
|
||||
type BrowserExtensionAuth struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null"`
|
||||
ExtensionID string `json:"extension_id" gorm:"not null"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
IsActive bool `json:"is_active" gorm:"default:true"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// GenerateAPIKey creates a new API key for browser extension
|
||||
func GenerateAPIKey(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
@@ -67,6 +55,7 @@ func GenerateAPIKey(c *gin.Context) {
|
||||
"bookmarks:write": true,
|
||||
"files:read": true,
|
||||
"files:write": true,
|
||||
"files:share": true,
|
||||
"notes:read": true,
|
||||
"notes:write": true,
|
||||
"tasks:read": true,
|
||||
@@ -91,12 +80,14 @@ func GenerateAPIKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create API key record
|
||||
now := time.Now()
|
||||
apiKey := models.APIKey{
|
||||
Name: req.Name,
|
||||
Key: key,
|
||||
UserID: currentUser.ID,
|
||||
Permissions: req.Permissions,
|
||||
IsActive: true,
|
||||
LastUsed: &now,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
@@ -260,14 +251,14 @@ func RegisterBrowserExtension(c *gin.Context) {
|
||||
|
||||
// Check if extension already registered
|
||||
db := config.GetDB()
|
||||
var existingAuth BrowserExtensionAuth
|
||||
var existingAuth models.BrowserExtension
|
||||
if err := db.Where("user_id = ? AND extension_id = ?", currentUser.ID, req.ExtensionID).First(&existingAuth).Error; err == nil {
|
||||
c.JSON(409, gin.H{"error": "Extension already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new extension registration
|
||||
extAuth := BrowserExtensionAuth{
|
||||
extAuth := models.BrowserExtension{
|
||||
UserID: currentUser.ID,
|
||||
ExtensionID: req.ExtensionID,
|
||||
Name: req.Name,
|
||||
@@ -296,7 +287,7 @@ func GetBrowserExtensions(c *gin.Context) {
|
||||
|
||||
currentUser := user.(models.User)
|
||||
|
||||
var extensions []BrowserExtensionAuth
|
||||
var extensions []models.BrowserExtension
|
||||
db := config.GetDB()
|
||||
if err := db.Where("user_id = ?", currentUser.ID).Order("created_at desc").Find(&extensions).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "Failed to retrieve extensions"})
|
||||
@@ -318,7 +309,7 @@ func RevokeBrowserExtension(c *gin.Context) {
|
||||
extensionID := c.Param("id")
|
||||
|
||||
db := config.GetDB()
|
||||
var extAuth BrowserExtensionAuth
|
||||
var extAuth models.BrowserExtension
|
||||
if err := db.Where("extension_id = ? AND user_id = ?", extensionID, currentUser.ID).First(&extAuth).Error; err != nil {
|
||||
c.JSON(404, gin.H{"error": "Extension not found"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
controlServiceFrontendRedirectCookieName = "control_auth_frontend_redirect"
|
||||
controlServiceSessionTokenHeader = "X-Trackeep-Controller-Token"
|
||||
controlServiceRequestTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var controlServiceBaseURL = config.ControlServiceURL
|
||||
|
||||
type controlServiceTokenValidationResponse struct {
|
||||
Token string `json:"token"`
|
||||
User centralizedOAuthUser `json:"user"`
|
||||
}
|
||||
|
||||
type controlServiceErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type controlServiceGitHubAppInfo struct {
|
||||
AppSlug string `json:"app_slug"`
|
||||
InstallEnabled bool `json:"install_enabled"`
|
||||
SignInConfigured bool `json:"sign_in_configured"`
|
||||
CredentialsConfigured bool `json:"credentials_configured"`
|
||||
}
|
||||
|
||||
type controlServiceInstallationVerification struct {
|
||||
Verified bool `json:"verified"`
|
||||
InstallationID int64 `json:"installation_id"`
|
||||
AccountLogin string `json:"account_login"`
|
||||
AccountType string `json:"account_type"`
|
||||
AppSlug string `json:"app_slug"`
|
||||
}
|
||||
|
||||
type controlServiceAccessTokenPayload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Source string `json:"source"`
|
||||
InstallationID int64 `json:"installation_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func storeControlServiceAuthFlowState(c *gin.Context, frontendRedirect string) {
|
||||
if frontendRedirect == "" {
|
||||
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, "", -1)
|
||||
return
|
||||
}
|
||||
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, frontendRedirect, gitHubAuthCookieMaxAgeSeconds)
|
||||
}
|
||||
|
||||
func clearControlServiceAuthFlowState(c *gin.Context) {
|
||||
setGitHubAuthCookie(c, controlServiceFrontendRedirectCookieName, "", -1)
|
||||
}
|
||||
|
||||
func getControlServiceFrontendRedirectFromCookie(c *gin.Context) string {
|
||||
raw, err := c.Cookie(controlServiceFrontendRedirectCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeFrontendRedirectURL(raw)
|
||||
}
|
||||
|
||||
func buildControlServiceCallbackURL(r *http.Request) string {
|
||||
baseURL := backendPublicBaseURL(r)
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(baseURL, "/") + "/api/v1/auth/control/callback"
|
||||
}
|
||||
|
||||
func buildGitHubAppInstallCallbackURL(r *http.Request, state string) string {
|
||||
baseURL := backendPublicBaseURL(r)
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
callbackURL, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/github/app/callback")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
query := callbackURL.Query()
|
||||
query.Set("state", state)
|
||||
callbackURL.RawQuery = query.Encode()
|
||||
return callbackURL.String()
|
||||
}
|
||||
|
||||
func buildControlServiceGitHubStartURL(r *http.Request) (string, error) {
|
||||
callbackURL := buildControlServiceCallbackURL(r)
|
||||
if callbackURL == "" {
|
||||
return "", errors.New("unable to determine local OAuth callback URL")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(strings.TrimRight(controlServiceBaseURL, "/") + "/auth/github")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("redirect_uri", callbackURL)
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func controlServiceClient() *http.Client {
|
||||
return &http.Client{Timeout: controlServiceRequestTimeout}
|
||||
}
|
||||
|
||||
func parseControlServiceError(statusCode int, body []byte) error {
|
||||
var payload controlServiceErrorResponse
|
||||
if err := json.Unmarshal(body, &payload); err == nil && strings.TrimSpace(payload.Error) != "" {
|
||||
return fmt.Errorf("control service returned %d: %s", statusCode, payload.Error)
|
||||
}
|
||||
message := strings.TrimSpace(string(body))
|
||||
if message == "" {
|
||||
message = http.StatusText(statusCode)
|
||||
}
|
||||
return fmt.Errorf("control service returned %d: %s", statusCode, truncateString(message, 220))
|
||||
}
|
||||
|
||||
func validateControlServiceToken(ctx context.Context, token string) (*controlServiceTokenValidationResponse, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("controller token is required")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{"token": token})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
strings.TrimRight(controlServiceBaseURL, "/")+"/api/v1/auth/control/callback",
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
resp, err := controlServiceClient().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, parseControlServiceError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var parsed controlServiceTokenValidationResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(parsed.Token) == "" {
|
||||
parsed.Token = token
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func upsertControlServiceSession(db *gorm.DB, userID uint, controllerUser centralizedOAuthUser, token string) error {
|
||||
if db == nil {
|
||||
return errors.New("database not available")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return errors.New("controller token is required")
|
||||
}
|
||||
|
||||
encryptedToken, err := utils.Encrypt(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt controller token: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var existing models.ControlServiceSession
|
||||
lookupErr := db.Where("user_id = ?", userID).First(&existing).Error
|
||||
|
||||
switch {
|
||||
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
||||
record := models.ControlServiceSession{
|
||||
UserID: userID,
|
||||
ControllerUserID: controllerUser.ID,
|
||||
GitHubID: controllerUser.GitHubID,
|
||||
Username: controllerUser.Username,
|
||||
Email: controllerUser.Email,
|
||||
Token: encryptedToken,
|
||||
LastValidatedAt: &now,
|
||||
}
|
||||
return db.Create(&record).Error
|
||||
case lookupErr != nil:
|
||||
return lookupErr
|
||||
default:
|
||||
return db.Model(&existing).Updates(map[string]interface{}{
|
||||
"controller_user_id": controllerUser.ID,
|
||||
"github_id": controllerUser.GitHubID,
|
||||
"username": controllerUser.Username,
|
||||
"email": controllerUser.Email,
|
||||
"token": encryptedToken,
|
||||
"last_validated_at": &now,
|
||||
}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func getControlServiceSessionRecord(db *gorm.DB, userID uint) (*models.ControlServiceSession, error) {
|
||||
var session models.ControlServiceSession
|
||||
if err := db.Where("user_id = ?", userID).First(&session).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func getControlServiceTokenForUser(db *gorm.DB, userID uint) (string, error) {
|
||||
session, err := getControlServiceSessionRecord(db, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := utils.Decrypt(session.Token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt controller token: %w", err)
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", errors.New("controller token is empty")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func persistControlServiceToken(db *gorm.DB, userID uint, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
encryptedToken, err := utils.Encrypt(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt refreshed controller token: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
return db.Model(&models.ControlServiceSession{}).
|
||||
Where("user_id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"token": encryptedToken,
|
||||
"last_validated_at": &now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func performControlServiceRequest(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
userID uint,
|
||||
method string,
|
||||
path string,
|
||||
body io.Reader,
|
||||
contentType string,
|
||||
) ([]byte, http.Header, error) {
|
||||
token, err := getControlServiceTokenForUser(db, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(controlServiceBaseURL, "/")+path, body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := controlServiceClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if refreshedToken := strings.TrimSpace(resp.Header.Get(controlServiceSessionTokenHeader)); refreshedToken != "" {
|
||||
_ = persistControlServiceToken(db, userID, refreshedToken)
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, resp.Header, parseControlServiceError(resp.StatusCode, responseBody)
|
||||
}
|
||||
|
||||
return responseBody, resp.Header, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubRepos(ctx context.Context, db *gorm.DB, userID uint) ([]GitHubRepo, error) {
|
||||
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/repos", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Repos []GitHubRepo `json:"repos"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload.Repos, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubAppInfo(ctx context.Context, db *gorm.DB, userID uint) (*controlServiceGitHubAppInfo, error) {
|
||||
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/app/info", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload controlServiceGitHubAppInfo
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubAppInstallURL(ctx context.Context, db *gorm.DB, userID uint, redirectURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(controlServiceBaseURL, "/") + "/api/v1/github/app/install-url")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("redirect_uri", redirectURL)
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, strings.TrimPrefix(parsed.String(), strings.TrimRight(controlServiceBaseURL, "/")), nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
InstallURL string `json:"install_url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.InstallURL) == "" {
|
||||
return "", errors.New("control service did not return an install URL")
|
||||
}
|
||||
return payload.InstallURL, nil
|
||||
}
|
||||
|
||||
func verifyControlServiceGitHubInstallation(ctx context.Context, db *gorm.DB, userID uint, installationID int64) (*controlServiceInstallationVerification, error) {
|
||||
body, _, err := performControlServiceRequest(
|
||||
ctx,
|
||||
db,
|
||||
userID,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/github/app/installations/%d/verify", installationID),
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload controlServiceInstallationVerification
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !payload.Verified {
|
||||
return nil, errors.New("control service could not verify the GitHub installation")
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubAppRepos(ctx context.Context, db *gorm.DB, userID uint, installationID int64) ([]GitHubRepo, error) {
|
||||
body, _, err := performControlServiceRequest(
|
||||
ctx,
|
||||
db,
|
||||
userID,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/github/app/installations/%d/repos", installationID),
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Repositories []GitHubRepo `json:"repositories"`
|
||||
Repos []GitHubRepo `json:"repos"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload.Repositories) > 0 {
|
||||
return payload.Repositories, nil
|
||||
}
|
||||
return payload.Repos, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubUserAccessToken(ctx context.Context, db *gorm.DB, userID uint) (*controlServiceAccessTokenPayload, error) {
|
||||
body, _, err := performControlServiceRequest(ctx, db, userID, http.MethodGet, "/api/v1/github/user/access-token", nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload controlServiceAccessTokenPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, errors.New("control service returned an empty GitHub user token")
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func fetchControlServiceGitHubInstallationAccessToken(ctx context.Context, db *gorm.DB, userID uint, installationID int64) (*controlServiceAccessTokenPayload, error) {
|
||||
body, _, err := performControlServiceRequest(
|
||||
ctx,
|
||||
db,
|
||||
userID,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/github/app/installations/%d/access-token", installationID),
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload controlServiceAccessTokenPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, errors.New("control service returned an empty GitHub installation token")
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
// HandleOAuthCallback exchanges a hq.trackeep.org token for a local Trackeep session.
|
||||
func HandleOAuthCallback(c *gin.Context) {
|
||||
frontendRedirect := getControlServiceFrontendRedirectFromCookie(c)
|
||||
clearControlServiceAuthFlowState(c)
|
||||
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Controller token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
validation, err := validateControlServiceToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := upsertCentralizedOAuthUser(db, validation.User)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := upsertControlServiceSession(db, user.ID, validation.User, validation.Token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store controller session"})
|
||||
return
|
||||
}
|
||||
|
||||
localToken, err := GenerateJWT(*user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Trackeep token"})
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := buildFrontendCallbackRedirectURL(frontendRedirect, localToken)
|
||||
if redirectURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
func generateRandomString(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
_, _ = rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +17,89 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type createFileShareRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AllowDownload *bool `json:"allow_download,omitempty"`
|
||||
}
|
||||
|
||||
type fileShareResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentID uint `json:"content_id"`
|
||||
ShareToken string `json:"share_token"`
|
||||
ShareURL string `json:"share_url"`
|
||||
PublicShareURL string `json:"public_share_url"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
AllowDownload bool `json:"allow_download"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func generateSecureShareToken() (string, error) {
|
||||
raw := make([]byte, 24)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "share_" + base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func buildPublicShareURL(c *gin.Context, relative string) string {
|
||||
relativePath := strings.TrimSpace(relative)
|
||||
if relativePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(relativePath, "http://") || strings.HasPrefix(relativePath, "https://") {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(relativePath, "/") {
|
||||
relativePath = "/" + relativePath
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
if forwardedProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s%s", scheme, host, relativePath)
|
||||
}
|
||||
|
||||
func mapFileShareResponse(c *gin.Context, share models.ContentShare) fileShareResponse {
|
||||
return fileShareResponse{
|
||||
ID: share.ID,
|
||||
ContentType: share.ContentType,
|
||||
ContentID: share.ContentID,
|
||||
ShareToken: share.ShareToken,
|
||||
ShareURL: share.ShareURL,
|
||||
PublicShareURL: buildPublicShareURL(c, share.ShareURL),
|
||||
Title: share.Title,
|
||||
Description: share.Description,
|
||||
AllowDownload: share.AllowDownload,
|
||||
IsActive: share.IsActive,
|
||||
ExpiresAt: share.ExpiresAt,
|
||||
CreatedAt: share.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// GetFiles retrieves all files for a user
|
||||
func GetFiles(c *gin.Context) {
|
||||
var files []models.File
|
||||
@@ -188,6 +273,165 @@ func DownloadFile(c *gin.Context) {
|
||||
c.File(file.FilePath)
|
||||
}
|
||||
|
||||
// CreateFileShare creates a share link for a file owned by the current user.
|
||||
func CreateFileShare(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
userID = c.GetUint("userID")
|
||||
}
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var file models.File
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", id, userID).First(&file).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file"})
|
||||
return
|
||||
}
|
||||
|
||||
var req createFileShareRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.ExpiresAt != nil && req.ExpiresAt.Before(time.Now()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Share expiration must be in the future"})
|
||||
return
|
||||
}
|
||||
|
||||
shareToken, err := generateSecureShareToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate share token"})
|
||||
return
|
||||
}
|
||||
|
||||
allowDownload := true
|
||||
if req.AllowDownload != nil {
|
||||
allowDownload = *req.AllowDownload
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = file.OriginalName
|
||||
}
|
||||
|
||||
share := models.ContentShare{
|
||||
OwnerID: userID,
|
||||
ContentType: "file",
|
||||
ContentID: file.ID,
|
||||
ShareToken: shareToken,
|
||||
ShareURL: "/api/v1/shared/" + shareToken,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AllowDownload: allowDownload,
|
||||
AllowComment: false,
|
||||
AllowEdit: false,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := models.DB.Create(&share).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create file share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, mapFileShareResponse(c, share))
|
||||
}
|
||||
|
||||
// GetFileShares lists active and historical shares for a file owned by the user.
|
||||
func GetFileShares(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
userID = c.GetUint("userID")
|
||||
}
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var file models.File
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", id, userID).First(&file).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file"})
|
||||
return
|
||||
}
|
||||
|
||||
var shares []models.ContentShare
|
||||
if err := models.DB.
|
||||
Where("owner_id = ? AND content_type = ? AND content_id = ?", userID, "file", file.ID).
|
||||
Order("created_at DESC").
|
||||
Find(&shares).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file shares"})
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]fileShareResponse, 0, len(shares))
|
||||
for _, share := range shares {
|
||||
result = append(result, mapFileShareResponse(c, share))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"shares": result})
|
||||
}
|
||||
|
||||
// DeleteFileShare deletes a single share link for a file owned by the user.
|
||||
func DeleteFileShare(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
shareID := c.Param("shareId")
|
||||
|
||||
userID := c.GetUint("user_id")
|
||||
if userID == 0 {
|
||||
userID = c.GetUint("userID")
|
||||
}
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var file models.File
|
||||
if err := models.DB.Where("id = ? AND user_id = ?", id, userID).First(&file).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file"})
|
||||
return
|
||||
}
|
||||
|
||||
var share models.ContentShare
|
||||
if err := models.DB.
|
||||
Where("id = ? AND owner_id = ? AND content_type = ? AND content_id = ?", shareID, userID, "file", file.ID).
|
||||
First(&share).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "File share not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve file share"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := models.DB.Delete(&share).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file share"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "File share deleted successfully"})
|
||||
}
|
||||
|
||||
// DeleteFile removes a file record and the actual file
|
||||
func DeleteFile(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
+233
-191
@@ -1,40 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
// GitHub OAuth configuration
|
||||
var githubOAuthConfig *oauth2.Config
|
||||
|
||||
func initGitHubOAuth() {
|
||||
githubOAuthConfig = &oauth2.Config{
|
||||
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"),
|
||||
Scopes: []string{"user:email", "repo"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubUser represents the GitHub user profile
|
||||
type GitHubUser struct {
|
||||
ID int `json:"id"`
|
||||
@@ -65,69 +44,66 @@ type GitHubRepo struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
// GitHubLogin initiates the GitHub OAuth flow
|
||||
// GitHubLogin initiates the GitHub App user sign-in 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)
|
||||
storeControlServiceAuthFlowState(c, resolveFrontendRedirectURL(c.Request))
|
||||
|
||||
redirectURL, err := buildControlServiceGitHubStartURL(c.Request)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if githubOAuthConfig == nil {
|
||||
initGitHubOAuth()
|
||||
}
|
||||
|
||||
// Generate state parameter to prevent CSRF
|
||||
state := generateRandomString(32)
|
||||
|
||||
// Store state in session or cookie (simplified here)
|
||||
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
|
||||
|
||||
// Redirect to GitHub for authorization
|
||||
authURL := githubOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, authURL)
|
||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||
}
|
||||
|
||||
// GitHubCallback handles the GitHub OAuth callback
|
||||
// GitHubCallback handles the GitHub App sign-in callback.
|
||||
func GitHubCallback(c *gin.Context) {
|
||||
if githubOAuthConfig == nil {
|
||||
initGitHubOAuth()
|
||||
}
|
||||
|
||||
// Verify state parameter
|
||||
storedState, err := c.Cookie("oauth_state")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "State not found"})
|
||||
frontendRedirect := getGitHubFrontendRedirectFromCookie(c)
|
||||
storedState, err := c.Cookie(gitHubAuthStateCookieName)
|
||||
clearGitHubAuthFlowState(c)
|
||||
if err != nil || strings.TrimSpace(storedState) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub sign-in state not found"})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
if state != storedState {
|
||||
if callbackError := strings.TrimSpace(c.Query("error")); callbackError != "" {
|
||||
description := strings.TrimSpace(c.Query("error_description"))
|
||||
if description == "" {
|
||||
description = callbackError
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub sign-in failed: " + description})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.Query("state")) != storedState {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
|
||||
|
||||
// Exchange authorization code for access token
|
||||
code := c.Query("code")
|
||||
token, err := githubOAuthConfig.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
|
||||
callbackURL := buildGitHubUserCallbackURL(c.Request)
|
||||
if callbackURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to determine GitHub callback URL"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user info from GitHub
|
||||
user, err := getGitHubUser(token.AccessToken)
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
tokenResponse, err := exchangeGitHubAuthorizationCode(c.Request.Context(), code, callbackURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange GitHub code: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(tokenResponse.RefreshToken) == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "GitHub did not return a refresh token. Enable user token expiration for the GitHub App."})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := getGitHubUser(tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch GitHub user profile: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user in database
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
@@ -145,14 +121,18 @@ func GitHubCallback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := GenerateJWTWithGitHubAccessToken(*existingUser, token.AccessToken)
|
||||
if err := upsertGitHubUserAuth(db, existingUser.ID, user, tokenResponse); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store GitHub session: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString, err := GenerateJWT(*existingUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token
|
||||
redirectURL := buildFrontendCallbackRedirectURL("", tokenString)
|
||||
redirectURL := buildFrontendCallbackRedirectURL(frontendRedirect, tokenString)
|
||||
if redirectURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||
return
|
||||
@@ -163,13 +143,15 @@ func GitHubCallback(c *gin.Context) {
|
||||
// getGitHubUser fetches user information from GitHub API
|
||||
func getGitHubUser(accessToken string) (*GitHubUser, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
req, err := http.NewRequest("GET", strings.TrimRight(gitHubAPIBaseURL, "/")+"/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -181,109 +163,27 @@ func getGitHubUser(accessToken string) (*GitHubUser, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub user API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var user GitHubUser
|
||||
if err := json.Unmarshal(body, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If email is not public, fetch user emails
|
||||
if user.Email == "" {
|
||||
email, err := getPrimaryEmail(accessToken)
|
||||
if err == nil {
|
||||
user.Email = email
|
||||
}
|
||||
email, err := getPrimaryEmail(accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail fetches the primary email for the user
|
||||
func getPrimaryEmail(accessToken string) (string, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, email := range emails {
|
||||
if email.Primary && email.Verified {
|
||||
return email.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no primary verified email found")
|
||||
}
|
||||
|
||||
// HandleOAuthCallback handles the callback from the centralized OAuth service
|
||||
func HandleOAuthCallback(c *gin.Context) {
|
||||
// Get the token from the query parameters
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No token provided"})
|
||||
return
|
||||
}
|
||||
|
||||
validationResponse, err := validateCentralizedOAuthToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get database
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := parseOAuthTokenClaimsUnverified(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token claims"})
|
||||
return
|
||||
}
|
||||
|
||||
trackeepTokenString, err := GenerateJWTWithGitHubAccessToken(*localUser, getAccessTokenFromOAuthClaims(claims))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return fetchGitHubPrimaryVerifiedEmail(accessToken)
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current authenticated user with GitHub info
|
||||
@@ -302,13 +202,24 @@ func GetCurrentUserWithGitHub(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"user": currentUser})
|
||||
}
|
||||
func GetGitHubRepos(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
userID := getGitHubRequestUserID(c)
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
repos, err := fetchControlServiceGitHubRepos(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch repos from control service: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"repos": repos})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
@@ -316,36 +227,18 @@ func GetGitHubRepos(c *gin.Context) {
|
||||
}
|
||||
|
||||
if user.GitHubID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub not connected"})
|
||||
return
|
||||
if _, err := getGitHubUserAuthRecord(db, userID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub sign-in is not connected"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the JWT token from the request header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "No authorization header"})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
tokenString := authHeader
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
tokenString = authHeader[7:]
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(tokenString)
|
||||
githubAccessToken, _, err := getGitHubUserAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
|
||||
@@ -355,16 +248,32 @@ func GetGitHubRepos(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"repos": repos})
|
||||
}
|
||||
|
||||
// GitHubContribution represents a day's contribution data
|
||||
type GitHubContribution struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
Level int `json:"level"` // 0-5 intensity level
|
||||
}
|
||||
|
||||
// GitHubActivityResponse represents the response structure for GitHub activity
|
||||
type GitHubActivityResponse struct {
|
||||
Contributions []GitHubContribution `json:"contributions"`
|
||||
WeeklyData []int `json:"weekly_data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// fetchGitHubRepos fetches repositories from GitHub API
|
||||
func fetchGitHubRepos(accessToken string) ([]GitHubRepo, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/user/repos?type=owner&sort=updated&per_page=100", nil)
|
||||
req, err := http.NewRequest("GET", strings.TrimRight(gitHubAPIBaseURL, "/")+"/user/repos?type=owner&sort=updated&per_page=100", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "Trackeep")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -376,6 +285,9 @@ func fetchGitHubRepos(accessToken string) ([]GitHubRepo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub repos API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var repos []GitHubRepo
|
||||
if err := json.Unmarshal(body, &repos); err != nil {
|
||||
@@ -385,9 +297,139 @@ func fetchGitHubRepos(accessToken string) ([]GitHubRepo, error) {
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string for state parameter
|
||||
func generateRandomString(length int) string {
|
||||
bytes := make([]byte, length)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
// fetchGitHubContributions fetches contribution data from GitHub API
|
||||
func fetchGitHubContributions(accessToken string) (*GitHubActivityResponse, error) {
|
||||
client := &http.Client{}
|
||||
|
||||
// Fetch contribution data for the last year
|
||||
req, err := http.NewRequest("GET", strings.TrimRight(gitHubAPIBaseURL, "/")+"/search/issues?q=author:@me+created:>=2025-03-13&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")
|
||||
|
||||
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 contributions API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
// Parse the response to get activity data
|
||||
var issueResponse struct {
|
||||
Items []struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &issueResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate contribution data for the last year
|
||||
contributions := make([]GitHubContribution, 0)
|
||||
weeklyData := make([]int, 7)
|
||||
today := time.Now()
|
||||
|
||||
// Initialize contribution map
|
||||
contributionMap := make(map[string]int)
|
||||
|
||||
// Count contributions by date
|
||||
for _, item := range issueResponse.Items {
|
||||
date := item.CreatedAt[:10] // Extract date part
|
||||
contributionMap[date]++
|
||||
}
|
||||
|
||||
// Generate daily contribution data for the last year
|
||||
for i := 364; i >= 0; i-- {
|
||||
date := today.AddDate(0, 0, -i)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
count := contributionMap[dateStr]
|
||||
|
||||
// Calculate level (0-5 intensity)
|
||||
level := 0
|
||||
if count > 0 {
|
||||
if count <= 1 {
|
||||
level = 1
|
||||
} else if count <= 3 {
|
||||
level = 2
|
||||
} else if count <= 5 {
|
||||
level = 3
|
||||
} else if count <= 8 {
|
||||
level = 4
|
||||
} else {
|
||||
level = 5
|
||||
}
|
||||
}
|
||||
|
||||
contributions = append(contributions, GitHubContribution{
|
||||
Date: dateStr,
|
||||
Count: count,
|
||||
Level: level,
|
||||
})
|
||||
|
||||
// Calculate weekly data (last 7 days)
|
||||
if i < 7 {
|
||||
weeklyData[6-i] = count
|
||||
}
|
||||
}
|
||||
|
||||
totalCount := len(issueResponse.Items)
|
||||
|
||||
return &GitHubActivityResponse{
|
||||
Contributions: contributions,
|
||||
WeeklyData: weeklyData,
|
||||
TotalCount: totalCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGitHubActivity fetches GitHub contribution activity
|
||||
func GetGitHubActivity(c *gin.Context) {
|
||||
userID := getGitHubRequestUserID(c)
|
||||
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var githubAccessToken string
|
||||
var err error
|
||||
|
||||
// Try to get access token from control service first
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
// Use control service token if available
|
||||
tokenPayload, err := fetchControlServiceGitHubUserAccessToken(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get GitHub access token from control service: " + err.Error()})
|
||||
return
|
||||
}
|
||||
githubAccessToken = tokenPayload.AccessToken
|
||||
} else {
|
||||
// Fall back to user auth token
|
||||
githubAccessToken, _, err = getGitHubUserAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
activity, err := fetchGitHubContributions(githubAccessToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch GitHub activity: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/utils"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupGitHubAuthTestDB(t *testing.T, migrate ...interface{}) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + url.PathEscape(t.Name()) + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(migrate...); err != nil {
|
||||
t.Fatalf("failed to migrate test database: %v", err)
|
||||
}
|
||||
|
||||
previousDB := config.DB
|
||||
config.DB = db
|
||||
t.Cleanup(func() {
|
||||
config.DB = previousDB
|
||||
})
|
||||
|
||||
t.Setenv("VITE_DEMO_MODE", "false")
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("a", 64))
|
||||
t.Setenv("ENCRYPTION_KEY", "test-encryption-key")
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func withControlServiceBaseURL(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
|
||||
previous := controlServiceBaseURL
|
||||
controlServiceBaseURL = value
|
||||
t.Cleanup(func() {
|
||||
controlServiceBaseURL = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHubLoginRedirectsToControlService(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
withControlServiceBaseURL(t, "https://control.example.com")
|
||||
t.Setenv("PUBLIC_API_URL", "https://api.example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github?frontend_redirect="+url.QueryEscape("https://app.example.com/auth/callback"), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = req
|
||||
|
||||
GitHubLogin(ctx)
|
||||
|
||||
if rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
|
||||
location := rec.Header().Get("Location")
|
||||
parsed, err := url.Parse(location)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse redirect location: %v", err)
|
||||
}
|
||||
if parsed.Scheme != "https" || parsed.Host != "control.example.com" || parsed.Path != "/auth/github" {
|
||||
t.Fatalf("unexpected redirect location: %s", location)
|
||||
}
|
||||
if got := parsed.Query().Get("redirect_uri"); got != "https://api.example.com/api/v1/auth/control/callback" {
|
||||
t.Fatalf("unexpected redirect_uri: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOAuthCallbackStoresControllerSessionAndRedirects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupGitHubAuthTestDB(t, &models.User{}, &models.ControlServiceSession{})
|
||||
|
||||
controller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/auth/control/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(controlServiceTokenValidationResponse{
|
||||
Token: "controller-token-fresh",
|
||||
User: centralizedOAuthUser{
|
||||
ID: 77,
|
||||
GitHubID: 99,
|
||||
Username: "octocat",
|
||||
Email: "octo@example.com",
|
||||
Name: "The Octocat",
|
||||
AvatarURL: "https://example.com/octocat.png",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer controller.Close()
|
||||
withControlServiceBaseURL(t, controller.URL)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/control/callback?token=controller-token-old", nil)
|
||||
req.AddCookie(&http.Cookie{Name: controlServiceFrontendRedirectCookieName, Value: "https://app.example.com/auth/callback"})
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = req
|
||||
|
||||
HandleOAuthCallback(ctx)
|
||||
|
||||
if rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
location := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(location, "https://app.example.com/auth/callback?token=") {
|
||||
t.Fatalf("unexpected redirect location: %s", location)
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("github_id = ?", 99).First(&user).Error; err != nil {
|
||||
t.Fatalf("failed to load local user: %v", err)
|
||||
}
|
||||
|
||||
var session models.ControlServiceSession
|
||||
if err := db.Where("user_id = ?", user.ID).First(&session).Error; err != nil {
|
||||
t.Fatalf("failed to load controller session: %v", err)
|
||||
}
|
||||
decryptedToken, err := utils.Decrypt(session.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt controller token: %v", err)
|
||||
}
|
||||
if decryptedToken != "controller-token-fresh" {
|
||||
t.Fatalf("unexpected stored controller token: %s", decryptedToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitHubReposUsesControlServiceAndPersistsRefreshedToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupGitHubAuthTestDB(t, &models.User{}, &models.ControlServiceSession{})
|
||||
|
||||
user := models.User{
|
||||
Email: "octo@example.com",
|
||||
Username: "octocat",
|
||||
Password: "hashed-password",
|
||||
FullName: "Octocat",
|
||||
GitHubID: 99,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := utils.Encrypt("controller-token-old")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt controller token: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.ControlServiceSession{
|
||||
UserID: user.ID,
|
||||
ControllerUserID: 77,
|
||||
GitHubID: 99,
|
||||
Username: "octocat",
|
||||
Email: "octo@example.com",
|
||||
Token: encryptedToken,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create controller session: %v", err)
|
||||
}
|
||||
|
||||
controller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/github/repos" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer controller-token-old" {
|
||||
t.Fatalf("unexpected authorization header: %s", got)
|
||||
}
|
||||
w.Header().Set(controlServiceSessionTokenHeader, "controller-token-new")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"repos": []GitHubRepo{{
|
||||
ID: 1,
|
||||
Name: "trackeep",
|
||||
FullName: "octocat/trackeep",
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer controller.Close()
|
||||
withControlServiceBaseURL(t, controller.URL)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/repos", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = req
|
||||
ctx.Set("user_id", user.ID)
|
||||
|
||||
GetGitHubRepos(ctx)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "octocat/trackeep") {
|
||||
t.Fatalf("unexpected response body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
var updated models.ControlServiceSession
|
||||
if err := db.Where("user_id = ?", user.ID).First(&updated).Error; err != nil {
|
||||
t.Fatalf("failed to reload controller session: %v", err)
|
||||
}
|
||||
decryptedToken, err := utils.Decrypt(updated.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt refreshed controller token: %v", err)
|
||||
}
|
||||
if decryptedToken != "controller-token-new" {
|
||||
t.Fatalf("unexpected refreshed controller token: %s", decryptedToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubAppInstallCallbackRejectsInaccessibleInstallationViaControlService(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupGitHubAuthTestDB(t, &models.User{}, &models.ControlServiceSession{}, &models.GitHubAppInstallState{})
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.com")
|
||||
|
||||
user := models.User{
|
||||
Email: "octo@example.com",
|
||||
Username: "octocat",
|
||||
Password: "hashed-password",
|
||||
FullName: "Octocat",
|
||||
GitHubID: 99,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := utils.Encrypt("controller-token-old")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt controller token: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.ControlServiceSession{
|
||||
UserID: user.ID,
|
||||
ControllerUserID: 77,
|
||||
GitHubID: 99,
|
||||
Username: "octocat",
|
||||
Email: "octo@example.com",
|
||||
Token: encryptedToken,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create controller session: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&models.GitHubAppInstallState{
|
||||
UserID: user.ID,
|
||||
State: "install-state",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to create install state: %v", err)
|
||||
}
|
||||
|
||||
controller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/github/app/installations/999/verify" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "installation_not_accessible"})
|
||||
}))
|
||||
defer controller.Close()
|
||||
withControlServiceBaseURL(t, controller.URL)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/app/callback?state=install-state&installation_id=999&setup_action=install", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = req
|
||||
|
||||
GitHubAppInstallCallback(ctx)
|
||||
|
||||
if rec.Code != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
location := rec.Header().Get("Location")
|
||||
if !strings.Contains(location, "github_app_error=installation_not_accessible") {
|
||||
t.Fatalf("unexpected redirect location: %s", location)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ func GetGitHubAppStatus(c *gin.Context) {
|
||||
response := gin.H{
|
||||
"app_slug": getGitHubAppSlug(),
|
||||
"install_enabled": isGitHubAppInstallEnabled(),
|
||||
"sign_in_configured": hasGitHubUserAuthConfig(),
|
||||
"credentials_configured": hasGitHubAppCredentials(),
|
||||
"installed": false,
|
||||
}
|
||||
@@ -79,6 +80,18 @@ func GetGitHubAppStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
if info, infoErr := fetchControlServiceGitHubAppInfo(c.Request.Context(), db, userID); infoErr == nil && info != nil {
|
||||
response["app_slug"] = info.AppSlug
|
||||
response["install_enabled"] = info.InstallEnabled
|
||||
response["sign_in_configured"] = info.SignInConfigured
|
||||
response["credentials_configured"] = info.CredentialsConfigured
|
||||
} else {
|
||||
response["sign_in_configured"] = true
|
||||
response["credentials_configured"] = true
|
||||
}
|
||||
}
|
||||
|
||||
installation, err := getUserGitHubInstallation(db, userID)
|
||||
if err == nil {
|
||||
response["installed"] = true
|
||||
@@ -96,11 +109,6 @@ func GetGitHubAppInstallURL(c *gin.Context) {
|
||||
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"})
|
||||
@@ -119,6 +127,35 @@ func GetGitHubAppInstallURL(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
callbackURL := buildGitHubAppInstallCallbackURL(c.Request, state)
|
||||
if callbackURL == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to determine local install callback URL"})
|
||||
return
|
||||
}
|
||||
|
||||
installURL, err := fetchControlServiceGitHubAppInstallURL(c.Request.Context(), db, userID, callbackURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to create unified GitHub App install URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"install_url": installURL,
|
||||
"expires_at": expiresAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !isGitHubAppInstallEnabled() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App slug is not configured"})
|
||||
return
|
||||
}
|
||||
if _, _, err := getGitHubUserAccessTokenForUser(c.Request.Context(), db, userID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Sign in with GitHub before installing the GitHub App"})
|
||||
return
|
||||
}
|
||||
|
||||
installURL := fmt.Sprintf(
|
||||
"https://github.com/apps/%s/installations/new?state=%s",
|
||||
url.PathEscape(getGitHubAppSlug()),
|
||||
@@ -172,13 +209,32 @@ func GitHubAppInstallCallback(c *gin.Context) {
|
||||
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
|
||||
if _, err := getControlServiceSessionRecord(db, stateRecord.UserID); err == nil {
|
||||
verification, verifyErr := verifyControlServiceGitHubInstallation(c.Request.Context(), db, stateRecord.UserID, installationID)
|
||||
if verifyErr != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_not_accessible")
|
||||
return
|
||||
}
|
||||
accountLogin = verification.AccountLogin
|
||||
accountType = verification.AccountType
|
||||
if verification.AppSlug != "" {
|
||||
lastValidatedNow := time.Now()
|
||||
lastValidated = &lastValidatedNow
|
||||
}
|
||||
} else {
|
||||
if err := verifyGitHubInstallationAccessForUser(c.Request.Context(), db, stateRecord.UserID, installationID); err != nil {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_not_accessible")
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +242,16 @@ func GitHubAppInstallCallback(c *gin.Context) {
|
||||
lookupErr := db.Where("installation_id = ?", installationID).First(&installation).Error
|
||||
switch {
|
||||
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
||||
appSlug := getGitHubAppSlug()
|
||||
if accountLogin == "" && accountType == "" {
|
||||
if info, infoErr := fetchControlServiceGitHubAppInfo(c.Request.Context(), db, stateRecord.UserID); infoErr == nil && info != nil && info.AppSlug != "" {
|
||||
appSlug = info.AppSlug
|
||||
}
|
||||
}
|
||||
installation = models.GitHubAppInstallation{
|
||||
UserID: stateRecord.UserID,
|
||||
InstallationID: installationID,
|
||||
AppSlug: getGitHubAppSlug(),
|
||||
AppSlug: appSlug,
|
||||
AccountLogin: accountLogin,
|
||||
AccountType: accountType,
|
||||
LastValidated: lastValidated,
|
||||
@@ -202,9 +264,13 @@ func GitHubAppInstallCallback(c *gin.Context) {
|
||||
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_lookup_failed")
|
||||
return
|
||||
default:
|
||||
appSlug := getGitHubAppSlug()
|
||||
if info, infoErr := fetchControlServiceGitHubAppInfo(c.Request.Context(), db, stateRecord.UserID); infoErr == nil && info != nil && info.AppSlug != "" {
|
||||
appSlug = info.AppSlug
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"user_id": stateRecord.UserID,
|
||||
"app_slug": getGitHubAppSlug(),
|
||||
"app_slug": appSlug,
|
||||
"account_login": accountLogin,
|
||||
"account_type": accountType,
|
||||
}
|
||||
@@ -246,6 +312,20 @@ func GetGitHubAppRepos(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
repos, fetchErr := fetchControlServiceGitHubAppRepos(c.Request.Context(), db, userID, installation.InstallationID)
|
||||
if fetchErr != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch installation repos from control service: " + fetchErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": "github_app",
|
||||
"installation_id": installation.InstallationID,
|
||||
"repos": repos,
|
||||
})
|
||||
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()})
|
||||
@@ -328,7 +408,7 @@ func BackupGitHubRepositories(c *gin.Context) {
|
||||
|
||||
knownRepos := make(map[string]GitHubRepo)
|
||||
switch source {
|
||||
case "oauth":
|
||||
case "github_user":
|
||||
repos, reposErr := fetchGitHubRepos(accessToken)
|
||||
if reposErr == nil {
|
||||
for _, repo := range repos {
|
||||
@@ -462,19 +542,30 @@ func getUserGitHubInstallation(db *gorm.DB, userID uint) (*models.GitHubAppInsta
|
||||
}
|
||||
|
||||
func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
|
||||
if _, err := getControlServiceSessionRecord(db, userID); err == nil {
|
||||
if token, source, installationID, brokerErr := resolveCentralizedGitHubBackupToken(c.Request.Context(), db, userID, requestedSource); brokerErr == nil {
|
||||
return token, source, installationID, nil
|
||||
} else if strings.TrimSpace(requestedSource) != "" {
|
||||
return "", "", nil, brokerErr
|
||||
}
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(requestedSource))
|
||||
switch source {
|
||||
case "", "oauth":
|
||||
accessToken, err := getGitHubOAuthAccessTokenFromHeader(c)
|
||||
case "", "oauth", "github_user", "user":
|
||||
accessToken, _, err := getGitHubUserAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if err == nil {
|
||||
return accessToken, "oauth", nil, nil
|
||||
return accessToken, "github_user", nil, nil
|
||||
}
|
||||
if source != "" {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
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")
|
||||
return "", "", nil, fmt.Errorf("no usable GitHub sign-in token and GitHub App fallback failed")
|
||||
case "github_app", "app":
|
||||
accessToken, installationID, err := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
|
||||
if err != nil {
|
||||
@@ -486,31 +577,44 @@ func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requeste
|
||||
}
|
||||
}
|
||||
|
||||
func getGitHubOAuthAccessTokenFromHeader(c *gin.Context) (string, error) {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader == "" {
|
||||
return "", errors.New("authorization header required")
|
||||
}
|
||||
func resolveCentralizedGitHubBackupToken(ctx context.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
|
||||
source := strings.ToLower(strings.TrimSpace(requestedSource))
|
||||
switch source {
|
||||
case "", "oauth", "github_user", "user":
|
||||
accessToken, err := fetchControlServiceGitHubUserAccessToken(ctx, db, userID)
|
||||
if err == nil {
|
||||
return accessToken.AccessToken, "github_user", nil, nil
|
||||
}
|
||||
if source != "" {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
tokenString := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
tokenString = strings.TrimSpace(authHeader[7:])
|
||||
}
|
||||
if tokenString == "" {
|
||||
return "", errors.New("invalid authorization header")
|
||||
}
|
||||
installation, installErr := getUserGitHubInstallation(db, userID)
|
||||
if installErr != nil {
|
||||
return "", "", nil, fmt.Errorf("no usable GitHub sign-in token and GitHub App fallback failed")
|
||||
}
|
||||
appToken, appErr := fetchControlServiceGitHubInstallationAccessToken(ctx, db, userID, installation.InstallationID)
|
||||
if appErr != nil {
|
||||
return "", "", nil, fmt.Errorf("no usable GitHub sign-in token and GitHub App fallback failed")
|
||||
}
|
||||
return appToken.AccessToken, "github_app", &installation.InstallationID, nil
|
||||
case "github_app", "app":
|
||||
installation, err := getUserGitHubInstallation(db, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", nil, errors.New("GitHub App not installed for this user")
|
||||
}
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
appToken, err := fetchControlServiceGitHubInstallationAccessToken(ctx, db, userID, installation.InstallationID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return appToken.AccessToken, "github_app", &installation.InstallationID, nil
|
||||
default:
|
||||
return "", "", nil, fmt.Errorf("unsupported source '%s'", requestedSource)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/models"
|
||||
"github.com/trackeep/backend/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
gitHubAuthorizeURL = "https://github.com/login/oauth/authorize"
|
||||
gitHubTokenURL = "https://github.com/login/oauth/access_token"
|
||||
gitHubAPIBaseURL = "https://api.github.com"
|
||||
)
|
||||
|
||||
const (
|
||||
gitHubAuthStateCookieName = "github_auth_state"
|
||||
gitHubAuthFrontendRedirectCookieName = "github_auth_frontend_redirect"
|
||||
gitHubAuthCookieMaxAgeSeconds = 600
|
||||
gitHubTokenRefreshSkew = 2 * time.Minute
|
||||
)
|
||||
|
||||
type gitHubUserTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
ErrorURI string `json:"error_uri"`
|
||||
}
|
||||
|
||||
type gitHubUserEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type gitHubUserInstallationsResponse struct {
|
||||
Installations []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"installations"`
|
||||
}
|
||||
|
||||
func getGitHubAppClientID() string {
|
||||
return strings.TrimSpace(os.Getenv("GITHUB_APP_CLIENT_ID"))
|
||||
}
|
||||
|
||||
func getGitHubAppClientSecret() string {
|
||||
return strings.TrimSpace(os.Getenv("GITHUB_APP_CLIENT_SECRET"))
|
||||
}
|
||||
|
||||
func hasGitHubUserAuthConfig() bool {
|
||||
return getGitHubAppClientID() != "" && getGitHubAppClientSecret() != ""
|
||||
}
|
||||
|
||||
func isSecureRequest(r *http.Request) bool {
|
||||
if strings.EqualFold(headerValue(r.Header, "X-Forwarded-Proto"), "https") {
|
||||
return true
|
||||
}
|
||||
return r.TLS != nil
|
||||
}
|
||||
|
||||
func setGitHubAuthCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(name, value, maxAge, "/", "", isSecureRequest(c.Request), true)
|
||||
}
|
||||
|
||||
func storeGitHubAuthFlowState(c *gin.Context, state, frontendRedirect string) {
|
||||
setGitHubAuthCookie(c, gitHubAuthStateCookieName, state, gitHubAuthCookieMaxAgeSeconds)
|
||||
if frontendRedirect != "" {
|
||||
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, frontendRedirect, gitHubAuthCookieMaxAgeSeconds)
|
||||
return
|
||||
}
|
||||
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, "", -1)
|
||||
}
|
||||
|
||||
func clearGitHubAuthFlowState(c *gin.Context) {
|
||||
setGitHubAuthCookie(c, gitHubAuthStateCookieName, "", -1)
|
||||
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, "", -1)
|
||||
}
|
||||
|
||||
func getGitHubFrontendRedirectFromCookie(c *gin.Context) string {
|
||||
raw, err := c.Cookie(gitHubAuthFrontendRedirectCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeFrontendRedirectURL(raw)
|
||||
}
|
||||
|
||||
func postGitHubTokenRequest(ctx context.Context, form url.Values) (*gitHubUserTokenResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, gitHubTokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
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 token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var payload gitHubUserTokenResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.Error != "" {
|
||||
message := payload.ErrorDescription
|
||||
if message == "" {
|
||||
message = payload.Error
|
||||
}
|
||||
return nil, fmt.Errorf("GitHub token exchange failed: %s", message)
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return nil, errors.New("GitHub returned an empty access token")
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func exchangeGitHubAuthorizationCode(ctx context.Context, code, redirectURL string) (*gitHubUserTokenResponse, error) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return nil, errors.New("missing GitHub authorization code")
|
||||
}
|
||||
if !hasGitHubUserAuthConfig() {
|
||||
return nil, errors.New("GitHub App sign-in is not configured")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", getGitHubAppClientID())
|
||||
form.Set("client_secret", getGitHubAppClientSecret())
|
||||
form.Set("code", code)
|
||||
if redirectURL != "" {
|
||||
form.Set("redirect_uri", redirectURL)
|
||||
}
|
||||
|
||||
return postGitHubTokenRequest(ctx, form)
|
||||
}
|
||||
|
||||
func refreshGitHubUserAccessToken(ctx context.Context, refreshToken string) (*gitHubUserTokenResponse, error) {
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, errors.New("missing GitHub refresh token")
|
||||
}
|
||||
if !hasGitHubUserAuthConfig() {
|
||||
return nil, errors.New("GitHub App sign-in is not configured")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", getGitHubAppClientID())
|
||||
form.Set("client_secret", getGitHubAppClientSecret())
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refreshToken)
|
||||
|
||||
return postGitHubTokenRequest(ctx, form)
|
||||
}
|
||||
|
||||
func tokenExpiryFromSeconds(seconds int64) *time.Time {
|
||||
if seconds <= 0 {
|
||||
return nil
|
||||
}
|
||||
expiresAt := time.Now().Add(time.Duration(seconds) * time.Second)
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
func upsertGitHubUserAuth(db *gorm.DB, userID uint, gitHubUser *GitHubUser, tokenResponse *gitHubUserTokenResponse) error {
|
||||
if db == nil {
|
||||
return errors.New("database not available")
|
||||
}
|
||||
if gitHubUser == nil {
|
||||
return errors.New("GitHub user is required")
|
||||
}
|
||||
if tokenResponse == nil || strings.TrimSpace(tokenResponse.AccessToken) == "" {
|
||||
return errors.New("GitHub access token is required")
|
||||
}
|
||||
|
||||
encryptedAccessToken, err := utils.Encrypt(tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt GitHub access token: %w", err)
|
||||
}
|
||||
|
||||
encryptedRefreshToken := ""
|
||||
if strings.TrimSpace(tokenResponse.RefreshToken) != "" {
|
||||
encryptedRefreshToken, err = utils.Encrypt(tokenResponse.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt GitHub refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var existing models.GitHubUserAuth
|
||||
lookupErr := db.Where("user_id = ? OR github_user_id = ?", userID, gitHubUser.ID).First(&existing).Error
|
||||
|
||||
switch {
|
||||
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
||||
record := models.GitHubUserAuth{
|
||||
UserID: userID,
|
||||
GitHubUserID: gitHubUser.ID,
|
||||
GitHubLogin: gitHubUser.Login,
|
||||
AccessToken: encryptedAccessToken,
|
||||
RefreshToken: encryptedRefreshToken,
|
||||
AccessTokenExpiresAt: tokenExpiryFromSeconds(tokenResponse.ExpiresIn),
|
||||
RefreshTokenExpiresAt: tokenExpiryFromSeconds(tokenResponse.RefreshTokenExpiresIn),
|
||||
LastRefreshedAt: &now,
|
||||
}
|
||||
return db.Create(&record).Error
|
||||
case lookupErr != nil:
|
||||
return lookupErr
|
||||
default:
|
||||
updates := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"github_user_id": gitHubUser.ID,
|
||||
"github_login": gitHubUser.Login,
|
||||
"access_token": encryptedAccessToken,
|
||||
"access_token_expires_at": tokenExpiryFromSeconds(tokenResponse.ExpiresIn),
|
||||
"last_refreshed_at": &now,
|
||||
}
|
||||
if encryptedRefreshToken != "" {
|
||||
updates["refresh_token"] = encryptedRefreshToken
|
||||
updates["refresh_token_expires_at"] = tokenExpiryFromSeconds(tokenResponse.RefreshTokenExpiresIn)
|
||||
}
|
||||
return db.Model(&existing).Updates(updates).Error
|
||||
}
|
||||
}
|
||||
|
||||
func getGitHubUserAuthRecord(db *gorm.DB, userID uint) (*models.GitHubUserAuth, error) {
|
||||
var auth models.GitHubUserAuth
|
||||
if err := db.Where("user_id = ?", userID).First(&auth).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &auth, nil
|
||||
}
|
||||
|
||||
func decryptGitHubUserToken(ciphertext string) (string, error) {
|
||||
plaintext, err := utils.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(plaintext), nil
|
||||
}
|
||||
|
||||
func getGitHubUserAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, *models.GitHubUserAuth, error) {
|
||||
authRecord, err := getGitHubUserAuthRecord(db, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, errors.New("GitHub sign-in is not connected for this user")
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if authRecord.AccessTokenExpiresAt == nil || time.Until(*authRecord.AccessTokenExpiresAt) > gitHubTokenRefreshSkew {
|
||||
accessToken, err := decryptGitHubUserToken(authRecord.AccessToken)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decrypt GitHub access token: %w", err)
|
||||
}
|
||||
if accessToken == "" {
|
||||
return "", nil, errors.New("GitHub access token is empty")
|
||||
}
|
||||
return accessToken, authRecord, nil
|
||||
}
|
||||
|
||||
if authRecord.RefreshTokenExpiresAt != nil && time.Now().After(*authRecord.RefreshTokenExpiresAt) {
|
||||
return "", nil, errors.New("GitHub session expired. Please sign in with GitHub again")
|
||||
}
|
||||
if strings.TrimSpace(authRecord.RefreshToken) == "" {
|
||||
return "", nil, errors.New("GitHub session expired. Please sign in with GitHub again")
|
||||
}
|
||||
|
||||
refreshToken, err := decryptGitHubUserToken(authRecord.RefreshToken)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decrypt GitHub refresh token: %w", err)
|
||||
}
|
||||
refreshedToken, err := refreshGitHubUserAccessToken(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if refreshedToken.RefreshToken == "" {
|
||||
refreshedToken.RefreshToken = refreshToken
|
||||
if authRecord.RefreshTokenExpiresAt != nil {
|
||||
remaining := time.Until(*authRecord.RefreshTokenExpiresAt)
|
||||
if remaining > 0 {
|
||||
refreshedToken.RefreshTokenExpiresIn = int64(remaining.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := upsertGitHubUserAuth(db, userID, &GitHubUser{
|
||||
ID: authRecord.GitHubUserID,
|
||||
Login: authRecord.GitHubLogin,
|
||||
}, refreshedToken); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
updatedRecord, err := getGitHubUserAuthRecord(db, userID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
accessToken, err := decryptGitHubUserToken(updatedRecord.AccessToken)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to decrypt refreshed GitHub access token: %w", err)
|
||||
}
|
||||
return accessToken, updatedRecord, nil
|
||||
}
|
||||
|
||||
func fetchGitHubPrimaryVerifiedEmail(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(gitHubAPIBaseURL, "/")+"/user/emails", nil)
|
||||
if err != nil {
|
||||
return "", 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 "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("GitHub email API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var emails []gitHubUserEmail
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, email := range emails {
|
||||
if email.Primary && email.Verified {
|
||||
return strings.TrimSpace(email.Email), nil
|
||||
}
|
||||
}
|
||||
for _, email := range emails {
|
||||
if email.Verified {
|
||||
return strings.TrimSpace(email.Email), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("no verified GitHub email found")
|
||||
}
|
||||
|
||||
func listGitHubUserInstallations(ctx context.Context, accessToken string) ([]int64, error) {
|
||||
installations := make([]int64, 0)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
for page := 1; page <= 10; page++ {
|
||||
reqURL := fmt.Sprintf("%s/user/installations?per_page=100&page=%d", strings.TrimRight(gitHubAPIBaseURL, "/"), page)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, 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")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GitHub installations API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||
}
|
||||
|
||||
var payload gitHubUserInstallationsResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, installation := range payload.Installations {
|
||||
installations = append(installations, installation.ID)
|
||||
}
|
||||
if len(payload.Installations) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return installations, nil
|
||||
}
|
||||
|
||||
func verifyGitHubInstallationAccessForUser(ctx context.Context, db *gorm.DB, userID uint, installationID int64) error {
|
||||
accessToken, _, err := getGitHubUserAccessTokenForUser(ctx, db, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installations, err := listGitHubUserInstallations(ctx, accessToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range installations {
|
||||
if id == installationID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("the GitHub installation is not accessible to the signed-in GitHub user")
|
||||
}
|
||||
@@ -1,26 +1,20 @@
|
||||
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/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
const defaultOAuthServiceURL = "https://oauth.trackeep.org"
|
||||
|
||||
type centralizedOAuthUser struct {
|
||||
ID int `json:"id"`
|
||||
GitHubID int `json:"github_id"`
|
||||
@@ -30,20 +24,8 @@ type centralizedOAuthUser struct {
|
||||
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, "/")
|
||||
return config.ControlServiceURL
|
||||
}
|
||||
|
||||
func headerValue(headers http.Header, key string) string {
|
||||
@@ -133,23 +115,17 @@ func resolveFrontendRedirectURL(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildOAuthCallbackURL(r *http.Request, frontendRedirect string) string {
|
||||
func buildGitHubUserCallbackURL(r *http.Request) string {
|
||||
baseURL := backendPublicBaseURL(r)
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/oauth/callback")
|
||||
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/github/callback")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if frontendRedirect != "" {
|
||||
query := callbackURL.Query()
|
||||
query.Set("frontend_redirect", frontendRedirect)
|
||||
callbackURL.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
return callbackURL.String()
|
||||
}
|
||||
|
||||
@@ -173,73 +149,6 @@ func buildFrontendCallbackRedirectURL(frontendRedirect, token string) string {
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@ type BraveSearchResult struct {
|
||||
|
||||
// SearchWeb handles POST /api/v1/search/web
|
||||
func SearchWeb(c *gin.Context) {
|
||||
fmt.Printf("DEBUG: SearchWeb function called\n")
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
@@ -233,7 +232,6 @@ func SearchWeb(c *gin.Context) {
|
||||
|
||||
// SearchNews handles POST /api/v1/search/news
|
||||
func SearchNews(c *gin.Context) {
|
||||
fmt.Printf("DEBUG: SearchNews function called\n")
|
||||
var req struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
@@ -304,18 +302,18 @@ func SearchNews(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Raw Brave News API response: %s\n", string(bodyBytes))
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
fmt.Printf("DEBUG: JSON decode error: %v\n", err)
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
fmt.Printf("DEBUG: Parsed BraveNewsResponse: %+v\n", braveResp)
|
||||
fmt.Printf("DEBUG: Number of results: %d\n", len(braveResp.Results))
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
@@ -400,18 +398,18 @@ func SearchNews(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Raw Brave News API response: %s\n", string(bodyBytes))
|
||||
|
||||
|
||||
var braveResp BraveNewsResponse
|
||||
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||
fmt.Printf("DEBUG: JSON decode error: %v\n", err)
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
fmt.Printf("DEBUG: Parsed BraveNewsResponse: %+v\n", braveResp)
|
||||
fmt.Printf("DEBUG: Number of results: %d\n", len(braveResp.Results))
|
||||
|
||||
|
||||
|
||||
resultsRaw := braveResp.Results
|
||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -152,7 +153,7 @@ func GenerateEmbedding(c *gin.Context) {
|
||||
|
||||
if err := db.Create(&contentEmbedding).Error; err != nil {
|
||||
// Log error but don't fail the request
|
||||
fmt.Printf("Failed to store embedding: %v\n", err)
|
||||
log.Printf("Failed to store embedding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +460,7 @@ func generateHighlights(text string, count int) []string {
|
||||
|
||||
// reindexUserContent reindexes all content for a user
|
||||
func reindexUserContent(db *gorm.DB, userID uint) {
|
||||
fmt.Printf("Starting reindexing for user %d\n", userID)
|
||||
log.Printf("Starting reindexing for user %d", userID)
|
||||
|
||||
// Reindex bookmarks
|
||||
var bookmarks []models.Bookmark
|
||||
@@ -537,7 +538,7 @@ func reindexUserContent(db *gorm.DB, userID uint) {
|
||||
upsertEmbedding(db, userID, "chat_message", message.ID, message.Body)
|
||||
}
|
||||
|
||||
fmt.Printf("Reindexing completed for user %d\n", userID)
|
||||
log.Printf("Reindexing completed for user %d", userID)
|
||||
}
|
||||
|
||||
func upsertEmbedding(db *gorm.DB, userID uint, contentType string, contentID uint, text string) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/trackeep/backend/config"
|
||||
"github.com/trackeep/backend/models"
|
||||
)
|
||||
|
||||
@@ -49,7 +50,7 @@ func UpdateUpdateSettings(c *gin.Context) {
|
||||
|
||||
// Update model
|
||||
updatedSettings := &models.UserUpdateSettings{
|
||||
OAuthServiceURL: newSettings.OAuthServiceURL,
|
||||
OAuthServiceURL: config.ControlServiceURL,
|
||||
AutoUpdateCheck: newSettings.AutoUpdateCheck,
|
||||
UpdateCheckInterval: newSettings.UpdateCheckInterval,
|
||||
PrereleaseUpdates: newSettings.PrereleaseUpdates,
|
||||
@@ -91,7 +92,7 @@ func GetUpdateSettingsForAPI(userID int) (UpdateSettings, error) {
|
||||
|
||||
func getDefaultUpdateSettings() UpdateSettings {
|
||||
return UpdateSettings{
|
||||
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.trackeep.org"),
|
||||
OAuthServiceURL: config.ControlServiceURL,
|
||||
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
|
||||
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
|
||||
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
||||
|
||||
@@ -833,7 +833,8 @@ func restartApplication() {
|
||||
return
|
||||
}
|
||||
|
||||
// Exit the current process
|
||||
// Exit the current process gracefully
|
||||
log.Println("Exiting current process to complete update")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -454,7 +455,7 @@ func (h *WebScrapingHandler) scrapeWebPage(pageURL string, job models.ScrapingJo
|
||||
|
||||
// Set error handler
|
||||
c.OnError(func(r *colly.Response, err error) {
|
||||
fmt.Printf("Error scraping %s: %v\n", r.Request.URL, err)
|
||||
log.Printf("Error scraping %s: %v", r.Request.URL, err)
|
||||
})
|
||||
|
||||
// Start scraping
|
||||
|
||||
Reference in New Issue
Block a user