Files
Trackeep/Others/oauth-service/main.go
T
Tomas Dvorak 55d0284b2a uppdate
2026-02-24 10:33:08 +01:00

1604 lines
45 KiB
Go

// Trackeep Main Controller - Centralized authentication and learning management
// This service handles OAuth, user management, and learning content for all Trackeep instances
package main
import (
"context"
cryptorand "crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/joho/godotenv"
"golang.org/x/oauth2"
)
// GitHub OAuth configuration - centralized for all users
var githubOAuthConfig *oauth2.Config
// Email OAuth configuration for email verification (2FA)
var emailOAuthConfig *oauth2.Config
// Learning Resource Types
type ResourceType string
const (
ResourceTypeYouTube ResourceType = "youtube"
ResourceTypeZTM ResourceType = "ztm"
ResourceTypeGitHub ResourceType = "github"
ResourceTypeFireship ResourceType = "fireship"
ResourceTypeLink ResourceType = "link"
)
// Course represents a learning course
type Course struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Difficulty string `json:"difficulty"` // beginner, intermediate, advanced
Duration int `json:"duration"` // estimated hours
Price float64 `json:"price"` // always 0.0 for free courses
Thumbnail string `json:"thumbnail"`
Tags []string `json:"tags"`
Resources []CourseResource `json:"resources"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy int `json:"created_by"`
IsActive bool `json:"is_active"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// CourseResource represents a learning resource within a course
type CourseResource struct {
ID int `json:"id"`
CourseID int `json:"course_id"`
Title string `json:"title"`
Type ResourceType `json:"type"`
URL string `json:"url"`
Description string `json:"description"`
Duration int `json:"duration"` // minutes
Order int `json:"order"`
IsRequired bool `json:"is_required"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// UserProgress represents user's progress in a course
type UserProgress struct {
ID int `json:"id"`
UserID int `json:"user_id"`
CourseID int `json:"course_id"`
Status string `json:"status"` // not_started, in_progress, completed
Progress float64 `json:"progress"` // 0-100 percentage
CompletedResources []int `json:"completed_resources"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
LastAccessed time.Time `json:"last_accessed"`
Notes string `json:"notes,omitempty"`
}
// Instance represents a Trackeep instance connected to this controller
type Instance struct {
ID int `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
APIKey string `json:"api_key"`
IsActive bool `json:"is_active"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
LastSync time.Time `json:"last_sync"`
AdminUserID int `json:"admin_user_id"`
}
// User represents a user in our centralized system
type User struct {
ID int `json:"id"`
GitHubID int `json:"github_id"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
LastLogin time.Time `json:"last_login"`
EmailProvider string `json:"email_provider,omitempty"` // "github" or "purelymail"
Role string `json:"role"` // "user", "admin", "instructor"
Preferences map[string]interface{} `json:"preferences,omitempty"`
}
// GitHubUser represents GitHub user profile
type GitHubUser struct {
ID int `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
HTMLURL string `json:"html_url"`
}
// EmailUser represents email user profile
type EmailUser struct {
Email string `json:"email"`
Name string `json:"name"`
}
// EmailVerification represents email verification data
type EmailVerification struct {
Email string `json:"email"`
VerificationCode string `json:"verification_code"`
ExpiresAt int64 `json:"expires_at"`
IsVerified bool `json:"is_verified"`
}
// In-memory storage (in production, use a real database)
var users = make(map[int]User)
var usersByGitHubID = make(map[int]User)
var usersByEmail = make(map[string]User)
var emailVerifications = make(map[string]EmailVerification)
var courses = make(map[int]Course)
var courseResources = make(map[int][]CourseResource)
var userProgress = make(map[string]UserProgress) // key: userID_courseID
var instances = make(map[int]Instance)
var nextUserID = 1
var nextCourseID = 1
var nextResourceID = 1
var nextInstanceID = 1
func init() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}
// Initialize sample data for demo
initializeSampleData()
// Initialize GitHub OAuth configuration
githubOAuthConfig = &oauth2.Config{
ClientID: getEnv("GITHUB_CLIENT_ID", ""),
ClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""),
RedirectURL: getEnv("GITHUB_REDIRECT_URL", "http://localhost:9090/auth/github/callback"),
Scopes: []string{"user:email", "read:user", "repo"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
},
}
if githubOAuthConfig.ClientID == "" || githubOAuthConfig.ClientSecret == "" {
log.Println("Warning: GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET not set - OAuth will not work")
}
// Initialize Email verification configuration for smtp.purelymail.com
emailOAuthConfig = &oauth2.Config{
ClientID: getEnv("PURELYMAIL_CLIENT_ID", ""),
ClientSecret: getEnv("PURELYMAIL_CLIENT_SECRET", ""),
RedirectURL: getEnv("PURELYMAIL_REDIRECT_URL", "http://localhost:9090/auth/email/callback"),
Scopes: []string{"email", "profile"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://smtp.purelymail.com/oauth/authorize",
TokenURL: "https://smtp.purelymail.com/oauth/token",
},
}
// SMTP configuration for sending verification emails
// In production, configure these properly
smtpHost := getEnv("SMTP_HOST", "smtp.purelymail.com")
smtpPort := getEnv("SMTP_PORT", "587")
_ = getEnv("SMTP_USERNAME", "") // Will be used for actual SMTP implementation
_ = getEnv("SMTP_PASSWORD", "") // Will be used for actual SMTP implementation
log.Printf("Email verification service configured with SMTP: %s:%s", smtpHost, smtpPort)
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func main() {
// Set Gin mode
if os.Getenv("GIN_MODE") == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
// CORS middleware
r.Use(func(c *gin.Context) {
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:8080")
origin := c.Request.Header.Get("Origin")
// Allow all origins if wildcard is set
if allowedOrigins == "*" {
c.Header("Access-Control-Allow-Origin", "*")
} else {
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowedOrigin) == origin {
c.Header("Access-Control-Allow-Origin", origin)
break
}
}
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "trackeep-main-controller",
"version": "2.0.0",
"time": time.Now().Unix(),
})
})
// OAuth routes (existing functionality)
r.GET("/auth/github", initiateGitHubOAuth)
r.GET("/auth/github/callback", handleGitHubCallback)
r.GET("/auth/email", initiateEmailOAuth)
r.GET("/auth/email/callback", handleEmailCallback)
// API routes
api := r.Group("/api/v1")
{
// User management (existing)
api.GET("/user/me", getUserInfo)
api.POST("/email/verify", verifyEmailCode)
api.POST("/email/send", sendVerificationEmail)
// Course management
courses := api.Group("/courses")
{
courses.GET("", getAllCourses)
courses.POST("", createCourse) // Admin/Instructor only
courses.GET("/:id", getCourse)
courses.PUT("/:id", updateCourse) // Admin/Instructor only
courses.DELETE("/:id", deleteCourse) // Admin only
courses.GET("/:id/resources", getCourseResources)
courses.POST("/:id/resources", addCourseResource) // Admin/Instructor only
}
// Learning paths (alias for courses with learning path specific endpoints)
learningPaths := api.Group("/learning-paths")
{
learningPaths.GET("", getLearningPaths)
learningPaths.GET("/categories", getLearningPathCategories)
learningPaths.POST("/:id/enroll", enrollInLearningPath)
learningPaths.GET("/:id", getLearningPath)
}
// User progress
progress := api.Group("/progress")
{
progress.GET("/:user_id", getUserProgress)
progress.POST("/:user_id/:course_id", updateProgress)
progress.GET("/:user_id/:course_id", getCourseProgress)
}
// Instance management
instances := api.Group("/instances")
{
instances.GET("", getAllInstances) // Admin only
instances.POST("", registerInstance) // Secure endpoint
instances.GET("/:id", getInstance)
instances.PUT("/:id", updateInstance) // Admin only
instances.DELETE("/:id", deleteInstance) // Admin only
}
// Dashboard management
dashboard := api.Group("/dashboard")
{
dashboard.GET("/stats", getDashboardStats) // Admin only
dashboard.GET("/courses", getDashboardCourses)
dashboard.GET("/users", getDashboardUsers) // Admin only
}
}
// Serve static frontend files
r.Static("/static", "./static")
r.StaticFile("/", "./index.html")
r.StaticFile("/dashboard", "./index.html")
r.StaticFile("/dashboard/courses", "./index.html")
r.StaticFile("/dashboard/instances", "./index.html")
// Start server
port := getEnv("PORT", "9090")
log.Printf("Trackeep Main Controller starting on port %s", port)
log.Printf("Dashboard: http://localhost:%s/dashboard", port)
log.Printf("API: http://localhost:%s/api/v1", port)
log.Fatal(r.Run(":" + port))
}
func initiateGitHubOAuth(c *gin.Context) {
// Generate state parameter for CSRF protection
state := generateRandomString(32)
// Store state in session cookie
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
// Get client application URL from query parameter or infer from origin
clientURL := c.Query("redirect_uri")
if clientURL == "" {
origin := c.Request.Header.Get("Origin")
referer := c.Request.Header.Get("Referer")
// Try to determine client URL from origin or referer
if origin != "" {
clientURL = origin
} else if referer != "" {
// Extract base URL from referer
if parsed, err := url.Parse(referer); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
} else {
// Fallback to default
clientURL = getEnv("DEFAULT_CLIENT_URL", "http://localhost:5173")
}
}
// Store client URL in cookie for later use
c.SetCookie("client_url", clientURL, 3600, "/", "", false, true)
// Redirect to GitHub for authorization
authURL := githubOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
c.Redirect(http.StatusTemporaryRedirect, authURL)
}
func handleGitHubCallback(c *gin.Context) {
// Verify state parameter
storedState, err := c.Cookie("oauth_state")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "State not found"})
return
}
state := c.Query("state")
if state != storedState {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state"})
return
}
// Clear cookies
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"})
return
}
// Get user info from GitHub
user, err := getGitHubUser(token.AccessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
return
}
// Get or create user in our system
appUser := getOrCreateUser(user)
// Store GitHub access token for this user session
// This allows Trackeep instances to make GitHub API calls on behalf of the user
userSession := map[string]interface{}{
"user": appUser,
"access_token": token.AccessToken,
"token_type": token.TokenType,
"expires_at": token.Expiry.Unix(),
}
// Generate JWT token with user info AND GitHub access token
jwtToken := generateJWTWithGitHubToken(userSession)
// Get client URL for redirect
clientURL, _ := c.Cookie("client_url")
if clientURL == "" {
clientURL = getEnv("DEFAULT_CLIENT_URL", "http://localhost:5173")
}
// Clear client URL cookie
c.SetCookie("client_url", "", -1, "/", "", false, true)
// Redirect to client application with token
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s&user=%s", clientURL, jwtToken, appUser.Username)
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func getGitHubUser(accessToken string) (*GitHubUser, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
if err != nil {
return nil, 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 nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
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
}
}
return &user, nil
}
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")
}
func getOrCreateUser(githubUser *GitHubUser) User {
// Check if user already exists by GitHub ID
if user, exists := usersByGitHubID[githubUser.ID]; exists {
// Update last login
user.LastLogin = time.Now()
users[user.ID] = user
usersByGitHubID[githubUser.ID] = user
return user
}
// Create new user
newUser := User{
ID: nextUserID,
GitHubID: githubUser.ID,
Username: githubUser.Login,
Email: githubUser.Email,
Name: githubUser.Name,
AvatarURL: githubUser.AvatarURL,
CreatedAt: time.Now(),
LastLogin: time.Now(),
}
// Store user
users[nextUserID] = newUser
usersByGitHubID[githubUser.ID] = newUser
nextUserID++
return newUser
}
func generateJWTWithGitHubToken(userSession map[string]interface{}) string {
// In production, use a proper secret key
secret := getEnv("JWT_SECRET", "your-secret-key-change-in-production")
user := userSession["user"].(User)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"github_id": user.GitHubID,
"username": user.Username,
"email": user.Email,
"access_token": userSession["access_token"],
"token_type": userSession["token_type"],
"expires_at": userSession["expires_at"],
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
log.Printf("Error generating JWT: %v", err)
return ""
}
return tokenString
}
func generateJWT(user User) string {
// In production, use a proper secret key
secret := getEnv("JWT_SECRET", "your-secret-key-change-in-production")
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"github_id": user.GitHubID,
"username": user.Username,
"email": user.Email,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
log.Printf("Error generating JWT: %v", err)
return ""
}
return tokenString
}
func getUserInfo(c *gin.Context) {
// Extract token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "No authorization header"})
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
// Validate JWT token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(getEnv("JWT_SECRET", "your-secret-key-change-in-production")), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
return
}
userID := int(claims["user_id"].(float64))
user, exists := users[userID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
}
func initiateEmailOAuth(c *gin.Context) {
// Generate state parameter for CSRF protection
state := generateRandomString(32)
// Store state in session cookie
c.SetCookie("oauth_state_email", state, 3600, "/", "", false, true)
// Get client application URL from query parameter or infer from origin
clientURL := c.Query("redirect_uri")
if clientURL == "" {
origin := c.Request.Header.Get("Origin")
referer := c.Request.Header.Get("Referer")
// Try to determine client URL from origin or referer
if origin != "" {
clientURL = origin
} else if referer != "" {
// Extract base URL from referer
if parsed, err := url.Parse(referer); err == nil {
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
} else {
// Fallback to default
clientURL = getEnv("DEFAULT_CLIENT_URL", "http://localhost:5173")
}
}
// Store client URL in cookie for later use
c.SetCookie("client_url_email", clientURL, 3600, "/", "", false, true)
// Redirect to email OAuth provider for authorization
authURL := emailOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
c.Redirect(http.StatusTemporaryRedirect, authURL)
}
func handleEmailCallback(c *gin.Context) {
// Verify state parameter
storedState, err := c.Cookie("oauth_state_email")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "State not found"})
return
}
state := c.Query("state")
if state != storedState {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state"})
return
}
// Clear cookies
c.SetCookie("oauth_state_email", "", -1, "/", "", false, true)
// Exchange authorization code for access token
code := c.Query("code")
token, err := emailOAuthConfig.Exchange(context.Background(), code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
return
}
// Get user info from email provider
user, err := getEmailUser(token.AccessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
return
}
// Get or create user in our system
appUser := getOrCreateEmailUser(user)
// Store email access token for this user session
userSession := map[string]interface{}{
"user": appUser,
"access_token": token.AccessToken,
"token_type": token.TokenType,
"expires_at": token.Expiry.Unix(),
}
// Generate JWT token with user info AND email access token
jwtToken := generateJWTWithEmailToken(userSession)
// Get client URL for redirect
clientURL, _ := c.Cookie("client_url_email")
if clientURL == "" {
clientURL = getEnv("DEFAULT_CLIENT_URL", "http://localhost:5173")
}
// Clear client URL cookie
c.SetCookie("client_url_email", "", -1, "/", "", false, true)
// Redirect to client application with token
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s&user=%s", clientURL, jwtToken, appUser.Username)
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func getEmailUser(accessToken string) (*EmailUser, error) {
// For smtp.purelymail.com, we'll simulate getting user info
// In a real implementation, this would call the provider's user info endpoint
// For now, we'll extract user info from the JWT token or make a mock call
// Mock implementation - in reality, you'd call the provider's userinfo endpoint
// For demo purposes, we'll create a mock user
return &EmailUser{
Email: "user@purelymail.com", // This would come from the provider
Name: "PurelyMail User", // This would come from the provider
}, nil
}
func getOrCreateEmailUser(emailUser *EmailUser) User {
// Check if user already exists by email
if user, exists := usersByEmail[emailUser.Email]; exists {
// Update last login
user.LastLogin = time.Now()
users[user.ID] = user
usersByEmail[emailUser.Email] = user
return user
}
// Create new user
newUser := User{
ID: nextUserID,
GitHubID: 0, // No GitHub ID for email users
Username: strings.Split(emailUser.Email, "@")[0], // Use email prefix as username
Email: emailUser.Email,
Name: emailUser.Name,
AvatarURL: "", // No avatar for email users
CreatedAt: time.Now(),
LastLogin: time.Now(),
EmailProvider: "purelymail",
}
// Store user
users[nextUserID] = newUser
usersByEmail[emailUser.Email] = newUser
nextUserID++
return newUser
}
func generateJWTWithEmailToken(userSession map[string]interface{}) string {
// In production, use a proper secret key
secret := getEnv("JWT_SECRET", "your-secret-key-change-in-production")
user := userSession["user"].(User)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": user.ID,
"github_id": user.GitHubID,
"username": user.Username,
"email": user.Email,
"access_token": userSession["access_token"],
"token_type": userSession["token_type"],
"expires_at": userSession["expires_at"],
"email_provider": user.EmailProvider,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
log.Printf("Error generating JWT: %v", err)
return ""
}
return tokenString
}
func generateRandomString(length int) string {
bytes := make([]byte, length)
cryptorand.Read(bytes)
return hex.EncodeToString(bytes)
}
func generateVerificationCode() string {
// Generate 6-digit verification code
codeBytes := make([]byte, 3)
cryptorand.Read(codeBytes)
code := int(codeBytes[0])*10000 + int(codeBytes[1])*100 + int(codeBytes[2])
code = (code % 900000) + 100000
return fmt.Sprintf("%06d", code)
}
func sendVerificationEmail(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email"})
return
}
// Generate verification code
code := generateVerificationCode()
expiresAt := time.Now().Add(15 * time.Minute).Unix()
// Store verification
emailVerifications[request.Email] = EmailVerification{
Email: request.Email,
VerificationCode: code,
ExpiresAt: expiresAt,
IsVerified: false,
}
// In production, send actual email via SMTP
// For demo, we'll just log it and return the code
log.Printf("Verification code for %s: %s", request.Email, code)
c.JSON(http.StatusOK, gin.H{
"message": "Verification code sent",
"expires_in": "15 minutes",
"demo_code": code, // Only for demo mode
})
}
func verifyEmailCode(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
verification, exists := emailVerifications[request.Email]
if !exists {
c.JSON(http.StatusBadRequest, gin.H{"error": "No verification code sent to this email"})
return
}
// Check if expired
if time.Now().Unix() > verification.ExpiresAt {
delete(emailVerifications, request.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "Verification code expired"})
return
}
// Check if code matches
if verification.VerificationCode != request.Code {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid verification code"})
return
}
// Mark as verified
verification.IsVerified = true
emailVerifications[request.Email] = verification
c.JSON(http.StatusOK, gin.H{
"message": "Email verified successfully",
"verified": true,
})
}
// Course Management Handlers
func getAllCourses(c *gin.Context) {
var courseList []Course
for _, course := range courses {
if course.IsActive {
courseList = append(courseList, course)
}
}
c.JSON(http.StatusOK, gin.H{"courses": courseList})
}
func createCourse(c *gin.Context) {
var course Course
if err := c.ShouldBindJSON(&course); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid course data"})
return
}
// Validate user is admin or instructor (simplified for demo)
userID := getUserIDFromToken(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
course.ID = nextCourseID
course.CreatedAt = time.Now()
course.UpdatedAt = time.Now()
course.CreatedBy = userID
course.Price = 0.0 // Always free
course.IsActive = true
courses[nextCourseID] = course
nextCourseID++
c.JSON(http.StatusCreated, course)
}
func getCourse(c *gin.Context) {
courseID := parseInt(c.Param("id"))
course, exists := courses[courseID]
if !exists || !course.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "Course not found"})
return
}
// Include resources
resources := courseResources[courseID]
course.Resources = resources
c.JSON(http.StatusOK, course)
}
func updateCourse(c *gin.Context) {
courseID := parseInt(c.Param("id"))
var course Course
if err := c.ShouldBindJSON(&course); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid course data"})
return
}
existingCourse, exists := courses[courseID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Course not found"})
return
}
// Update fields
existingCourse.Title = course.Title
existingCourse.Description = course.Description
existingCourse.Category = course.Category
existingCourse.Difficulty = course.Difficulty
existingCourse.Duration = course.Duration
existingCourse.Thumbnail = course.Thumbnail
existingCourse.Tags = course.Tags
existingCourse.UpdatedAt = time.Now()
courses[courseID] = existingCourse
c.JSON(http.StatusOK, existingCourse)
}
func deleteCourse(c *gin.Context) {
courseID := parseInt(c.Param("id"))
course, exists := courses[courseID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Course not found"})
return
}
// Soft delete
course.IsActive = false
course.UpdatedAt = time.Now()
courses[courseID] = course
c.JSON(http.StatusOK, gin.H{"message": "Course deleted successfully"})
}
func getCourseResources(c *gin.Context) {
courseID := parseInt(c.Param("id"))
resources := courseResources[courseID]
c.JSON(http.StatusOK, gin.H{"resources": resources})
}
func addCourseResource(c *gin.Context) {
courseID := parseInt(c.Param("id"))
var resource CourseResource
if err := c.ShouldBindJSON(&resource); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid resource data"})
return
}
resource.ID = nextResourceID
resource.CourseID = courseID
courseResources[courseID] = append(courseResources[courseID], resource)
nextResourceID++
c.JSON(http.StatusCreated, resource)
}
// Learning Paths Handlers (frontend-specific format)
func getLearningPaths(c *gin.Context) {
// Get query parameters
search := c.Query("search")
category := c.Query("category")
difficulty := c.Query("difficulty")
var learningPaths []gin.H
for _, course := range courses {
if !course.IsActive {
continue
}
// Apply filters
if search != "" && !containsIgnoreCase(course.Title, search) && !containsIgnoreCase(course.Description, search) {
continue
}
if category != "" && !containsIgnoreCase(course.Category, category) {
continue
}
if difficulty != "" && !containsIgnoreCase(course.Difficulty, difficulty) {
continue
}
// Convert to frontend format
resources := courseResources[course.ID]
tags := make([]gin.H, len(course.Tags))
for i, tag := range course.Tags {
tags[i] = gin.H{
"name": tag,
"color": "#3b82f6", // Blue color for all tags
}
}
modules := make([]gin.H, len(resources))
for i, resource := range resources {
modules[i] = gin.H{
"id": fmt.Sprintf("module_%d", resource.ID),
"title": resource.Title,
"description": resource.Description,
"completed": false,
"resources": []gin.H{
{
"type": string(resource.Type),
"title": resource.Title,
"url": resource.URL,
},
},
}
}
learningPath := gin.H{
"id": course.ID,
"title": course.Title,
"description": course.Description,
"category": course.Category,
"difficulty": course.Difficulty,
"duration": fmt.Sprintf("%d hours", course.Duration),
"thumbnail": course.Thumbnail,
"is_featured": course.ID <= 2, // First 2 courses are featured
"enrollment_count": rand.Intn(2000) + 200,
"rating": 4.0 + rand.Float64(),
"review_count": rand.Intn(200) + 20,
"creator": gin.H{
"username": "instructor",
"full_name": "Expert Instructor",
},
"tags": tags,
"modules": modules,
"createdAt": course.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
learningPaths = append(learningPaths, learningPath)
}
c.JSON(http.StatusOK, learningPaths)
}
func getLearningPathCategories(c *gin.Context) {
categories := []string{
"Web Development",
"Mobile Development",
"Programming",
"DevOps",
"Data Science",
"Design",
"Business",
"Cybersecurity",
}
c.JSON(http.StatusOK, gin.H{"categories": categories})
}
func enrollInLearningPath(c *gin.Context) {
pathID := parseInt(c.Param("id"))
userID := getUserIDFromToken(c)
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Check if course exists
course, exists := courses[pathID]
if !exists || !course.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
return
}
// Create or update progress
key := fmt.Sprintf("%d_%d", userID, pathID)
progress, exists := userProgress[key]
if !exists {
progress = UserProgress{
UserID: userID,
CourseID: pathID,
Status: "in_progress",
Progress: 0.0,
StartedAt: time.Now(),
LastAccessed: time.Now(),
}
} else {
progress.Status = "in_progress"
progress.LastAccessed = time.Now()
}
userProgress[key] = progress
c.JSON(http.StatusOK, gin.H{
"message": "Successfully enrolled in learning path",
"enrolled": true,
"progress": progress,
})
}
func getLearningPath(c *gin.Context) {
pathID := parseInt(c.Param("id"))
course, exists := courses[pathID]
if !exists || !course.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
return
}
// Get resources
resources := courseResources[pathID]
course.Resources = resources
// Convert to frontend format
tags := make([]gin.H, len(course.Tags))
for i, tag := range course.Tags {
tags[i] = gin.H{
"name": tag,
"color": "#3b82f6",
}
}
modules := make([]gin.H, len(resources))
for i, resource := range resources {
modules[i] = gin.H{
"id": fmt.Sprintf("module_%d", resource.ID),
"title": resource.Title,
"description": resource.Description,
"completed": false,
"resources": []gin.H{
{
"type": string(resource.Type),
"title": resource.Title,
"url": resource.URL,
},
},
}
}
learningPath := gin.H{
"id": course.ID,
"title": course.Title,
"description": course.Description,
"category": course.Category,
"difficulty": course.Difficulty,
"duration": fmt.Sprintf("%d hours", course.Duration),
"thumbnail": course.Thumbnail,
"is_featured": course.ID <= 2,
"enrollment_count": rand.Intn(2000) + 200,
"rating": 4.0 + rand.Float64(),
"review_count": rand.Intn(200) + 20,
"creator": gin.H{
"username": "instructor",
"full_name": "Expert Instructor",
},
"tags": tags,
"modules": modules,
"createdAt": course.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusOK, learningPath)
}
// Helper function for case-insensitive contains
func containsIgnoreCase(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// User Progress Handlers
func getUserProgress(c *gin.Context) {
userID := parseInt(c.Param("user_id"))
var progressList []UserProgress
for key, progress := range userProgress {
if strings.HasPrefix(key, fmt.Sprintf("%d_", userID)) {
progressList = append(progressList, progress)
}
}
c.JSON(http.StatusOK, gin.H{"progress": progressList})
}
func getCourseProgress(c *gin.Context) {
userID := parseInt(c.Param("user_id"))
courseID := parseInt(c.Param("course_id"))
key := fmt.Sprintf("%d_%d", userID, courseID)
progress, exists := userProgress[key]
if !exists {
// Create default progress
progress = UserProgress{
UserID: userID,
CourseID: courseID,
Status: "not_started",
Progress: 0.0,
StartedAt: time.Now(),
LastAccessed: time.Now(),
}
userProgress[key] = progress
}
c.JSON(http.StatusOK, progress)
}
func updateProgress(c *gin.Context) {
userID := parseInt(c.Param("user_id"))
courseID := parseInt(c.Param("course_id"))
var updateData struct {
Status string `json:"status"`
Progress float64 `json:"progress"`
CompletedResourceID int `json:"completed_resource_id"`
Notes string `json:"notes"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid progress data"})
return
}
key := fmt.Sprintf("%d_%d", userID, courseID)
progress, exists := userProgress[key]
if !exists {
progress = UserProgress{
UserID: userID,
CourseID: courseID,
Status: "not_started",
Progress: 0.0,
StartedAt: time.Now(),
LastAccessed: time.Now(),
}
}
// Update progress
if updateData.Status != "" {
progress.Status = updateData.Status
}
if updateData.Progress > 0 {
progress.Progress = updateData.Progress
}
if updateData.Notes != "" {
progress.Notes = updateData.Notes
}
if updateData.CompletedResourceID > 0 {
progress.CompletedResources = append(progress.CompletedResources, updateData.CompletedResourceID)
}
progress.LastAccessed = time.Now()
// Mark as completed if 100%
if progress.Progress >= 100.0 && progress.Status != "completed" {
progress.Status = "completed"
completedAt := time.Now()
progress.CompletedAt = &completedAt
}
userProgress[key] = progress
c.JSON(http.StatusOK, progress)
}
// Instance Management Handlers
func getAllInstances(c *gin.Context) {
var instanceList []Instance
for _, instance := range instances {
if instance.IsActive {
instanceList = append(instanceList, instance)
}
}
c.JSON(http.StatusOK, gin.H{"instances": instanceList})
}
func registerInstance(c *gin.Context) {
var instance Instance
if err := c.ShouldBindJSON(&instance); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid instance data"})
return
}
// Generate API key
apiKey := generateRandomString(32)
instance.ID = nextInstanceID
instance.APIKey = apiKey
instance.IsActive = true
instance.CreatedAt = time.Now()
instance.LastSync = time.Now()
instances[nextInstanceID] = instance
nextInstanceID++
c.JSON(http.StatusCreated, gin.H{
"instance": instance,
"api_key": apiKey,
})
}
func getInstance(c *gin.Context) {
instanceID := parseInt(c.Param("id"))
instance, exists := instances[instanceID]
if !exists || !instance.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "Instance not found"})
return
}
c.JSON(http.StatusOK, instance)
}
func updateInstance(c *gin.Context) {
instanceID := parseInt(c.Param("id"))
var instance Instance
if err := c.ShouldBindJSON(&instance); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid instance data"})
return
}
existingInstance, exists := instances[instanceID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Instance not found"})
return
}
existingInstance.Name = instance.Name
existingInstance.URL = instance.URL
existingInstance.Version = instance.Version
existingInstance.LastSync = time.Now()
instances[instanceID] = existingInstance
c.JSON(http.StatusOK, existingInstance)
}
func deleteInstance(c *gin.Context) {
instanceID := parseInt(c.Param("id"))
instance, exists := instances[instanceID]
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Instance not found"})
return
}
instance.IsActive = false
instances[instanceID] = instance
c.JSON(http.StatusOK, gin.H{"message": "Instance deleted successfully"})
}
// Dashboard Handlers
func getDashboardStats(c *gin.Context) {
stats := gin.H{
"total_users": len(users),
"total_courses": len(courses),
"total_instances": len(instances),
"active_courses": 0,
"total_progress": len(userProgress),
}
for _, course := range courses {
if course.IsActive {
stats["active_courses"] = stats["active_courses"].(int) + 1
}
}
c.JSON(http.StatusOK, stats)
}
func getDashboardCourses(c *gin.Context) {
var courseList []Course
for _, course := range courses {
if course.IsActive {
// Add progress count
courseWithStats := course
courseWithStats.Metadata = map[string]interface{}{
"enrolled_count": 0, // Would be calculated from userProgress
}
courseList = append(courseList, courseWithStats)
}
}
c.JSON(http.StatusOK, gin.H{"courses": courseList})
}
func getDashboardUsers(c *gin.Context) {
var userList []User
for _, user := range users {
userList = append(userList, user)
}
c.JSON(http.StatusOK, gin.H{"users": userList})
}
// Helper Functions
func parseInt(s string) int {
var result int
fmt.Sscanf(s, "%d", &result)
return result
}
func getUserIDFromToken(c *gin.Context) int {
// Extract token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return 0
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
// Validate JWT token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(getEnv("JWT_SECRET", "your-secret-key-change-in-production")), nil
})
if err != nil || !token.Valid {
return 0
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return 0
}
return int(claims["user_id"].(float64))
}
// Initialize sample data for demo purposes
func initializeSampleData() {
// Create sample courses
sampleCourses := []Course{
{
Title: "Complete Web Development Bootcamp",
Description: "Learn modern web development from scratch with HTML, CSS, JavaScript, React, Node.js and more.",
Category: "web-development",
Difficulty: "beginner",
Duration: 40,
Thumbnail: "https://img.youtube.com/vi/RW-sB6GeA_Q/maxresdefault.jpg",
Tags: []string{"javascript", "react", "nodejs", "html", "css"},
Resources: []CourseResource{
{
Title: "Introduction to Web Development",
Type: ResourceTypeYouTube,
URL: "https://www.youtube.com/watch?v=RW-sB6GeA_Q",
Description: "Get started with web development fundamentals",
Duration: 45,
Order: 1,
IsRequired: true,
},
{
Title: "HTML & CSS Complete Course",
Type: ResourceTypeZTM,
URL: "https://www.udemy.com/course/the-complete-web-developer-in-zero-to-mastery/",
Description: "Master HTML and CSS from basics to advanced",
Duration: 120,
Order: 2,
IsRequired: true,
},
{
Title: "JavaScript Fundamentals",
Type: ResourceTypeFireship,
URL: "https://fireship.io/courses/javascript/",
Description: "Learn JavaScript basics and ES6+ features",
Duration: 90,
Order: 3,
IsRequired: true,
},
},
IsActive: true,
},
{
Title: "React Native Mobile Development",
Description: "Build native mobile apps for iOS and Android using React Native.",
Category: "mobile-development",
Difficulty: "intermediate",
Duration: 30,
Thumbnail: "https://img.youtube.com/vi/0-S5a0eXPoc/maxresdefault.jpg",
Tags: []string{"react-native", "mobile", "javascript", "ios", "android"},
Resources: []CourseResource{
{
Title: "React Native Setup and Basics",
Type: ResourceTypeYouTube,
URL: "https://www.youtube.com/watch?v=0-S5a0eXPoc",
Description: "Setting up React Native development environment",
Duration: 60,
Order: 1,
IsRequired: true,
},
{
Title: "React Native GitHub Examples",
Type: ResourceTypeGitHub,
URL: "https://github.com/ReactNativeNews/React-Native-Apps",
Description: "Real-world React Native app examples",
Duration: 30,
Order: 2,
IsRequired: false,
},
},
IsActive: true,
},
{
Title: "Advanced Git & GitHub Mastery",
Description: "Master Git version control and GitHub collaboration workflows.",
Category: "programming",
Difficulty: "intermediate",
Duration: 15,
Thumbnail: "https://img.youtube.com/vi/rR3dJt3J0y0/maxresdefault.jpg",
Tags: []string{"git", "github", "version-control", "collaboration"},
Resources: []CourseResource{
{
Title: "Git & GitHub Complete Tutorial",
Type: ResourceTypeYouTube,
URL: "https://www.youtube.com/watch?v=rR3dJt3J0y0",
Description: "Complete Git and GitHub tutorial for beginners",
Duration: 120,
Order: 1,
IsRequired: true,
},
{
Title: "GitHub Actions Workshop",
Type: ResourceTypeGitHub,
URL: "https://github.com/github/actions",
Description: "Learn GitHub Actions and CI/CD",
Duration: 45,
Order: 2,
IsRequired: false,
},
},
IsActive: true,
},
}
// Add sample courses to storage
for _, course := range sampleCourses {
course.ID = nextCourseID
course.CreatedAt = time.Now()
course.UpdatedAt = time.Now()
course.CreatedBy = 1 // Admin user
course.Price = 0.0 // Always free
courses[nextCourseID] = course
// Add resources separately
for _, resource := range course.Resources {
resource.ID = nextResourceID
resource.CourseID = nextCourseID
courseResources[nextCourseID] = append(courseResources[nextCourseID], resource)
nextResourceID++
}
nextCourseID++
}
// Create sample instance
sampleInstance := Instance{
Name: "Trackeep Demo Instance",
URL: "https://demo.trackeep.com",
Version: "2.0.0",
IsActive: true,
CreatedAt: time.Now(),
LastSync: time.Now(),
AdminUserID: 1,
}
sampleInstance.ID = nextInstanceID
sampleInstance.APIKey = generateRandomString(32)
instances[nextInstanceID] = sampleInstance
nextInstanceID++
log.Printf("Initialized %d sample courses and %d instance", len(sampleCourses), 1)
}