package handlers import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "server/internal/auth" "server/internal/middleware" "server/internal/models" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) func githubOAuthConfig() (clientID, clientSecret, redirectURI string, err error) { clientID = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_ID")) clientSecret = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_SECRET")) redirectURI = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_REDIRECT_URI")) if redirectURI == "" { redirectURI = "http://127.0.0.1:8080/api/auth/github/callback" } if clientID == "" || clientSecret == "" { return "", "", "", errors.New("Server chưa cấu hình GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET") } return clientID, clientSecret, redirectURI, nil } func loadStaffGitHubToken(db *gorm.DB, staffID uint) (token string, login string, err error) { var row models.StaffGitHubAuth if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil { return "", "", err } token, err = auth.DecryptSecret(row.AccessTokenEnc) if err != nil { return "", "", err } return token, row.GitHubLogin, nil } // GET /api/auth/github/authorize — trả URL để mở popup OAuth (cần JWT staff) func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { if _, _, _, err := githubOAuthConfig(); err != nil { return c.Status(503).JSON(fiber.Map{"error": err.Error()}) } staffID := middleware.StaffIDFromCtx(c) if staffID == 0 { return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) } state, err := auth.IssueGitHubOAuthState(staffID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "Không tạo được state OAuth"}) } clientID, _, redirectURI, _ := githubOAuthConfig() authorizeURL := fmt.Sprintf( "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s", url.QueryEscape(clientID), url.QueryEscape(redirectURI), url.QueryEscape("repo delete_repo"), url.QueryEscape(state), ) return c.JSON(fiber.Map{"authorizeUrl": authorizeURL}) } } // GET /api/auth/github/callback — GitHub redirect (public) func GitHubOAuthCallbackHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { if errMsg := strings.TrimSpace(c.Query("error")); errMsg != "" { return oauthCallbackHTML(c, false, "GitHub từ chối: "+errMsg) } code := strings.TrimSpace(c.Query("code")) state := strings.TrimSpace(c.Query("state")) if code == "" || state == "" { return oauthCallbackHTML(c, false, "Thiếu mã xác thực từ GitHub") } staffID, err := auth.ParseGitHubOAuthState(state) if err != nil { return oauthCallbackHTML(c, false, "Phiên OAuth hết hạn — thử kết nối lại") } clientID, clientSecret, redirectURI, err := githubOAuthConfig() if err != nil { return oauthCallbackHTML(c, false, err.Error()) } form := url.Values{} form.Set("client_id", clientID) form.Set("client_secret", clientSecret) form.Set("code", code) form.Set("redirect_uri", redirectURI) req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) if err != nil { return oauthCallbackHTML(c, false, err.Error()) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return oauthCallbackHTML(c, false, "Không đổi được token GitHub") } defer resp.Body.Close() var tokenResp struct { AccessToken string `json:"access_token"` Scope string `json:"scope"` Error string `json:"error"` Description string `json:"error_description"` } _ = json.NewDecoder(resp.Body).Decode(&tokenResp) if tokenResp.AccessToken == "" { msg := tokenResp.Description if msg == "" { msg = tokenResp.Error } if msg == "" { msg = "Không nhận được access token" } return oauthCallbackHTML(c, false, msg) } userReq, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) userReq.Header.Set("Accept", "application/vnd.github+json") userResp, err := http.DefaultClient.Do(userReq) if err != nil { return oauthCallbackHTML(c, false, "Không lấy được thông tin GitHub") } defer userResp.Body.Close() var ghUser struct { Login string `json:"login"` } _ = json.NewDecoder(userResp.Body).Decode(&ghUser) if ghUser.Login == "" { return oauthCallbackHTML(c, false, "Tài khoản GitHub không hợp lệ") } enc, err := auth.EncryptSecret(tokenResp.AccessToken) if err != nil { return oauthCallbackHTML(c, false, "Không lưu được token") } row := models.StaffGitHubAuth{ StaffID: staffID, GitHubLogin: ghUser.Login, AccessTokenEnc: enc, Scope: tokenResp.Scope, ConnectedAt: time.Now(), } if err := db.Save(&row).Error; err != nil { return oauthCallbackHTML(c, false, "Lưu DB thất bại") } return oauthCallbackHTML(c, true, ghUser.Login) } } func oauthCallbackHTML(c *fiber.Ctx, ok bool, detail string) error { c.Set("Content-Type", "text/html; charset=utf-8") payload, _ := json.Marshal(map[string]any{"type": "simple-care-github-connected", "ok": ok, "detail": detail}) html := fmt.Sprintf(` GitHub

