git feature
This commit is contained in:
473
server/internal/handlers/handlers_exam_submissions.go
Normal file
473
server/internal/handlers/handlers_exam_submissions.go
Normal file
@@ -0,0 +1,473 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user