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(`
%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) { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repoName) req, _ := http.NewRequest(http.MethodGet, apiURL, 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 "", false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", false } var repo struct { HTMLURL string `json:"html_url"` } _ = json.NewDecoder(resp.Body).Decode(&repo) if repo.HTMLURL == "" { return "", false } return repo.HTMLURL, true } func githubCreateRepo(token, owner, repoName string, private bool) (htmlURL string, err error) { 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("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"` } if err := json.Unmarshal(raw, &created); err != nil { return "", err } if created.HTMLURL == "" { return "", errors.New("GitHub không trả URL repo") } return created.HTMLURL, 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.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(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, r); ok { return r, url, nil } } } for _, candidate := range examGitRepoNameCandidates(room) { if url, ok := githubGetRepo(token, owner, candidate); ok { // Repo đã tồn tại — chỉ dùng lại nếu đúng phòng thi này đã lưu URL if u := strings.TrimSpace(room.GitRepoURL); u != "" { if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) { return candidate, url, nil } } continue } url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { return candidate, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } return "", "", errors.New("không tạo được repo GitHub — đã thử nhiều tên khác nhau") } 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(token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) { branch = "main" owner = ghLogin repo, htmlURL, err := githubEnsureRepoForRoom(token, owner, room) if err != nil { return "", "", "", "", err } 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(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, r); ok { return r, url, nil } } } for _, candidate := range studentGitRepoNameCandidates(*row, room) { if url, ok := githubGetRepo(token, owner, candidate); ok { if u := strings.TrimSpace(row.GitRepoURL); u != "" { if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) { return candidate, url, nil } } continue } url, createErr := githubCreateRepo(token, owner, candidate, true) if createErr == nil { return candidate, url, nil } if isGitHubRepoNameTaken(createErr) { continue } return "", "", createErr } return "", "", errors.New("không tạo được repo GitHub cho sinh viên") }