474 lines
13 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|