package handlers import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "server/internal/middleware" "server/internal/models" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type githubRepoListItem struct { Key string `json:"key"` OwnerLogin string `json:"ownerLogin"` RepoName string `json:"repoName"` RepoHTMLURL string `json:"repoHtmlUrl"` Private bool `json:"private"` PublishMode string `json:"publishMode,omitempty"` ExamRoomID *uint `json:"examRoomId,omitempty"` ExamRoomName string `json:"examRoomName,omitempty"` StudentLabel string `json:"studentLabel,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` FromSimpleCare bool `json:"fromSimpleCare"` } func githubDeleteRepo(token, owner, repo string) error { apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) req, err := http.NewRequest(http.MethodDelete, apiURL, nil) 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") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil } if resp.StatusCode >= 300 { raw, _ := io.ReadAll(resp.Body) msg := strings.TrimSpace(string(raw)) var errBody struct { Message string `json:"message"` } _ = json.Unmarshal(raw, &errBody) if m := strings.TrimSpace(errBody.Message); m != "" { msg = m } if msg == "" { msg = fmt.Sprintf("HTTP %d", resp.StatusCode) } if resp.StatusCode == http.StatusForbidden { return fmt.Errorf("%s — token thiếu quyền delete_repo; ngắt kết nối rồi kết nối lại GitHub", msg) } return fmt.Errorf("%s", msg) } return nil } func githubListUserRepos(token string) ([]struct { Name string `json:"name"` FullName string `json:"full_name"` HTMLURL string `json:"html_url"` Private bool `json:"private"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` }, error) { var all []struct { Name string `json:"name"` FullName string `json:"full_name"` HTMLURL string `json:"html_url"` Private bool `json:"private"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } for page := 1; page <= 10; page++ { apiURL := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&sort=updated&direction=desc", page) req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } var batch []struct { Name string `json:"name"` FullName string `json:"full_name"` HTMLURL string `json:"html_url"` Private bool `json:"private"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil { resp.Body.Close() return nil, err } resp.Body.Close() if resp.StatusCode >= 300 { return nil, fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode) } all = append(all, batch...) if len(batch) < 100 { break } } return all, nil } type repoMeta struct { PublishMode string ExamRoomID *uint ExamRoomName string StudentLabel string } func repoMetaKey(owner, name string) string { return strings.ToLower(owner) + "/" + strings.ToLower(name) } func buildRepoMetaMap(db *gorm.DB, staffID uint) map[string]repoMeta { out := map[string]repoMeta{} var tracked []models.StaffGitHubRepo _ = db.Where("staff_id = ?", staffID).Find(&tracked).Error roomNames := map[uint]string{} for _, t := range tracked { key := repoMetaKey(t.OwnerLogin, t.RepoName) meta := repoMeta{ PublishMode: t.PublishMode, ExamRoomID: t.ExamRoomID, StudentLabel: t.StudentLabel, } if t.ExamRoomID != nil { rid := *t.ExamRoomID if name, ok := roomNames[rid]; ok { meta.ExamRoomName = name } else { var room models.ExamRoom if err := db.Select("name").First(&room, rid).Error; err == nil { roomNames[rid] = room.Name meta.ExamRoomName = room.Name } } } out[key] = meta } return out } func recordStaffGitHubRepo(db *gorm.DB, staffID uint, owner, repoName, htmlURL, publishMode, studentLabel string, examRoomID, submissionID *uint) { owner = strings.TrimSpace(owner) repoName = strings.TrimSpace(repoName) if staffID == 0 || owner == "" || repoName == "" || htmlURL == "" { return } _ = db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, owner, repoName). Delete(&models.StaffGitHubRepo{}).Error row := models.StaffGitHubRepo{ StaffID: staffID, OwnerLogin: owner, RepoName: repoName, RepoHTMLURL: htmlURL, ExamRoomID: examRoomID, SubmissionID: submissionID, StudentLabel: studentLabel, PublishMode: publishMode, } _ = db.Create(&row).Error } // GET /api/auth/github/repos — toàn bộ repo trên tài khoản GitHub đã kết nối func GitHubListReposHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) token, ghLogin, err := loadStaffGitHubToken(db, staffID) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"}) } ghRepos, err := githubListUserRepos(token) if err != nil { return c.Status(502).JSON(fiber.Map{"error": "Không tải repo từ GitHub: " + err.Error()}) } metaMap := buildRepoMetaMap(db, staffID) out := make([]githubRepoListItem, 0, len(ghRepos)) for _, r := range ghRepos { parts := strings.SplitN(r.FullName, "/", 2) owner := ghLogin if len(parts) == 2 { owner = parts[0] } key := owner + "/" + r.Name item := githubRepoListItem{ Key: key, OwnerLogin: owner, RepoName: r.Name, RepoHTMLURL: r.HTMLURL, Private: r.Private, CreatedAt: r.CreatedAt.Format(time.RFC3339), UpdatedAt: r.UpdatedAt.Format(time.RFC3339), FromSimpleCare: strings.Contains(strings.ToLower(r.Description), "simple care"), } if meta, ok := metaMap[repoMetaKey(owner, r.Name)]; ok { item.PublishMode = meta.PublishMode item.ExamRoomID = meta.ExamRoomID item.ExamRoomName = meta.ExamRoomName item.StudentLabel = meta.StudentLabel item.FromSimpleCare = true } out = append(out, item) } return c.JSON(fiber.Map{"data": out, "total": len(out), "githubLogin": ghLogin}) } } // POST /api/auth/github/repos/bulk-delete — repos: ["owner/name", ...] func GitHubBulkDeleteReposHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) token, _, err := loadStaffGitHubToken(db, staffID) if err != nil { return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"}) } var authRow models.StaffGitHubAuth _ = db.First(&authRow, "staff_id = ?", staffID).Error if !githubScopeAllowsDelete(authRow.Scope) { return c.Status(403).JSON(fiber.Map{ "error": "Token GitHub chưa có quyền xóa repo (delete_repo). Ngắt kết nối rồi bấm Kết nối lại GitHub.", }) } var req struct { Repos []string `json:"repos"` IDs []uint `json:"ids"` // legacy } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } targets := make([]struct{ owner, name string }, 0) for _, raw := range req.Repos { raw = strings.TrimSpace(raw) if raw == "" { continue } parts := strings.SplitN(raw, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { continue } targets = append(targets, struct{ owner, name string }{owner: parts[0], name: parts[1]}) } if len(targets) == 0 && len(req.IDs) > 0 { var rows []models.StaffGitHubRepo _ = db.Where("staff_id = ? AND id IN ?", staffID, req.IDs).Find(&rows).Error for _, row := range rows { targets = append(targets, struct{ owner, name string }{owner: row.OwnerLogin, name: row.RepoName}) } } if len(targets) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chọn ít nhất một repo"}) } deleted := 0 var failures []string for _, t := range targets { if err := githubDeleteRepo(token, t.owner, t.name); err != nil { failures = append(failures, t.name+": "+err.Error()) continue } var row models.StaffGitHubRepo if err := db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, t.owner, t.name).First(&row).Error; err == nil { _ = db.Delete(&row).Error clearLinkedGitURLs(db, row) } else { clearLinkedGitURLsByName(db, t.owner, t.name) } deleted++ } return c.JSON(fiber.Map{ "ok": deleted > 0, "deleted": deleted, "failures": failures, "message": fmt.Sprintf("Đã xóa %d repo trên GitHub", deleted), }) } } func clearLinkedGitURLs(db *gorm.DB, row models.StaffGitHubRepo) { url := strings.TrimSpace(row.RepoHTMLURL) if url == "" { return } if row.ExamRoomID != nil && row.PublishMode == models.GitPublishModeRoom { _ = db.Model(&models.ExamRoom{}).Where("id = ? AND git_repo_url = ?", *row.ExamRoomID, url). Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error } if row.SubmissionID != nil { _ = db.Model(&models.ExamSubmission{}).Where("id = ? AND git_repo_url = ?", *row.SubmissionID, url). Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error } } func clearLinkedGitURLsByName(db *gorm.DB, owner, repoName string) { prefix := fmt.Sprintf("https://github.com/%s/%s", owner, repoName) _ = db.Model(&models.ExamRoom{}).Where("git_repo_url LIKE ?", prefix+"%"). Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error _ = db.Model(&models.ExamSubmission{}).Where("git_repo_url LIKE ?", prefix+"%"). Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error }