small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:01 +02:00
parent 954a1a1080
commit c6a99c7e21
214 changed files with 40237 additions and 2828 deletions
+110 -21
View File
@@ -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()
}
}
+7 -16
View File
@@ -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
+521
View File
@@ -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)
}
+244
View File
@@ -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
View File
@@ -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)
}
+286
View File
@@ -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)
}
}
+145 -41
View File
@@ -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) {
+434
View File
@@ -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")
}
+4 -95
View File
@@ -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)
-95
View File
@@ -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)
}
}
+8 -10
View File
@@ -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))
+4 -3
View File
@@ -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) {
+3 -2
View File
@@ -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),
+2 -1
View File
@@ -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 -1
View File
@@ -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