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:
@@ -41,7 +41,7 @@ type AppConfig struct {
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnvWithDefault("BACKEND_PORT", getEnvWithDefault("PORT", "8080")),
|
||||
Port: getEnvWithDefault("PORT", getEnvWithDefault("BACKEND_PORT", "8080")),
|
||||
ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second),
|
||||
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second),
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/trackeep/backend/migrations"
|
||||
"go.uber.org/zap"
|
||||
@@ -24,6 +25,10 @@ func getJWTSecret() string {
|
||||
return "your-secret-key-change-in-production"
|
||||
}
|
||||
|
||||
func shouldRunLegacySQLMigrations() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("RUN_LEGACY_SQL_MIGRATIONS")), "true")
|
||||
}
|
||||
|
||||
// InitDatabase initializes the database connection
|
||||
func InitDatabase() {
|
||||
// Initialize logger first
|
||||
@@ -39,7 +44,9 @@ func InitDatabase() {
|
||||
var err error
|
||||
|
||||
// Configure GORM
|
||||
gormConfig := &gorm.Config{}
|
||||
gormConfig := &gorm.Config{
|
||||
DisableForeignKeyConstraintWhenMigrating: true,
|
||||
}
|
||||
|
||||
dbType := os.Getenv("DB_TYPE")
|
||||
if dbType == "" {
|
||||
@@ -68,9 +75,15 @@ func InitDatabase() {
|
||||
|
||||
logger.Info("Database connected successfully")
|
||||
|
||||
// Run database migrations
|
||||
if err := migrations.RunMigrations(); err != nil {
|
||||
logger.Fatal("Failed to run database migrations", zap.Error(err))
|
||||
// The checked-in Goose bootstrap targets an older UUID-based schema.
|
||||
// Use it only when explicitly requested; the current application schema is
|
||||
// maintained via GORM auto-migrations during startup.
|
||||
if shouldRunLegacySQLMigrations() {
|
||||
if err := migrations.RunMigrations(); err != nil {
|
||||
logger.Fatal("Failed to run legacy database migrations", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
logger.Info("Skipping legacy SQL migrations; relying on GORM auto-migration for the current schema")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProductionConfig holds production-specific configuration
|
||||
type ProductionConfig struct {
|
||||
// Database connection pooling
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
|
||||
// Rate limiting
|
||||
EnableRateLimiting bool
|
||||
RateLimitPerMinute int
|
||||
|
||||
// Logging
|
||||
LogLevel string
|
||||
EnableMetrics bool
|
||||
|
||||
// Security
|
||||
EnableCSRF bool
|
||||
SecureCookies bool
|
||||
HTTPSOnly bool
|
||||
HSTSMaxAge int
|
||||
ContentSecPolicy string
|
||||
|
||||
// Performance
|
||||
EnableGzip bool
|
||||
EnableCaching bool
|
||||
CacheTTL time.Duration
|
||||
EnableCompression bool
|
||||
|
||||
// Monitoring
|
||||
EnableHealthChecks bool
|
||||
HealthCheckPath string
|
||||
MetricsPath string
|
||||
}
|
||||
|
||||
// DefaultProductionConfig returns default production configuration
|
||||
func DefaultProductionConfig() ProductionConfig {
|
||||
return ProductionConfig{
|
||||
// Database
|
||||
MaxOpenConns: 25,
|
||||
MaxIdleConns: 10,
|
||||
ConnMaxLifetime: time.Hour,
|
||||
ConnMaxIdleTime: 10 * time.Minute,
|
||||
|
||||
// Rate limiting
|
||||
EnableRateLimiting: true,
|
||||
RateLimitPerMinute: 60,
|
||||
|
||||
// Logging
|
||||
LogLevel: "info",
|
||||
EnableMetrics: true,
|
||||
|
||||
// Security
|
||||
EnableCSRF: true,
|
||||
SecureCookies: true,
|
||||
HTTPSOnly: true,
|
||||
HSTSMaxAge: 31536000, // 1 year
|
||||
ContentSecPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
|
||||
|
||||
// Performance
|
||||
EnableGzip: true,
|
||||
EnableCaching: true,
|
||||
CacheTTL: 5 * time.Minute,
|
||||
EnableCompression: true,
|
||||
|
||||
// Monitoring
|
||||
EnableHealthChecks: true,
|
||||
HealthCheckPath: "/health",
|
||||
MetricsPath: "/metrics",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package config
|
||||
|
||||
const ControlServiceURL = "https://hq.trackeep.org"
|
||||
+6
-6
@@ -10,14 +10,17 @@ require (
|
||||
github.com/gocolly/colly/v2 v2.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.17.0
|
||||
gorm.io/driver/postgres v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -47,7 +50,6 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -55,11 +57,10 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lib/pq v1.11.2 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -72,7 +73,6 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
|
||||
+8
-5
@@ -28,7 +28,6 @@ github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/e
|
||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -121,6 +120,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -175,6 +176,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
@@ -210,8 +213,6 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -285,8 +286,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
|
||||
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
|
||||
+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
|
||||
|
||||
+15
-11
@@ -111,7 +111,9 @@ func main() {
|
||||
if !cfg.App.DemoMode {
|
||||
config.InitDatabase()
|
||||
models.InitDB()
|
||||
models.AutoMigrate()
|
||||
if err := models.AutoMigrate(); err != nil {
|
||||
log.Fatal("Failed to auto-migrate database:", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Demo mode enabled, skipping database initialization")
|
||||
}
|
||||
@@ -223,24 +225,22 @@ func main() {
|
||||
{
|
||||
// Auth routes
|
||||
auth := v1.Group("/auth")
|
||||
auth.Use(middleware.AuthRateLimit(rateLimiters["auth"]))
|
||||
{
|
||||
auth.GET("/check-users", handlers.CheckUsers)
|
||||
auth.POST("/register", handlers.Register)
|
||||
auth.POST("/login", handlers.Login)
|
||||
auth.POST("/login-totp", handlers.LoginWithTOTP)
|
||||
auth.POST("/register", middleware.AuthRateLimit(rateLimiters["auth"]), handlers.Register)
|
||||
auth.POST("/login", middleware.AuthRateLimit(rateLimiters["auth"]), handlers.Login)
|
||||
auth.POST("/login-totp", middleware.AuthRateLimit(rateLimiters["auth"]), handlers.LoginWithTOTP)
|
||||
auth.POST("/logout", handlers.Logout)
|
||||
auth.GET("/me", handlers.AuthMiddleware(), handlers.GetCurrentUserWithGitHub)
|
||||
auth.POST("/password-reset", handlers.RequestPasswordReset)
|
||||
auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset)
|
||||
auth.POST("/password-reset", middleware.AuthRateLimit(rateLimiters["auth"]), handlers.RequestPasswordReset)
|
||||
auth.POST("/password-reset/confirm", middleware.AuthRateLimit(rateLimiters["auth"]), handlers.ConfirmPasswordReset)
|
||||
auth.GET("/control/callback", handlers.HandleOAuthCallback)
|
||||
|
||||
// GitHub OAuth routes
|
||||
// Unified GitHub sign-in route
|
||||
auth.GET("/github", handlers.GitHubLogin)
|
||||
auth.GET("/github/callback", handlers.GitHubCallback)
|
||||
auth.GET("/oauth/callback", handlers.HandleOAuthCallback)
|
||||
}
|
||||
|
||||
// GitHub App callback (public for GitHub redirect)
|
||||
// GitHub App callback (public for control service redirect)
|
||||
v1.GET("/github/app/callback", handlers.GitHubAppInstallCallback)
|
||||
|
||||
// GitHub routes (protected)
|
||||
@@ -253,6 +253,7 @@ func main() {
|
||||
github.GET("/app/repos", handlers.GetGitHubAppRepos)
|
||||
github.GET("/backups", handlers.GetGitHubBackups)
|
||||
github.POST("/backups", handlers.BackupGitHubRepositories)
|
||||
github.GET("/activity", handlers.GetGitHubActivity)
|
||||
}
|
||||
|
||||
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
|
||||
@@ -343,6 +344,9 @@ func main() {
|
||||
files.POST("/upload", handlers.UploadFile)
|
||||
files.GET("/:id", handlers.GetFile)
|
||||
files.GET("/:id/download", handlers.DownloadFile)
|
||||
files.POST("/:id/share", handlers.CreateFileShare)
|
||||
files.GET("/:id/shares", handlers.GetFileShares)
|
||||
files.DELETE("/:id/shares/:shareId", handlers.DeleteFileShare)
|
||||
files.DELETE("/:id", handlers.DeleteFile)
|
||||
|
||||
// Encrypted files
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ErrorResponse represents a standardized error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorHandlerMiddleware handles panics and errors
|
||||
func ErrorHandlerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// Log the error with stack trace
|
||||
log.Printf("Panic recovered: %v\n%s", err, debug.Stack())
|
||||
|
||||
// Return error response
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
Error: "Internal server error",
|
||||
Message: "An unexpected error occurred. Please try again later.",
|
||||
Code: "INTERNAL_ERROR",
|
||||
})
|
||||
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
|
||||
// Check for errors after request processing
|
||||
if len(c.Errors) > 0 {
|
||||
err := c.Errors.Last()
|
||||
log.Printf("Request error: %v", err.Err)
|
||||
|
||||
// Return appropriate error response based on status code
|
||||
statusCode := c.Writer.Status()
|
||||
if statusCode == http.StatusOK {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
|
||||
c.JSON(statusCode, ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Message: "Request processing failed",
|
||||
Code: "REQUEST_ERROR",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotFoundHandler handles 404 errors
|
||||
func NotFoundHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{
|
||||
Error: "Not found",
|
||||
Message: "The requested resource was not found",
|
||||
Code: "NOT_FOUND",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MethodNotAllowedHandler handles 405 errors
|
||||
func MethodNotAllowedHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusMethodNotAllowed, ErrorResponse{
|
||||
Error: "Method not allowed",
|
||||
Message: "The HTTP method is not allowed for this endpoint",
|
||||
Code: "METHOD_NOT_ALLOWED",
|
||||
})
|
||||
}
|
||||
}
|
||||
+33
-33
@@ -31,22 +31,22 @@ const (
|
||||
type AuditResource string
|
||||
|
||||
const (
|
||||
AuditResourceUser AuditResource = "user"
|
||||
AuditResourceNote AuditResource = "note"
|
||||
AuditResourceFile AuditResource = "file"
|
||||
AuditResourceBookmark AuditResource = "bookmark"
|
||||
AuditResourceTask AuditResource = "task"
|
||||
AuditResourceTimeEntry AuditResource = "time_entry"
|
||||
AuditResourceIntegration AuditResource = "integration"
|
||||
AuditResourceTeam AuditResource = "team"
|
||||
AuditResourceGoal AuditResource = "goal"
|
||||
AuditResourceHabit AuditResource = "habit"
|
||||
AuditResourceCalendar AuditResource = "calendar"
|
||||
AuditResourceSearch AuditResource = "search"
|
||||
AuditResourceAI AuditResource = "ai"
|
||||
AuditResourceAnalytics AuditResource = "analytics"
|
||||
AuditResourceSecurity AuditResource = "security"
|
||||
AuditResourceSystem AuditResource = "system"
|
||||
AuditResourceUser AuditResource = "user"
|
||||
AuditResourceNote AuditResource = "note"
|
||||
AuditResourceFile AuditResource = "file"
|
||||
AuditResourceBookmark AuditResource = "bookmark"
|
||||
AuditResourceTask AuditResource = "task"
|
||||
AuditResourceTimeEntry AuditResource = "time_entry"
|
||||
AuditResourceIntegration AuditResource = "integration"
|
||||
AuditResourceTeam AuditResource = "team"
|
||||
AuditResourceGoal AuditResource = "goal"
|
||||
AuditResourceHabit AuditResource = "habit"
|
||||
AuditResourceCalendar AuditResource = "calendar"
|
||||
AuditResourceSearch AuditResource = "search"
|
||||
AuditResourceAI AuditResource = "ai"
|
||||
AuditResourceAnalytics AuditResource = "analytics"
|
||||
AuditResourceSecurity AuditResource = "security"
|
||||
AuditResourceSystem AuditResource = "system"
|
||||
)
|
||||
|
||||
// AuditLog represents an audit log entry
|
||||
@@ -57,16 +57,16 @@ type AuditLog struct {
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// User information
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
UserEmail string `json:"user_email" gorm:"not null"`
|
||||
UserIP string `json:"user_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID;-:migration"`
|
||||
UserEmail string `json:"user_email" gorm:"not null"`
|
||||
UserIP string `json:"user_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Action information
|
||||
Action AuditAction `json:"action" gorm:"not null;index"`
|
||||
Resource AuditResource `json:"resource" gorm:"not null;index"`
|
||||
ResourceID *uint `json:"resource_id,omitempty" gorm:"index"`
|
||||
Action AuditAction `json:"action" gorm:"not null;index"`
|
||||
Resource AuditResource `json:"resource" gorm:"not null;index"`
|
||||
ResourceID *uint `json:"resource_id,omitempty" gorm:"index"`
|
||||
|
||||
// Details
|
||||
Description string `json:"description"`
|
||||
@@ -75,20 +75,20 @@ type AuditLog struct {
|
||||
NewValues map[string]interface{} `json:"new_values" gorm:"serializer:json"`
|
||||
|
||||
// Security context
|
||||
SessionID string `json:"session_id"`
|
||||
Success bool `json:"success" gorm:"default:true"`
|
||||
SessionID string `json:"session_id"`
|
||||
Success bool `json:"success" gorm:"default:true"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
|
||||
// Geographic and device info
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
Platform string `json:"platform"`
|
||||
Browser string `json:"browser"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
Platform string `json:"platform"`
|
||||
Browser string `json:"browser"`
|
||||
|
||||
// Risk assessment
|
||||
RiskLevel string `json:"risk_level" gorm:"default:low"` // low, medium, high, critical
|
||||
Suspicious bool `json:"suspicious" gorm:"default:false"`
|
||||
RiskLevel string `json:"risk_level" gorm:"default:low"` // low, medium, high, critical
|
||||
Suspicious bool `json:"suspicious" gorm:"default:false"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for AuditLog
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ControlServiceSession stores the opaque hq.trackeep.org token for a Trackeep user.
|
||||
type ControlServiceSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"column:user_id;not null;uniqueIndex"`
|
||||
ControllerUserID int `json:"controller_user_id" gorm:"column:controller_user_id;not null;index"`
|
||||
GitHubID int `json:"github_id" gorm:"column:github_id;index"`
|
||||
Username string `json:"username" gorm:"size:255"`
|
||||
Email string `json:"email" gorm:"size:255"`
|
||||
Token string `json:"-" gorm:"type:text;not null"`
|
||||
|
||||
LastValidatedAt *time.Time `json:"last_validated_at"`
|
||||
|
||||
User User `json:"-" gorm:"foreignKey:UserID;-:migration"`
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type GitHubAppInstallState struct {
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID;-:migration"`
|
||||
|
||||
State string `json:"state" gorm:"not null;size:128;uniqueIndex"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
|
||||
@@ -29,7 +29,7 @@ type GitHubAppInstallation struct {
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID;-:migration"`
|
||||
|
||||
InstallationID int64 `json:"installation_id" gorm:"not null;uniqueIndex"`
|
||||
AppSlug string `json:"app_slug" gorm:"size:255"`
|
||||
@@ -46,7 +46,7 @@ type GitHubRepoBackup struct {
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"not null;index:idx_github_backup_user_repo,unique"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID;-:migration"`
|
||||
|
||||
RepositoryID int64 `json:"repository_id" gorm:"index"`
|
||||
RepositoryName string `json:"repository_name" gorm:"size:255"`
|
||||
@@ -54,7 +54,7 @@ type GitHubRepoBackup struct {
|
||||
DefaultBranch string `json:"default_branch" gorm:"size:255"`
|
||||
CloneURL string `json:"clone_url" gorm:"type:text"`
|
||||
LocalPath string `json:"local_path" gorm:"not null;type:text"`
|
||||
Source string `json:"source" gorm:"not null;size:32"` // oauth or github_app
|
||||
Source string `json:"source" gorm:"not null;size:32"` // github_user or github_app
|
||||
InstallationID *int64 `json:"installation_id,omitempty"`
|
||||
LastBackupAt *time.Time `json:"last_backup_at"`
|
||||
LastBackupStatus string `json:"last_backup_status" gorm:"not null;default:'pending';size:32"` // pending, success, error
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GitHubUserAuth stores encrypted GitHub App user tokens for a Trackeep user.
|
||||
type GitHubUserAuth struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
UserID uint `json:"user_id" gorm:"column:user_id;not null;uniqueIndex"`
|
||||
GitHubUserID int `json:"github_user_id" gorm:"column:github_user_id;not null;uniqueIndex"`
|
||||
GitHubLogin string `json:"github_login" gorm:"column:github_login;not null;size:255"`
|
||||
|
||||
AccessToken string `json:"-" gorm:"type:text;not null"`
|
||||
RefreshToken string `json:"-" gorm:"type:text"`
|
||||
|
||||
AccessTokenExpiresAt *time.Time `json:"access_token_expires_at"`
|
||||
RefreshTokenExpiresAt *time.Time `json:"refresh_token_expires_at"`
|
||||
LastRefreshedAt *time.Time `json:"last_refreshed_at"`
|
||||
|
||||
User User `json:"-" gorm:"foreignKey:UserID;-:migration"`
|
||||
}
|
||||
+226
-116
@@ -1,8 +1,12 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DB is the global database instance
|
||||
@@ -13,120 +17,226 @@ func InitDB() {
|
||||
DB = config.GetDB()
|
||||
}
|
||||
|
||||
// AutoMigrate runs database migrations for all models
|
||||
func AutoMigrate() {
|
||||
db := config.GetDB()
|
||||
|
||||
// Auto migrate all models
|
||||
db.AutoMigrate(
|
||||
&User{},
|
||||
&Tag{},
|
||||
&Bookmark{},
|
||||
&Task{},
|
||||
&File{},
|
||||
&Note{},
|
||||
&TimeEntry{},
|
||||
&FileAnalysis{},
|
||||
&ChatSession{},
|
||||
&ChatMessage{},
|
||||
&LearningPath{},
|
||||
&LearningModule{},
|
||||
&ModuleResource{},
|
||||
&Enrollment{},
|
||||
&Progress{},
|
||||
&Course{},
|
||||
&LearningPathCourse{},
|
||||
&CalendarEvent{},
|
||||
&RecurrenceRule{},
|
||||
&CalendarSettings{},
|
||||
// Search models
|
||||
&ContentEmbedding{},
|
||||
&SavedSearch{},
|
||||
&SavedSearchTag{},
|
||||
&SearchAnalytics{},
|
||||
&SearchSuggestion{},
|
||||
// AI Feature models
|
||||
&AISummary{},
|
||||
&AITaskSuggestion{},
|
||||
&UserAISettings{},
|
||||
&UserSearchSettings{},
|
||||
&UserUpdateSettings{},
|
||||
&AITagSuggestion{},
|
||||
&AIContentGeneration{},
|
||||
&AICodeReview{},
|
||||
&AILearningRecommendation{},
|
||||
// Advanced AI Recommendation models
|
||||
&AIRecommendation{},
|
||||
&UserPreference{},
|
||||
&RecommendationInteraction{},
|
||||
// Integration models
|
||||
&Integration{},
|
||||
&SyncLog{},
|
||||
&WebhookEvent{},
|
||||
&GitHubAppInstallState{},
|
||||
&GitHubAppInstallation{},
|
||||
&GitHubRepoBackup{},
|
||||
// Analytics models
|
||||
&Analytics{},
|
||||
&ProductivityMetrics{},
|
||||
&LearningAnalytics{},
|
||||
&ContentAnalytics{},
|
||||
&GitHubAnalytics{},
|
||||
&HabitAnalytics{},
|
||||
&Goal{},
|
||||
&Milestone{},
|
||||
&AnalyticsReport{},
|
||||
// Social features models
|
||||
&Skill{},
|
||||
&Project{},
|
||||
&ProjectTag{},
|
||||
&SocialLink{},
|
||||
&Follow{},
|
||||
// Team workspace models
|
||||
&Team{},
|
||||
&TeamMember{},
|
||||
&TeamInvitation{},
|
||||
&TeamProject{},
|
||||
&TeamProjectTag{},
|
||||
&TeamBookmark{},
|
||||
&TeamNote{},
|
||||
&TeamTask{},
|
||||
&TeamFile{},
|
||||
&TeamActivity{},
|
||||
// Security models
|
||||
&AuditLog{},
|
||||
// Marketplace models
|
||||
&MarketplaceItem{},
|
||||
&MarketplaceTag{},
|
||||
&MarketplaceReview{},
|
||||
&MarketplacePurchase{},
|
||||
&ContentShare{},
|
||||
// Community models
|
||||
&Challenge{},
|
||||
&ChallengeParticipant{},
|
||||
&ChallengeTeam{},
|
||||
&ChallengeMilestone{},
|
||||
&ChallengeMilestoneCompletion{},
|
||||
&ChallengeResource{},
|
||||
&ChallengeTag{},
|
||||
&Mentorship{},
|
||||
&MentorshipSession{},
|
||||
&MentorshipReview{},
|
||||
&MentorshipMilestone{},
|
||||
&MentorshipRequest{},
|
||||
// YouTube cache models
|
||||
&YouTubeChannelCache{},
|
||||
// Video bookmark models
|
||||
&VideoBookmark{},
|
||||
// Messaging models
|
||||
&Conversation{},
|
||||
&ConversationMember{},
|
||||
&Message{},
|
||||
&MessageAttachment{},
|
||||
&MessageReference{},
|
||||
&MessageSuggestion{},
|
||||
&MessageReaction{},
|
||||
&PasswordVaultItem{},
|
||||
&PasswordVaultShare{},
|
||||
)
|
||||
func tableHasColumn(db *gorm.DB, tableName, columnName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Raw(
|
||||
`SELECT count(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
tableName,
|
||||
columnName,
|
||||
).Scan(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func tableExists(db *gorm.DB, tableName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Raw(
|
||||
`SELECT count(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?`,
|
||||
tableName,
|
||||
).Scan(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func repairLegacyBootstrapSchema(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
usersTableExists, err := tableExists(db, "users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !usersTableExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasLegacyPasswordHash, err := tableHasColumn(db, "users", "password_hash")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasLegacyPasswordHash {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasCurrentPasswordColumn, err := tableHasColumn(db, "users", "password")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasCurrentPasswordColumn {
|
||||
return nil
|
||||
}
|
||||
|
||||
var userCount int64
|
||||
if err := db.Table("users").Count(&userCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if userCount > 0 {
|
||||
return fmt.Errorf("legacy bootstrap schema detected with %d existing users; manual migration is required", userCount)
|
||||
}
|
||||
|
||||
log.Println("Legacy bootstrap schema detected with no users; dropping stale UUID-based tables before auto-migration")
|
||||
return db.Exec(`DROP TABLE IF EXISTS
|
||||
file_tags,
|
||||
note_tags,
|
||||
task_tags,
|
||||
bookmark_tags,
|
||||
audit_logs,
|
||||
files,
|
||||
notes,
|
||||
tasks,
|
||||
bookmarks,
|
||||
tags,
|
||||
users
|
||||
CASCADE`).Error
|
||||
}
|
||||
|
||||
// AutoMigrate runs database migrations for all models
|
||||
func AutoMigrate() error {
|
||||
db := config.GetDB()
|
||||
if db == nil {
|
||||
return fmt.Errorf("database not initialized")
|
||||
}
|
||||
|
||||
if err := repairLegacyBootstrapSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The pgx simple-protocol path used by GORM's PostgreSQL migrator can fail
|
||||
// schema introspection (`SELECT * ... LIMIT 1`) with "insufficient arguments".
|
||||
// Running migrations with prepared statements enabled avoids that path and
|
||||
// allows startup migrations to create missing production tables reliably.
|
||||
migrationDB := db.Session(&gorm.Session{PrepareStmt: true})
|
||||
|
||||
models := []struct {
|
||||
name string
|
||||
model interface{}
|
||||
}{
|
||||
{name: "User", model: &User{}},
|
||||
{name: "Tag", model: &Tag{}},
|
||||
{name: "Bookmark", model: &Bookmark{}},
|
||||
{name: "Task", model: &Task{}},
|
||||
{name: "File", model: &File{}},
|
||||
{name: "Note", model: &Note{}},
|
||||
{name: "APIKey", model: &APIKey{}},
|
||||
{name: "BrowserExtension", model: &BrowserExtension{}},
|
||||
{name: "TimeEntry", model: &TimeEntry{}},
|
||||
{name: "FileAnalysis", model: &FileAnalysis{}},
|
||||
{name: "ChatSession", model: &ChatSession{}},
|
||||
{name: "ChatMessage", model: &ChatMessage{}},
|
||||
{name: "LearningPath", model: &LearningPath{}},
|
||||
{name: "LearningModule", model: &LearningModule{}},
|
||||
{name: "ModuleResource", model: &ModuleResource{}},
|
||||
{name: "Enrollment", model: &Enrollment{}},
|
||||
{name: "Progress", model: &Progress{}},
|
||||
{name: "Course", model: &Course{}},
|
||||
{name: "LearningPathCourse", model: &LearningPathCourse{}},
|
||||
{name: "CalendarEvent", model: &CalendarEvent{}},
|
||||
{name: "RecurrenceRule", model: &RecurrenceRule{}},
|
||||
{name: "CalendarSettings", model: &CalendarSettings{}},
|
||||
{name: "ContentEmbedding", model: &ContentEmbedding{}},
|
||||
{name: "SavedSearch", model: &SavedSearch{}},
|
||||
{name: "SavedSearchTag", model: &SavedSearchTag{}},
|
||||
{name: "SearchAnalytics", model: &SearchAnalytics{}},
|
||||
{name: "SearchSuggestion", model: &SearchSuggestion{}},
|
||||
{name: "AISummary", model: &AISummary{}},
|
||||
{name: "AITaskSuggestion", model: &AITaskSuggestion{}},
|
||||
{name: "UserAISettings", model: &UserAISettings{}},
|
||||
{name: "UserSearchSettings", model: &UserSearchSettings{}},
|
||||
{name: "UserUpdateSettings", model: &UserUpdateSettings{}},
|
||||
{name: "AITagSuggestion", model: &AITagSuggestion{}},
|
||||
{name: "AIContentGeneration", model: &AIContentGeneration{}},
|
||||
{name: "AICodeReview", model: &AICodeReview{}},
|
||||
{name: "AILearningRecommendation", model: &AILearningRecommendation{}},
|
||||
{name: "AIRecommendation", model: &AIRecommendation{}},
|
||||
{name: "UserPreference", model: &UserPreference{}},
|
||||
{name: "RecommendationInteraction", model: &RecommendationInteraction{}},
|
||||
{name: "Integration", model: &Integration{}},
|
||||
{name: "SyncLog", model: &SyncLog{}},
|
||||
{name: "WebhookEvent", model: &WebhookEvent{}},
|
||||
{name: "ControlServiceSession", model: &ControlServiceSession{}},
|
||||
{name: "GitHubUserAuth", model: &GitHubUserAuth{}},
|
||||
{name: "GitHubAppInstallState", model: &GitHubAppInstallState{}},
|
||||
{name: "GitHubAppInstallation", model: &GitHubAppInstallation{}},
|
||||
{name: "GitHubRepoBackup", model: &GitHubRepoBackup{}},
|
||||
{name: "Analytics", model: &Analytics{}},
|
||||
{name: "ProductivityMetrics", model: &ProductivityMetrics{}},
|
||||
{name: "LearningAnalytics", model: &LearningAnalytics{}},
|
||||
{name: "ContentAnalytics", model: &ContentAnalytics{}},
|
||||
{name: "GitHubAnalytics", model: &GitHubAnalytics{}},
|
||||
{name: "HabitAnalytics", model: &HabitAnalytics{}},
|
||||
{name: "Goal", model: &Goal{}},
|
||||
{name: "Milestone", model: &Milestone{}},
|
||||
{name: "AnalyticsReport", model: &AnalyticsReport{}},
|
||||
{name: "Skill", model: &Skill{}},
|
||||
{name: "Project", model: &Project{}},
|
||||
{name: "ProjectTag", model: &ProjectTag{}},
|
||||
{name: "SocialLink", model: &SocialLink{}},
|
||||
{name: "Follow", model: &Follow{}},
|
||||
{name: "Team", model: &Team{}},
|
||||
{name: "TeamMember", model: &TeamMember{}},
|
||||
{name: "TeamInvitation", model: &TeamInvitation{}},
|
||||
{name: "TeamProject", model: &TeamProject{}},
|
||||
{name: "TeamProjectTag", model: &TeamProjectTag{}},
|
||||
{name: "TeamBookmark", model: &TeamBookmark{}},
|
||||
{name: "TeamNote", model: &TeamNote{}},
|
||||
{name: "TeamTask", model: &TeamTask{}},
|
||||
{name: "TeamFile", model: &TeamFile{}},
|
||||
{name: "TeamActivity", model: &TeamActivity{}},
|
||||
{name: "AuditLog", model: &AuditLog{}},
|
||||
{name: "MarketplaceItem", model: &MarketplaceItem{}},
|
||||
{name: "MarketplaceTag", model: &MarketplaceTag{}},
|
||||
{name: "MarketplaceReview", model: &MarketplaceReview{}},
|
||||
{name: "MarketplacePurchase", model: &MarketplacePurchase{}},
|
||||
{name: "ContentShare", model: &ContentShare{}},
|
||||
{name: "Challenge", model: &Challenge{}},
|
||||
{name: "ChallengeParticipant", model: &ChallengeParticipant{}},
|
||||
{name: "ChallengeTeam", model: &ChallengeTeam{}},
|
||||
{name: "ChallengeMilestone", model: &ChallengeMilestone{}},
|
||||
{name: "ChallengeMilestoneCompletion", model: &ChallengeMilestoneCompletion{}},
|
||||
{name: "ChallengeResource", model: &ChallengeResource{}},
|
||||
{name: "ChallengeTag", model: &ChallengeTag{}},
|
||||
{name: "Mentorship", model: &Mentorship{}},
|
||||
{name: "MentorshipSession", model: &MentorshipSession{}},
|
||||
{name: "MentorshipReview", model: &MentorshipReview{}},
|
||||
{name: "MentorshipMilestone", model: &MentorshipMilestone{}},
|
||||
{name: "MentorshipRequest", model: &MentorshipRequest{}},
|
||||
{name: "YouTubeChannelCache", model: &YouTubeChannelCache{}},
|
||||
{name: "VideoBookmark", model: &VideoBookmark{}},
|
||||
{name: "Conversation", model: &Conversation{}},
|
||||
{name: "ConversationMember", model: &ConversationMember{}},
|
||||
{name: "Message", model: &Message{}},
|
||||
{name: "MessageAttachment", model: &MessageAttachment{}},
|
||||
{name: "MessageReference", model: &MessageReference{}},
|
||||
{name: "MessageSuggestion", model: &MessageSuggestion{}},
|
||||
{name: "MessageReaction", model: &MessageReaction{}},
|
||||
{name: "PasswordVaultItem", model: &PasswordVaultItem{}},
|
||||
{name: "PasswordVaultShare", model: &PasswordVaultShare{}},
|
||||
}
|
||||
|
||||
criticalModels := map[string]bool{
|
||||
"User": true,
|
||||
"ControlServiceSession": true,
|
||||
"GitHubUserAuth": true,
|
||||
"GitHubAppInstallState": true,
|
||||
"GitHubAppInstallation": true,
|
||||
"GitHubRepoBackup": true,
|
||||
"AuditLog": true,
|
||||
}
|
||||
|
||||
for _, entry := range models {
|
||||
if err := migrationDB.Omit(clause.Associations).AutoMigrate(entry.model); err != nil {
|
||||
if criticalModels[entry.name] {
|
||||
return fmt.Errorf("auto-migrate %s: %w", entry.name, err)
|
||||
}
|
||||
log.Printf("Warning: skipping auto-migrate for %s: %v", entry.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/trackeep/backend/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,7 @@ type UserUpdateSettings struct {
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
|
||||
// OAuth Service Configuration
|
||||
OAuthServiceURL string `json:"oauth_service_url" gorm:"default:https://oauth.trackeep.org"`
|
||||
OAuthServiceURL string `json:"oauth_service_url" gorm:"default:https://hq.trackeep.org"`
|
||||
|
||||
// Update Configuration
|
||||
AutoUpdateCheck bool `json:"auto_update_check" gorm:"default:false"`
|
||||
@@ -34,7 +35,7 @@ func GetUserUpdateSettings(userID uint) (*UserUpdateSettings, error) {
|
||||
// Create default settings
|
||||
settings = UserUpdateSettings{
|
||||
UserID: userID,
|
||||
OAuthServiceURL: "https://oauth.trackeep.org",
|
||||
OAuthServiceURL: config.ControlServiceURL,
|
||||
AutoUpdateCheck: false,
|
||||
UpdateCheckInterval: "24h",
|
||||
PrereleaseUpdates: false,
|
||||
@@ -47,6 +48,14 @@ func GetUserUpdateSettings(userID uint) (*UserUpdateSettings, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if settings.OAuthServiceURL != config.ControlServiceURL {
|
||||
settings.OAuthServiceURL = config.ControlServiceURL
|
||||
if err := DB.Model(&settings).Update("oauth_service_url", config.ControlServiceURL).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ type User struct {
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role" gorm:"default:user"` // user, admin
|
||||
|
||||
// GitHub OAuth fields
|
||||
GitHubID int `json:"github_id" gorm:"uniqueIndex"`
|
||||
// GitHub sign-in fields
|
||||
GitHubID int `json:"github_id" gorm:"column:github_id;uniqueIndex"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Provider string `json:"provider" gorm:"default:email"` // email, github
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GracefulShutdown handles graceful shutdown of the server
|
||||
type GracefulShutdown struct {
|
||||
server *http.Server
|
||||
shutdownTimeout time.Duration
|
||||
cleanupFuncs []func() error
|
||||
}
|
||||
|
||||
// NewGracefulShutdown creates a new graceful shutdown handler
|
||||
func NewGracefulShutdown(server *http.Server, timeout time.Duration) *GracefulShutdown {
|
||||
return &GracefulShutdown{
|
||||
server: server,
|
||||
shutdownTimeout: timeout,
|
||||
cleanupFuncs: make([]func() error, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddCleanupFunc adds a cleanup function to be called during shutdown
|
||||
func (gs *GracefulShutdown) AddCleanupFunc(fn func() error) {
|
||||
gs.cleanupFuncs = append(gs.cleanupFuncs, fn)
|
||||
}
|
||||
|
||||
// Wait waits for shutdown signal and performs graceful shutdown
|
||||
func (gs *GracefulShutdown) Wait() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Run cleanup functions
|
||||
for i, fn := range gs.cleanupFuncs {
|
||||
if err := fn(); err != nil {
|
||||
log.Printf("Cleanup function %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create shutdown context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gs.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Attempt graceful shutdown
|
||||
if err := gs.server.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
} else {
|
||||
log.Println("Server shutdown complete")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user