%s

`, detail, string(payload)) if !ok { html = strings.Replace(html, detail, "Lỗi: "+detail, 1) } else { html = strings.Replace(html, detail, "Đã kết nối GitHub: @"+detail, 1) } return c.SendString(html) } // GET /api/auth/github/status func GitHubStatusHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) var row models.StaffGitHubAuth if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil { return c.JSON(fiber.Map{"connected": false}) } return c.JSON(fiber.Map{ "connected": true, "githubLogin": row.GitHubLogin, "connectedAt": row.ConnectedAt, "scope": row.Scope, "canDeleteRepos": githubScopeAllowsDelete(row.Scope), }) } } // DELETE /api/auth/github func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) _ = db.Delete(&models.StaffGitHubAuth{}, "staff_id = ?", staffID).Error return c.JSON(fiber.Map{"ok": true}) } } func githubScopeAllowsDelete(scope string) bool { scope = strings.ToLower(strings.TrimSpace(scope)) if scope == "" { return false } for _, part := range strings.FieldsFunc(scope, func(r rune) bool { return r == ' ' || r == ',' }) { if part == "delete_repo" { return true } } return false } func githubGetUser(token string) (login string, err error) { req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() var u struct { Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { return "", err } if u.Login == "" { return "", errors.New("không lấy được tài khoản GitHub") } return u.Login, nil } func githubGetRepo(token, owner, repoName string) (htmlURL string, found bool) { meta, ok := githubFetchRepo(token, owner, repoName) if !ok { return "", false } return meta.HTMLURL, true } func githubFetchRepo(token, owner, repoName string) (meta struct { HTMLURL string DefaultBranch string }, ok bool) { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repoName)) req, _ := http.NewRequest(http.MethodGet, apiURL, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := http.DefaultClient.Do(req) if err != nil { return meta, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return meta, false } var repo struct { HTMLURL string `json:"html_url"` DefaultBranch string `json:"default_branch"` } _ = json.NewDecoder(resp.Body).Decode(&repo) if repo.HTMLURL == "" { return meta, false } meta.HTMLURL = repo.HTMLURL meta.DefaultBranch = strings.TrimSpace(repo.DefaultBranch) if meta.DefaultBranch == "" { meta.DefaultBranch = "main" } return meta, true } func clearStaleExamRoomGitURLs(db *gorm.DB, room *models.ExamRoom) { room.GitRepoURL = "" room.GitPublishURL = "" _ = db.Model(room).Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error } func clearStaleSubmissionGitURLs(db *gorm.DB, row *submissionRow) { row.GitRepoURL = "" row.GitPublishURL = "" _ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID). Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error } func githubRepoTrackedForRoom(db *gorm.DB, staffID uint, owner, repoName string, roomID uint) bool { var count int64 _ = db.Model(&models.StaffGitHubRepo{}). Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND exam_room_id = ? AND publish_mode = ?", staffID, owner, repoName, roomID, models.GitPublishModeRoom). Count(&count).Error return count > 0 } func githubRepoTrackedForSubmission(db *gorm.DB, staffID uint, owner, repoName string, submissionID uint) bool { var count int64 _ = db.Model(&models.StaffGitHubRepo{}). Where("staff_id = ? AND owner_login = ? AND repo_name = ? AND submission_id = ? AND publish_mode = ?", staffID, owner, repoName, submissionID, models.GitPublishModeStudent). Count(&count).Error return count > 0 } func repoNameMatchesSavedURL(savedURL, owner, repoName string) bool { if strings.TrimSpace(savedURL) == "" { return false } o, r, err := parseGitHubRepo(savedURL) return err == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, repoName) } func githubCreateRepo(token, owner, repoName string, private bool) (actualName, htmlURL string, err error) { repoName = trimGitHubRepoName(repoName) body := map[string]any{ "name": repoName, "private": private, "auto_init": true, "description": "Bài nộp phòng thi — Simple Care", } payload, _ := json.Marshal(body) req, err := http.NewRequest(http.MethodPost, "https://api.github.com/user/repos", bytes.NewReader(payload)) if err != nil { return "", "", err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", "", fmt.Errorf("tạo repo thất bại: %s", strings.TrimSpace(string(raw))) } var created struct { HTMLURL string `json:"html_url"` Name string `json:"name"` FullName string `json:"full_name"` } if err := json.Unmarshal(raw, &created); err != nil { return "", "", err } if created.HTMLURL == "" { return "", "", errors.New("GitHub không trả URL repo") } actualName = strings.TrimSpace(created.Name) if actualName == "" { if _, r, perr := parseGitHubRepo(created.HTMLURL); perr == nil { actualName = r } else { actualName = repoName } } return actualName, created.HTMLURL, nil } func githubOwnerRepoFromURL(htmlURL string) (owner, repo string, ok bool) { o, r, err := parseGitHubRepo(htmlURL) return o, r, err == nil } func isGitHubRepoNameTaken(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "name already exists") || strings.Contains(msg, `"code":"custom"`) && strings.Contains(msg, "name") } func trimGitHubRepoName(name string) string { name = strings.ToLower(strings.TrimSpace(name)) name = strings.NewReplacer(" ", "-", "_", "-").Replace(name) var b strings.Builder for _, r := range name { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.': b.WriteRune(r) case r == '_': b.WriteRune('-') } } name = b.String() for strings.Contains(name, "--") { name = strings.ReplaceAll(name, "--", "-") } name = strings.Trim(name, "-.") if len(name) > 90 { name = strings.Trim(name[:90], "-.") } if name == "" { name = "exam-repo" } return name } func examGitRepoBaseName(room *models.ExamRoom) string { base := strings.ToLower(sanitizePathPart(room.Name)) base = strings.NewReplacer(" ", "-", "_", "-").Replace(base) base = invalidPathChars.ReplaceAllString(base, "-") base = strings.Trim(base, "-.") for strings.Contains(base, "--") { base = strings.ReplaceAll(base, "--", "-") } if base == "" { base = fmt.Sprintf("exam-%d", room.ID) } return trimGitHubRepoName(base) } func examGitRepoNameCandidates(room *models.ExamRoom) []string { base := examGitRepoBaseName(room) candidates := []string{ base, fmt.Sprintf("%s-exam-%d", base, room.ID), fmt.Sprintf("%s-%d", base, room.ID), fmt.Sprintf("exam-%d", room.ID), } for i := 2; i <= 20; i++ { candidates = append(candidates, fmt.Sprintf("%s-exam-%d-%d", base, room.ID, i)) } seen := map[string]bool{} out := make([]string, 0, len(candidates)) for _, c := range candidates { c = trimGitHubRepoName(c) if seen[c] { continue } seen[c] = true out = append(out, c) } return out } // githubEnsureRepoForRoom — dùng repo đã gắn phòng thi, hoặc tạo mới; trùng tên thì tự đổi tên. func githubEnsureRepoForRoom(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom) (repoName, htmlURL string, err error) { if u := strings.TrimSpace(room.GitRepoURL); u != "" { o, r, parseErr := parseGitHubRepo(u) if parseErr == nil && strings.EqualFold(o, owner) { if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok { if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { return name, url, nil } return trimGitHubRepoName(r), url, nil } clearStaleExamRoomGitURLs(db, room) } } var tracked models.StaffGitHubRepo if err := db.Where("staff_id = ? AND exam_room_id = ? AND publish_mode = ?", staffID, room.ID, models.GitPublishModeRoom). Order("created_at desc").First(&tracked).Error; err == nil { if strings.EqualFold(tracked.OwnerLogin, owner) { trackedName := trimGitHubRepoName(tracked.RepoName) if url, ok := githubGetRepo(token, owner, trackedName); ok { if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { return name, url, nil } return trackedName, url, nil } } } for _, candidate := range examGitRepoNameCandidates(room) { if url, ok := githubGetRepo(token, owner, candidate); ok { if repoNameMatchesSavedURL(room.GitRepoURL, owner, candidate) || githubRepoTrackedForRoom(db, staffID, owner, candidate, room.ID) { return candidate, url, nil } continue } actualName, url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { return actualName, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", examGitRepoBaseName(room), room.ID, time.Now().Unix())) actualName, url, createErr := githubCreateRepo(token, owner, fallback, true) if createErr != nil { return "", "", fmt.Errorf("không tạo được repo GitHub — đã thử nhiều tên khác nhau: %w", createErr) } return actualName, url, nil } func examGitRepoName(room *models.ExamRoom) string { if u := strings.TrimSpace(room.GitRepoURL); u != "" { if _, name, err := parseGitHubRepo(u); err == nil && name != "" { return name } } return examGitRepoBaseName(room) } func resolvePublishRepo(db *gorm.DB, staffID uint, token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) { owner = ghLogin repo, htmlURL, err := githubEnsureRepoForRoom(db, staffID, token, owner, room) if err != nil { return "", "", "", "", err } branch = "main" if meta, ok := githubFetchRepo(token, owner, repo); ok && meta.DefaultBranch != "" { branch = meta.DefaultBranch } return owner, repo, branch, htmlURL, nil } func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []string { base := trimGitHubRepoName(strings.ToLower(studentSubmissionFolder(row.StudentCode, row.FullName))) candidates := []string{ base, fmt.Sprintf("%s-exam-%d", base, room.ID), fmt.Sprintf("%s-%d", base, row.StudentRkID), fmt.Sprintf("exam-%d-%d", room.ID, row.StudentRkID), } seen := map[string]bool{} out := make([]string, 0, len(candidates)) for _, c := range candidates { c = trimGitHubRepoName(c) if seen[c] { continue } seen[c] = true out = append(out, c) } for i := 2; i <= 10; i++ { c := trimGitHubRepoName(fmt.Sprintf("%s-%d", base, i)) if !seen[c] { seen[c] = true out = append(out, c) } } return out } func githubEnsureRepoForStudent(db *gorm.DB, staffID uint, token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) { if u := strings.TrimSpace(row.GitRepoURL); u != "" { o, r, parseErr := parseGitHubRepo(u) if parseErr == nil && strings.EqualFold(o, owner) { if url, ok := githubGetRepo(token, owner, trimGitHubRepoName(r)); ok { if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { return name, url, nil } return trimGitHubRepoName(r), url, nil } clearStaleSubmissionGitURLs(db, row) } } var tracked models.StaffGitHubRepo if err := db.Where("staff_id = ? AND submission_id = ? AND publish_mode = ?", staffID, row.ID, models.GitPublishModeStudent). Order("created_at desc").First(&tracked).Error; err == nil { if strings.EqualFold(tracked.OwnerLogin, owner) { trackedName := trimGitHubRepoName(tracked.RepoName) if url, ok := githubGetRepo(token, owner, trackedName); ok { if _, name, perr := parseGitHubRepo(url); perr == nil && name != "" { return name, url, nil } return trackedName, url, nil } } } for _, candidate := range studentGitRepoNameCandidates(*row, room) { if url, ok := githubGetRepo(token, owner, candidate); ok { if repoNameMatchesSavedURL(row.GitRepoURL, owner, candidate) || githubRepoTrackedForSubmission(db, staffID, owner, candidate, row.ID) { return candidate, url, nil } continue } actualName, url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { return actualName, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } fallback := trimGitHubRepoName(fmt.Sprintf("%s-sc-%d-%d", studentSubmissionFolder(row.StudentCode, row.FullName), row.StudentRkID, time.Now().Unix())) actualName, url, createErr := githubCreateRepo(token, owner, fallback, true) if createErr != nil { return "", "", fmt.Errorf("không tạo được repo GitHub cho sinh viên: %w", createErr) } return actualName, url, nil }