package handlers import ( "archive/zip" "errors" "fmt" "io" "os" "path/filepath" "regexp" "strings" "server/internal/middleware" "server/internal/models" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) var invalidPathChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]+`) type submissionRow struct { models.ExamSubmission FullName string StudentCode string } func sanitizePathPart(s string) string { s = strings.TrimSpace(s) s = invalidPathChars.ReplaceAllString(s, "_") s = strings.Trim(s, " .") if s == "" { return "unknown" } return s } func studentSubmissionFolder(code, fullName string) string { code = sanitizePathPart(code) name := sanitizePathPart(fullName) if code != "unknown" && name != "unknown" { return code + "_" + name } if code != "unknown" { return code } if name != "unknown" { return name } return "sinh_vien" } func shouldSkipBundledPath(rel string) bool { rel = filepath.ToSlash(strings.TrimSpace(rel)) if rel == "" { return true } for _, part := range strings.Split(rel, "/") { switch part { case ".git", "__MACOSX", ".svn", ".hg": return true } } base := filepath.Base(rel) switch base { case ".DS_Store", "Thumbs.db", "desktop.ini": return true } return false } func unzipToDir(zipPath, destDir string) (skipped []string, err error) { r, err := zip.OpenReader(zipPath) if err != nil { return nil, err } defer r.Close() cleanDest := filepath.Clean(destDir) for _, f := range r.File { name := filepath.FromSlash(f.Name) if shouldSkipBundledPath(name) { continue } target := filepath.Join(destDir, name) cleanTarget := filepath.Clean(target) if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) { continue } if f.FileInfo().IsDir() { if err := os.MkdirAll(cleanTarget, 0755); err != nil { skipped = append(skipped, name+": "+err.Error()) } continue } if err := os.MkdirAll(filepath.Dir(cleanTarget), 0755); err != nil { skipped = append(skipped, name+": "+err.Error()) continue } rc, err := f.Open() if err != nil { skipped = append(skipped, name+": "+err.Error()) continue } out, err := os.Create(cleanTarget) if err != nil { rc.Close() skipped = append(skipped, name+": "+err.Error()) continue } _, copyErr := io.Copy(out, rc) out.Close() rc.Close() if copyErr != nil { skipped = append(skipped, name+": "+copyErr.Error()) } } return skipped, nil } func loadExamSubmissionRows(db *gorm.DB, roomID uint) ([]submissionRow, error) { var subs []models.ExamSubmission if err := db.Where("exam_room_id = ?", roomID).Order("created_at asc").Find(&subs).Error; err != nil { return nil, err } rows := make([]submissionRow, 0, len(subs)) for _, s := range subs { row := submissionRow{ExamSubmission: s} var st models.Student if err := db.Where("rk_id = ?", s.StudentRkID).First(&st).Error; err == nil { row.FullName = st.FullName row.StudentCode = st.StudentCode } rows = append(rows, row) } return rows, nil } func copySubmissionZipEntries(srcZipPath, prefix string, addFile func(destPath string, open func() (io.ReadCloser, error), size int64) error) (skipped []string, err error) { r, err := zip.OpenReader(srcZipPath) if err != nil { return nil, err } defer r.Close() prefix = strings.Trim(prefix, "/\\") for _, f := range r.File { name := filepath.ToSlash(filepath.FromSlash(f.Name)) if shouldSkipBundledPath(name) { continue } if f.FileInfo().IsDir() { continue } dest := name if prefix != "" { dest = prefix + "/" + name } dest = strings.TrimPrefix(dest, "/") entry := f addErr := addFile(dest, entry.Open, int64(entry.UncompressedSize64)) if addErr != nil { skipped = append(skipped, dest+": "+addErr.Error()) } } return skipped, nil } func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings []string, fileCount int, err error) { f, err := os.Create(destZipPath) if err != nil { return nil, 0, err } zw := zip.NewWriter(f) usedFolders := map[string]int{} for _, row := range rows { if _, statErr := os.Stat(row.FilePath); statErr != nil { warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID)) continue } folder := studentSubmissionFolder(row.StudentCode, row.FullName) if n := usedFolders[folder]; n > 0 { folder = fmt.Sprintf("%s_%d", folder, n+1) } usedFolders[folder]++ skipped, copyErr := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error { hdr := &zip.FileHeader{ Name: destPath, Method: zip.Deflate, } w, err := zw.CreateHeader(hdr) if err != nil { return err } rc, err := open() if err != nil { return err } _, copyErr := io.Copy(w, rc) rc.Close() if copyErr == nil { fileCount++ } return copyErr }) if copyErr != nil { warnings = append(warnings, folder+": "+copyErr.Error()) continue } for _, s := range skipped { warnings = append(warnings, folder+"/"+s) } } if closeErr := zw.Close(); closeErr != nil { f.Close() _ = os.Remove(destZipPath) return warnings, fileCount, closeErr } if closeErr := f.Close(); closeErr != nil { _ = os.Remove(destZipPath) return warnings, fileCount, closeErr } if fileCount == 0 { _ = os.Remove(destZipPath) return warnings, 0, errors.New("không có file bài nộp hợp lệ để gộp") } return warnings, fileCount, nil } func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) { return collectSubmissionZipFilesWithPrefix(rows, true) } func collectSubmissionZipFilesWithPrefix(rows []submissionRow, useStudentFolder bool) (files []gitPathFile, warnings []string) { byPath := map[string]gitPathFile{} usedFolders := map[string]int{} for _, row := range rows { if _, statErr := os.Stat(row.FilePath); statErr != nil { warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID)) continue } prefix := "" if useStudentFolder { folder := studentSubmissionFolder(row.StudentCode, row.FullName) if n := usedFolders[folder]; n > 0 { folder = fmt.Sprintf("%s_%d", folder, n+1) } usedFolders[folder]++ prefix = folder } skipped, err := copySubmissionZipEntries(row.FilePath, prefix, func(destPath string, open func() (io.ReadCloser, error), size int64) error { if size > githubBlobMaxBytes { warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua") return nil } rc, err := open() if err != nil { return err } data, readErr := io.ReadAll(rc) rc.Close() if readErr != nil { return readErr } byPath[destPath] = gitPathFile{path: destPath, content: data} return nil }) if err != nil { warnings = append(warnings, prefix+": "+err.Error()) continue } for _, s := range skipped { warnings = append(warnings, prefix+"/"+s) } } files = make([]gitPathFile, 0, len(byPath)) for _, f := range byPath { files = append(files, f) } return files, warnings } func buildExamSubmissionsBundle(room models.ExamRoom, rows []submissionRow) (zipPath string, cleanup func(), err error) { tmpBase, err := os.MkdirTemp("", "exam-bundle-*") if err != nil { return "", nil, err } cleanup = func() { _ = os.RemoveAll(tmpBase) } rootName := sanitizePathPart(room.Name) + "_bai_nop" outZip := filepath.Join(tmpBase, rootName+".zip") _, _, err = bundleSubmissionZips(rows, outZip) if err != nil { cleanup() return "", nil, err } return outZip, cleanup, nil } func prepareExamSubmissionsForGit(rows []submissionRow) (files []gitPathFile, warnings []string, err error) { files, warnings = collectSubmissionZipFiles(rows) if len(files) == 0 { return nil, warnings, errors.New("không có file bài nộp hợp lệ để đẩy") } return files, warnings, nil } // GET /api/exam-rooms/:id/submissions/download-all func DownloadAllExamSubmissionsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) } rows, err := loadExamSubmissionRows(db, roomID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if len(rows) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"}) } zipPath, cleanup, err := buildExamSubmissionsBundle(room, rows) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } zipBytes, readErr := os.ReadFile(zipPath) cleanup() if readErr != nil { return c.Status(500).JSON(fiber.Map{"error": "Không đọc được file zip gộp"}) } downloadName := sanitizePathPart(room.Name) + "_bai_nop.zip" c.Set("Content-Type", "application/zip") c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, downloadName)) return c.Send(zipBytes) } } // PATCH /api/exam-rooms/:id/git-settings func UpdateExamRoomGitSettingsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) } var req struct { GitRepoURL *string `json:"gitRepoUrl"` GitBranch *string `json:"gitBranch"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } if req.GitRepoURL != nil { room.GitRepoURL = strings.TrimSpace(*req.GitRepoURL) } if req.GitBranch != nil { b := strings.TrimSpace(*req.GitBranch) if b == "" { b = "main" } room.GitBranch = b } if room.GitBranch == "" { room.GitBranch = "main" } if err := db.Save(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ "gitRepoUrl": room.GitRepoURL, "gitBranch": room.GitBranch, "gitPublishUrl": room.GitPublishURL, }) } } func parseGitHubRepo(repoURL string) (owner, repo string, err error) { u := strings.TrimSpace(repoURL) u = strings.TrimSuffix(u, ".git") u = strings.TrimPrefix(u, "https://github.com/") u = strings.TrimPrefix(u, "http://github.com/") u = strings.TrimPrefix(u, "github.com/") parts := strings.Split(strings.Trim(u, "/"), "/") if len(parts) < 2 || parts[0] == "" || parts[1] == "" { return "", "", errors.New("URL GitHub không hợp lệ (vd: https://github.com/org/repo)") } return parts[0], parts[1], nil } // POST /api/exam-rooms/:id/submissions/publish-git func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) if staffID == 0 { return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) } token, ghLogin, err := loadStaffGitHubToken(db, staffID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(401).JSON(fiber.Map{ "error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.", "needsGitHubOAuth": true, }) } return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"}) } roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) } rows, err := loadExamSubmissionRows(db, roomID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if len(rows) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"}) } files, unpackWarn, err := prepareExamSubmissionsForGit(rows) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error(), "warnings": unpackWarn}) } owner, repo, branch, repoHTML, err := resolvePublishRepo(token, ghLogin, &room) if err != nil { return c.Status(502).JSON(fiber.Map{"error": err.Error()}) } if repoHTML != "" { room.GitRepoURL = repoHTML room.GitBranch = branch } commitMsg := fmt.Sprintf("Simple Care: bài nộp phòng thi %s (#%d)", room.Name, roomID) viewURL, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, commitMsg) allWarn := append(unpackWarn, pushWarn...) if err != nil { return c.Status(502).JSON(fiber.Map{ "error": "Đẩy Git thất bại: " + err.Error(), "warnings": allWarn, }) } room.GitPublishURL = viewURL _ = db.Save(&room).Error roomIDCopy := roomID recordStaffGitHubRepo(db, staffID, owner, repo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil) msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows)) if len(allWarn) > 0 { msg += fmt.Sprintf(" (%d file bỏ qua do lỗi)", len(allWarn)) } return c.JSON(fiber.Map{ "ok": true, "url": viewURL, "gitPublishUrl": viewURL, "gitRepoUrl": room.GitRepoURL, "githubLogin": ghLogin, "openUrl": viewURL, "warnings": allWarn, "message": msg, }) } } // POST /api/exam-rooms/:id/submissions/publish-git-students — mỗi sinh viên một repo GitHub riêng func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { staffID := middleware.StaffIDFromCtx(c) if staffID == 0 { return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) } token, ghLogin, err := loadStaffGitHubToken(db, staffID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(401).JSON(fiber.Map{ "error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.", "needsGitHubOAuth": true, }) } return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"}) } roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) } rows, err := loadExamSubmissionRows(db, roomID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if len(rows) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"}) } branch := "main" published := 0 var allWarn []string var repoURLs []string type studentRepo struct { StudentRkID int64 `json:"studentRkId"` StudentCode string `json:"studentCode"` FullName string `json:"fullName"` URL string `json:"url"` } studentRepos := make([]studentRepo, 0, len(rows)) for _, row := range rows { files, unpackWarn := collectSubmissionZipFilesWithPrefix([]submissionRow{row}, false) allWarn = append(allWarn, unpackWarn...) if len(files) == 0 { allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName))) continue } repoName, repoHTML, err := githubEnsureRepoForStudent(token, ghLogin, &room, &row) if err != nil { allWarn = append(allWarn, row.StudentCode+": "+err.Error()) continue } label := studentSubmissionFolder(row.StudentCode, row.FullName) commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name) viewURL, pushWarn, err := githubPushFiles(token, ghLogin, repoName, branch, files, commitMsg) allWarn = append(allWarn, pushWarn...) if err != nil { allWarn = append(allWarn, label+": "+err.Error()) continue } _ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID). Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error subID := row.ID roomIDCopy := roomID recordStaffGitHubRepo(db, staffID, ghLogin, repoName, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID) published++ repoURLs = append(repoURLs, viewURL) studentRepos = append(studentRepos, studentRepo{ StudentRkID: row.StudentRkID, StudentCode: row.StudentCode, FullName: row.FullName, URL: viewURL, }) } if published == 0 { return c.Status(502).JSON(fiber.Map{ "error": "Không đẩy được repo nào", "warnings": allWarn, }) } msg := fmt.Sprintf("Đã tạo %d repo GitHub — mỗi sinh viên một repo.", published) if len(allWarn) > 0 { msg += fmt.Sprintf(" (%d cảnh báo)", len(allWarn)) } openURL := "" if len(repoURLs) > 0 { openURL = repoURLs[0] } return c.JSON(fiber.Map{ "ok": true, "published": published, "studentRepos": studentRepos, "openUrl": openURL, "warnings": allWarn, "message": msg, }) } }