Files
rikkei_simple_care/server/internal/handlers/handlers_exam_submissions.go
2026-06-30 14:46:19 +07:00

474 lines
13 KiB
Go

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) {
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
}
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
skipped, err := copySubmissionZipEntries(row.FilePath, folder, 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, folder+": "+err.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, folder+"/"+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
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,
})
}
}