diff --git a/management/src/hooks/useGitHubConnection.ts b/management/src/hooks/useGitHubConnection.ts
new file mode 100644
index 0000000..a9043d3
--- /dev/null
+++ b/management/src/hooks/useGitHubConnection.ts
@@ -0,0 +1,86 @@
+import { useCallback, useEffect, useState } from 'react';
+import { apiGitHub } from '../api';
+
+export function useGitHubConnection() {
+ const [connected, setConnected] = useState(false);
+ const [githubLogin, setGithubLogin] = useState('');
+ const [connecting, setConnecting] = useState(false);
+ const [error, setError] = useState('');
+ const [message, setMessage] = useState('');
+
+ const refresh = useCallback(async () => {
+ try {
+ const st = await apiGitHub.status();
+ setConnected(!!st.connected);
+ setGithubLogin(st.githubLogin || '');
+ } catch (e) {
+ console.error(e);
+ setConnected(false);
+ setGithubLogin('');
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh().catch(console.error);
+ }, [refresh]);
+
+ useEffect(() => {
+ const onMessage = (e: MessageEvent) => {
+ const data = e.data as { type?: string; ok?: boolean; detail?: string };
+ if (data?.type !== 'simple-care-github-connected') return;
+ setConnecting(false);
+ if (data.ok) {
+ refresh().catch(console.error);
+ setMessage(typeof data.detail === 'string' ? `Đã kết nối GitHub @${data.detail}` : 'Đã kết nối GitHub');
+ setError('');
+ } else {
+ setError(typeof data.detail === 'string' ? data.detail : 'Kết nối GitHub thất bại');
+ }
+ };
+ window.addEventListener('message', onMessage);
+ return () => window.removeEventListener('message', onMessage);
+ }, [refresh]);
+
+ const connect = async () => {
+ setError('');
+ setMessage('');
+ setConnecting(true);
+ try {
+ const { authorizeUrl } = await apiGitHub.authorizeUrl();
+ const w = window.open(authorizeUrl, 'simple_care_github_oauth', 'width=720,height=760');
+ if (!w) {
+ setConnecting(false);
+ setError('Trình duyệt chặn popup — cho phép popup rồi thử lại.');
+ }
+ } catch (e: any) {
+ setConnecting(false);
+ setError(e?.message || 'Không mở được OAuth GitHub');
+ }
+ };
+
+ const disconnect = async () => {
+ setError('');
+ setMessage('');
+ try {
+ await apiGitHub.disconnect();
+ setConnected(false);
+ setGithubLogin('');
+ setMessage('Đã ngắt kết nối GitHub');
+ } catch (e: any) {
+ setError(e?.message || 'Ngắt kết nối thất bại');
+ }
+ };
+
+ return {
+ connected,
+ githubLogin,
+ connecting,
+ error,
+ message,
+ setError,
+ setMessage,
+ refresh,
+ connect,
+ disconnect,
+ };
+}
diff --git a/management/src/index.css b/management/src/index.css
index 75caa56..02f2b79 100644
--- a/management/src/index.css
+++ b/management/src/index.css
@@ -4261,3 +4261,94 @@ input:checked + .slider:before {
justify-content: center;
}
}
+
+.exam-submissions-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-light);
+ background: #fafafa;
+}
+
+.exam-submissions-toolbar-hint {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ flex: 1;
+ min-width: 200px;
+}
+
+.exam-git-settings {
+ margin-top: 0.5rem;
+ padding-top: 0.75rem;
+ border-top: 1px dashed var(--border-light);
+}
+
+.exam-git-oauth-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.65rem;
+ margin-bottom: 0.65rem;
+}
+
+.exam-git-connected {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.exam-git-connected strong {
+ color: var(--text-primary);
+}
+
+.exam-git-status-line {
+ margin: 0 0 0.65rem;
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+.exam-git-not-connected {
+ color: var(--danger);
+}
+
+.exam-git-branch-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.45rem;
+}
+
+.exam-git-branch-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.exam-git-branch-input {
+ width: 120px;
+ padding: 0.4rem 0.55rem;
+ font-size: 0.85rem;
+}
+
+.exam-git-hint {
+ margin: 0.5rem 0 0;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+
+.exam-git-hint code {
+ font-size: 0.72rem;
+}
+
+.exam-git-last {
+ margin: 0.45rem 0 0;
+ font-size: 0.78rem;
+ word-break: break-all;
+}
+
+.exam-git-last a {
+ color: var(--accent);
+}
diff --git a/server/.env b/server/.env
index e010a0b..f6a2c82 100644
--- a/server/.env
+++ b/server/.env
@@ -6,4 +6,9 @@ MAIL_HOST=smtp.gmail.com
MAIL_PORT=465
MAIL_SECURE=true
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
-MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
\ No newline at end of file
+MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
+
+# GitHub OAuth — mỗi giáo viên kết nối tài khoản riêng (tạo OAuth App tại github.com/settings/developers)
+GITHUB_OAUTH_CLIENT_ID=Ov23liRiArENu3uBwyDz
+GITHUB_OAUTH_CLIENT_SECRET=f1d762d4061c83905bd13acaa8611ee2fb0ccb01
+GITHUB_OAUTH_REDIRECT_URI=http://127.0.0.1:8080/api/auth/github/callback
\ No newline at end of file
diff --git a/server/internal/auth/github_token.go b/server/internal/auth/github_token.go
new file mode 100644
index 0000000..337f30e
--- /dev/null
+++ b/server/internal/auth/github_token.go
@@ -0,0 +1,90 @@
+package auth
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "io"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type GitHubOAuthStateClaims struct {
+ StaffID uint `json:"staffId"`
+ jwt.RegisteredClaims
+}
+
+func IssueGitHubOAuthState(staffID uint) (string, error) {
+ claims := GitHubOAuthStateClaims{
+ StaffID: staffID,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ },
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return t.SignedString([]byte(JWTSecret()))
+}
+
+func ParseGitHubOAuthState(state string) (uint, error) {
+ t, err := jwt.ParseWithClaims(state, &GitHubOAuthStateClaims{}, func(t *jwt.Token) (any, error) {
+ return []byte(JWTSecret()), nil
+ })
+ if err != nil {
+ return 0, err
+ }
+ claims, ok := t.Claims.(*GitHubOAuthStateClaims)
+ if !ok || !t.Valid || claims.StaffID == 0 {
+ return 0, errors.New("invalid oauth state")
+ }
+ return claims.StaffID, nil
+}
+
+func tokenCipher() (cipher.AEAD, error) {
+ sum := sha256.Sum256([]byte(JWTSecret()))
+ block, err := aes.NewCipher(sum[:])
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
+
+func EncryptSecret(plain string) (string, error) {
+ if plain == "" {
+ return "", errors.New("empty secret")
+ }
+ gcm, err := tokenCipher()
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+ sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
+ return base64.StdEncoding.EncodeToString(sealed), nil
+}
+
+func DecryptSecret(encoded string) (string, error) {
+ raw, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", err
+ }
+ gcm, err := tokenCipher()
+ if err != nil {
+ return "", err
+ }
+ if len(raw) < gcm.NonceSize() {
+ return "", errors.New("ciphertext too short")
+ }
+ nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
+ plain, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", err
+ }
+ return string(plain), nil
+}
diff --git a/server/internal/db/db.go b/server/internal/db/db.go
index f37ba45..9aae724 100644
--- a/server/internal/db/db.go
+++ b/server/internal/db/db.go
@@ -62,6 +62,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.ExamPaperResource{},
&models.ExamRoomStudent{},
&models.ExamSubmission{},
+ &models.StaffGitHubAuth{},
); err != nil {
return err
}
diff --git a/server/internal/handlers/handlers_exam.go b/server/internal/handlers/handlers_exam.go
index 8cbb97a..785e82b 100644
--- a/server/internal/handlers/handlers_exam.go
+++ b/server/internal/handlers/handlers_exam.go
@@ -1083,8 +1083,8 @@ func ZipFolder(srcDir, destZip string) error {
if err != nil {
return err
}
- defer rf.Close()
- _, err = io.Copy(writer, rf)
- return err
+ _, copyErr := io.Copy(writer, rf)
+ rf.Close()
+ return copyErr
})
}
diff --git a/server/internal/handlers/handlers_exam_submissions.go b/server/internal/handlers/handlers_exam_submissions.go
new file mode 100644
index 0000000..e9d79df
--- /dev/null
+++ b/server/internal/handlers/handlers_exam_submissions.go
@@ -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,
+ })
+ }
+}
diff --git a/server/internal/handlers/handlers_github_git.go b/server/internal/handlers/handlers_github_git.go
new file mode 100644
index 0000000..bfbe461
--- /dev/null
+++ b/server/internal/handlers/handlers_github_git.go
@@ -0,0 +1,260 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const githubBlobMaxBytes = 100 * 1024 * 1024
+
+func collectDirFiles(rootDir string) ([]gitPathFile, []string) {
+ cleanRoot := filepath.Clean(rootDir)
+ byPath := map[string]gitPathFile{}
+ var warnings []string
+
+ _ = filepath.Walk(cleanRoot, func(path string, info os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ warnings = append(warnings, path+": "+walkErr.Error())
+ return nil
+ }
+ if info.IsDir() {
+ if shouldSkipBundledPath(filepath.Base(path)) && path != cleanRoot {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ rel, err := filepath.Rel(cleanRoot, path)
+ if err != nil {
+ warnings = append(warnings, path+": "+err.Error())
+ return nil
+ }
+ rel = filepath.ToSlash(rel)
+ if rel == "" || strings.HasPrefix(rel, "..") || shouldSkipBundledPath(rel) {
+ return nil
+ }
+ if info.Size() > githubBlobMaxBytes {
+ warnings = append(warnings, rel+": file quá lớn (>100MB), bỏ qua")
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ warnings = append(warnings, rel+": "+err.Error())
+ return nil
+ }
+ byPath[rel] = gitPathFile{path: rel, content: data}
+ return nil
+ })
+
+ out := make([]gitPathFile, 0, len(byPath))
+ for _, f := range byPath {
+ out = append(out, f)
+ }
+ return out, warnings
+}
+
+type gitPathFile struct {
+ path string
+ content []byte
+}
+
+func githubCreateBlob(token, owner, repo string, content []byte) (string, error) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/blobs", owner, repo)
+ body := map[string]string{
+ "content": base64.StdEncoding.EncodeToString(content),
+ "encoding": "base64",
+ }
+ var resp struct {
+ SHA string `json:"sha"`
+ }
+ if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
+ return "", err
+ }
+ if resp.SHA == "" {
+ return "", errors.New("GitHub không trả blob sha")
+ }
+ return resp.SHA, nil
+}
+
+func githubCreateTree(token, owner, repo, baseTreeSHA string, entries []map[string]string) (string, error) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/trees", owner, repo)
+ body := map[string]any{"tree": entries}
+ if baseTreeSHA != "" {
+ body["base_tree"] = baseTreeSHA
+ }
+ var resp struct {
+ SHA string `json:"sha"`
+ }
+ if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
+ return "", err
+ }
+ if resp.SHA == "" {
+ return "", errors.New("GitHub không trả tree sha")
+ }
+ return resp.SHA, nil
+}
+
+func githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message string) (string, error) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits", owner, repo)
+ body := map[string]any{
+ "message": message,
+ "tree": treeSHA,
+ }
+ if parentSHA != "" {
+ body["parents"] = []string{parentSHA}
+ }
+ var resp struct {
+ SHA string `json:"sha"`
+ }
+ if err := githubJSON(token, http.MethodPost, apiURL, body, &resp); err != nil {
+ return "", err
+ }
+ if resp.SHA == "" {
+ return "", errors.New("GitHub không trả commit sha")
+ }
+ return resp.SHA, nil
+}
+
+func githubUpdateBranchRef(token, owner, repo, branch, commitSHA string) error {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs/heads/%s", owner, repo, branch)
+ body := map[string]any{"sha": commitSHA, "force": false}
+ err := githubJSON(token, http.MethodPatch, apiURL, body, nil)
+ if err == nil {
+ return nil
+ }
+ createURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/refs", owner, repo)
+ createBody := map[string]string{
+ "ref": "refs/heads/" + branch,
+ "sha": commitSHA,
+ }
+ return githubJSON(token, http.MethodPost, createURL, createBody, nil)
+}
+
+func githubBranchTip(token, owner, repo, branch string) (commitSHA, treeSHA string, err error) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/ref/heads/%s", owner, repo, branch)
+ var refResp struct {
+ Object struct {
+ SHA string `json:"sha"`
+ } `json:"object"`
+ }
+ if err := githubJSON(token, http.MethodGet, apiURL, nil, &refResp); err != nil {
+ return "", "", err
+ }
+ commitSHA = refResp.Object.SHA
+ if commitSHA == "" {
+ return "", "", errors.New("không lấy được commit hiện tại")
+ }
+ commitURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/commits/%s", owner, repo, commitSHA)
+ var commitResp struct {
+ Tree struct {
+ SHA string `json:"sha"`
+ } `json:"tree"`
+ }
+ if err := githubJSON(token, http.MethodGet, commitURL, nil, &commitResp); err != nil {
+ return "", "", err
+ }
+ return commitSHA, commitResp.Tree.SHA, nil
+}
+
+func githubJSON(token, method, apiURL string, body any, out any) error {
+ var reader io.Reader
+ if body != nil {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ reader = bytes.NewReader(payload)
+ }
+ req, err := http.NewRequest(method, apiURL, reader)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ var errBody struct {
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal(raw, &errBody)
+ if errBody.Message != "" {
+ return errors.New(errBody.Message)
+ }
+ return fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
+ }
+ if out != nil && len(raw) > 0 {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// githubPushFiles — đẩy danh sách file lên repo; file lỗi được bỏ qua.
+func githubPushFiles(token, owner, repo, branch string, files []gitPathFile, message string) (string, []string, error) {
+ warnings := []string{}
+ if len(files) == 0 {
+ return "", warnings, errors.New("không có file để đẩy")
+ }
+
+ parentSHA, baseTreeSHA, tipErr := githubBranchTip(token, owner, repo, branch)
+ if tipErr != nil {
+ parentSHA = ""
+ baseTreeSHA = ""
+ }
+
+ entries := make([]map[string]string, 0, len(files))
+ for _, f := range files {
+ blobSHA, err := githubCreateBlob(token, owner, repo, f.content)
+ if err != nil {
+ warnings = append(warnings, f.path+": "+err.Error()+" (bỏ qua)")
+ continue
+ }
+ entries = append(entries, map[string]string{
+ "path": f.path,
+ "mode": "100644",
+ "type": "blob",
+ "sha": blobSHA,
+ })
+ }
+
+ if len(entries) == 0 {
+ return "", warnings, errors.New("không đẩy được file nào lên GitHub")
+ }
+
+ treeSHA, err := githubCreateTree(token, owner, repo, baseTreeSHA, entries)
+ if err != nil {
+ return "", warnings, err
+ }
+ commitSHA, err := githubCreateCommit(token, owner, repo, treeSHA, parentSHA, message)
+ if err != nil {
+ return "", warnings, err
+ }
+ if err := githubUpdateBranchRef(token, owner, repo, branch, commitSHA); err != nil {
+ return "", warnings, err
+ }
+ return fmt.Sprintf("https://github.com/%s/%s/tree/%s", owner, repo, branch), warnings, nil
+}
+
+// githubPushDirectory — đẩy file lên repo; file lỗi được bỏ qua, không chặn cả lần push.
+func githubPushDirectory(token, owner, repo, branch, localRoot, message string) (string, []string, error) {
+ files, collectWarn := collectDirFiles(localRoot)
+ warnings := append([]string{}, collectWarn...)
+ url, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, message)
+ return url, append(warnings, pushWarn...), err
+}
diff --git a/server/internal/handlers/handlers_github_oauth.go b/server/internal/handlers/handlers_github_oauth.go
new file mode 100644
index 0000000..0624bc4
--- /dev/null
+++ b/server/internal/handlers/handlers_github_oauth.go
@@ -0,0 +1,401 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "server/internal/auth"
+ "server/internal/middleware"
+ "server/internal/models"
+
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+func githubOAuthConfig() (clientID, clientSecret, redirectURI string, err error) {
+ clientID = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_ID"))
+ clientSecret = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_CLIENT_SECRET"))
+ redirectURI = strings.TrimSpace(os.Getenv("GITHUB_OAUTH_REDIRECT_URI"))
+ if redirectURI == "" {
+ redirectURI = "http://127.0.0.1:8080/api/auth/github/callback"
+ }
+ if clientID == "" || clientSecret == "" {
+ return "", "", "", errors.New("Server chưa cấu hình GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET")
+ }
+ return clientID, clientSecret, redirectURI, nil
+}
+
+func loadStaffGitHubToken(db *gorm.DB, staffID uint) (token string, login string, err error) {
+ var row models.StaffGitHubAuth
+ if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil {
+ return "", "", err
+ }
+ token, err = auth.DecryptSecret(row.AccessTokenEnc)
+ if err != nil {
+ return "", "", err
+ }
+ return token, row.GitHubLogin, nil
+}
+
+// GET /api/auth/github/authorize — trả URL để mở popup OAuth (cần JWT staff)
+func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ if _, _, _, err := githubOAuthConfig(); err != nil {
+ return c.Status(503).JSON(fiber.Map{"error": err.Error()})
+ }
+ staffID := middleware.StaffIDFromCtx(c)
+ if staffID == 0 {
+ return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
+ }
+ state, err := auth.IssueGitHubOAuthState(staffID)
+ if err != nil {
+ return c.Status(500).JSON(fiber.Map{"error": "Không tạo được state OAuth"})
+ }
+ clientID, _, redirectURI, _ := githubOAuthConfig()
+ authorizeURL := fmt.Sprintf(
+ "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s",
+ url.QueryEscape(clientID),
+ url.QueryEscape(redirectURI),
+ url.QueryEscape("repo"),
+ url.QueryEscape(state),
+ )
+ return c.JSON(fiber.Map{"authorizeUrl": authorizeURL})
+ }
+}
+
+// GET /api/auth/github/callback — GitHub redirect (public)
+func GitHubOAuthCallbackHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ if errMsg := strings.TrimSpace(c.Query("error")); errMsg != "" {
+ return oauthCallbackHTML(c, false, "GitHub từ chối: "+errMsg)
+ }
+ code := strings.TrimSpace(c.Query("code"))
+ state := strings.TrimSpace(c.Query("state"))
+ if code == "" || state == "" {
+ return oauthCallbackHTML(c, false, "Thiếu mã xác thực từ GitHub")
+ }
+ staffID, err := auth.ParseGitHubOAuthState(state)
+ if err != nil {
+ return oauthCallbackHTML(c, false, "Phiên OAuth hết hạn — thử kết nối lại")
+ }
+ clientID, clientSecret, redirectURI, err := githubOAuthConfig()
+ if err != nil {
+ return oauthCallbackHTML(c, false, err.Error())
+ }
+
+ form := url.Values{}
+ form.Set("client_id", clientID)
+ form.Set("client_secret", clientSecret)
+ form.Set("code", code)
+ form.Set("redirect_uri", redirectURI)
+
+ req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode()))
+ if err != nil {
+ return oauthCallbackHTML(c, false, err.Error())
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return oauthCallbackHTML(c, false, "Không đổi được token GitHub")
+ }
+ defer resp.Body.Close()
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ Scope string `json:"scope"`
+ Error string `json:"error"`
+ Description string `json:"error_description"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&tokenResp)
+ if tokenResp.AccessToken == "" {
+ msg := tokenResp.Description
+ if msg == "" {
+ msg = tokenResp.Error
+ }
+ if msg == "" {
+ msg = "Không nhận được access token"
+ }
+ return oauthCallbackHTML(c, false, msg)
+ }
+
+ userReq, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
+ userReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
+ userReq.Header.Set("Accept", "application/vnd.github+json")
+ userResp, err := http.DefaultClient.Do(userReq)
+ if err != nil {
+ return oauthCallbackHTML(c, false, "Không lấy được thông tin GitHub")
+ }
+ defer userResp.Body.Close()
+ var ghUser struct {
+ Login string `json:"login"`
+ }
+ _ = json.NewDecoder(userResp.Body).Decode(&ghUser)
+ if ghUser.Login == "" {
+ return oauthCallbackHTML(c, false, "Tài khoản GitHub không hợp lệ")
+ }
+
+ enc, err := auth.EncryptSecret(tokenResp.AccessToken)
+ if err != nil {
+ return oauthCallbackHTML(c, false, "Không lưu được token")
+ }
+ row := models.StaffGitHubAuth{
+ StaffID: staffID,
+ GitHubLogin: ghUser.Login,
+ AccessTokenEnc: enc,
+ Scope: tokenResp.Scope,
+ ConnectedAt: time.Now(),
+ }
+ if err := db.Save(&row).Error; err != nil {
+ return oauthCallbackHTML(c, false, "Lưu DB thất bại")
+ }
+ return oauthCallbackHTML(c, true, ghUser.Login)
+ }
+}
+
+func oauthCallbackHTML(c *fiber.Ctx, ok bool, detail string) error {
+ c.Set("Content-Type", "text/html; charset=utf-8")
+ payload, _ := json.Marshal(map[string]any{"type": "simple-care-github-connected", "ok": ok, "detail": detail})
+ html := fmt.Sprintf(`
+
GitHub
+
+
%s
+
+`, detail, string(payload))
+ if !ok {
+ html = strings.Replace(html, detail, "Lỗi: "+detail, 1)
+ } else {
+ html = strings.Replace(html, detail, "Đã kết nối GitHub: @"+detail, 1)
+ }
+ return c.SendString(html)
+}
+
+// GET /api/auth/github/status
+func GitHubStatusHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID := middleware.StaffIDFromCtx(c)
+ var row models.StaffGitHubAuth
+ if err := db.First(&row, "staff_id = ?", staffID).Error; err != nil {
+ return c.JSON(fiber.Map{"connected": false})
+ }
+ return c.JSON(fiber.Map{
+ "connected": true,
+ "githubLogin": row.GitHubLogin,
+ "connectedAt": row.ConnectedAt,
+ "scope": row.Scope,
+ })
+ }
+}
+
+// DELETE /api/auth/github
+func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ staffID := middleware.StaffIDFromCtx(c)
+ _ = db.Delete(&models.StaffGitHubAuth{}, "staff_id = ?", staffID).Error
+ return c.JSON(fiber.Map{"ok": true})
+ }
+}
+
+func githubGetUser(token string) (login string, err error) {
+ req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ var u struct {
+ Login string `json:"login"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
+ return "", err
+ }
+ if u.Login == "" {
+ return "", errors.New("không lấy được tài khoản GitHub")
+ }
+ return u.Login, nil
+}
+
+func githubGetRepo(token, owner, repoName string) (htmlURL string, found bool) {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repoName)
+ req, _ := http.NewRequest(http.MethodGet, apiURL, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", false
+ }
+ var repo struct {
+ HTMLURL string `json:"html_url"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&repo)
+ if repo.HTMLURL == "" {
+ return "", false
+ }
+ return repo.HTMLURL, true
+}
+
+func githubCreateRepo(token, owner, repoName string, private bool) (htmlURL string, err error) {
+ body := map[string]any{
+ "name": repoName,
+ "private": private,
+ "auto_init": true,
+ "description": "Bài nộp phòng thi — Simple Care",
+ }
+ payload, _ := json.Marshal(body)
+ req, err := http.NewRequest(http.MethodPost, "https://api.github.com/user/repos", bytes.NewReader(payload))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("tạo repo thất bại: %s", strings.TrimSpace(string(raw)))
+ }
+ var created struct {
+ HTMLURL string `json:"html_url"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(raw, &created); err != nil {
+ return "", err
+ }
+ if created.HTMLURL == "" {
+ return "", errors.New("GitHub không trả URL repo")
+ }
+ return created.HTMLURL, nil
+}
+
+func isGitHubRepoNameTaken(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "name already exists") || strings.Contains(msg, `"code":"custom"`) && strings.Contains(msg, "name")
+}
+
+func trimGitHubRepoName(name string) string {
+ name = strings.ToLower(strings.TrimSpace(name))
+ name = strings.Trim(name, "-.")
+ if len(name) > 90 {
+ name = strings.Trim(name[:90], "-.")
+ }
+ if name == "" {
+ name = "exam-repo"
+ }
+ return name
+}
+
+func examGitRepoBaseName(room *models.ExamRoom) string {
+ base := strings.ToLower(sanitizePathPart(room.Name))
+ base = strings.NewReplacer(" ", "-", "_", "-").Replace(base)
+ base = invalidPathChars.ReplaceAllString(base, "-")
+ base = strings.Trim(base, "-.")
+ for strings.Contains(base, "--") {
+ base = strings.ReplaceAll(base, "--", "-")
+ }
+ if base == "" {
+ base = fmt.Sprintf("exam-%d", room.ID)
+ }
+ return trimGitHubRepoName(base)
+}
+
+func examGitRepoNameCandidates(room *models.ExamRoom) []string {
+ base := examGitRepoBaseName(room)
+ candidates := []string{
+ base,
+ fmt.Sprintf("%s-exam-%d", base, room.ID),
+ fmt.Sprintf("%s-%d", base, room.ID),
+ fmt.Sprintf("exam-%d", room.ID),
+ }
+ for i := 2; i <= 20; i++ {
+ candidates = append(candidates, fmt.Sprintf("%s-exam-%d-%d", base, room.ID, i))
+ }
+ seen := map[string]bool{}
+ out := make([]string, 0, len(candidates))
+ for _, c := range candidates {
+ c = trimGitHubRepoName(c)
+ if seen[c] {
+ continue
+ }
+ seen[c] = true
+ out = append(out, c)
+ }
+ return out
+}
+
+// githubEnsureRepoForRoom — dùng repo đã gắn phòng thi, hoặc tạo mới; trùng tên thì tự đổi tên.
+func githubEnsureRepoForRoom(token, owner string, room *models.ExamRoom) (repoName, htmlURL string, err error) {
+ if u := strings.TrimSpace(room.GitRepoURL); u != "" {
+ o, r, parseErr := parseGitHubRepo(u)
+ if parseErr == nil && strings.EqualFold(o, owner) {
+ if url, ok := githubGetRepo(token, owner, r); ok {
+ return r, url, nil
+ }
+ }
+ }
+
+ for _, candidate := range examGitRepoNameCandidates(room) {
+ if url, ok := githubGetRepo(token, owner, candidate); ok {
+ // Repo đã tồn tại — chỉ dùng lại nếu đúng phòng thi này đã lưu URL
+ if u := strings.TrimSpace(room.GitRepoURL); u != "" {
+ if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) {
+ return candidate, url, nil
+ }
+ }
+ continue
+ }
+ url, createErr := githubCreateRepo(token, owner, candidate, true)
+ if createErr == nil {
+ return candidate, url, nil
+ }
+ if isGitHubRepoNameTaken(createErr) {
+ continue
+ }
+ return "", "", createErr
+ }
+ return "", "", errors.New("không tạo được repo GitHub — đã thử nhiều tên khác nhau")
+}
+
+func examGitRepoName(room *models.ExamRoom) string {
+ if u := strings.TrimSpace(room.GitRepoURL); u != "" {
+ if _, name, err := parseGitHubRepo(u); err == nil && name != "" {
+ return name
+ }
+ }
+ return examGitRepoBaseName(room)
+}
+
+func resolvePublishRepo(token, ghLogin string, room *models.ExamRoom) (owner, repo, branch, repoHTML string, err error) {
+ branch = "main"
+ owner = ghLogin
+ repo, htmlURL, err := githubEnsureRepoForRoom(token, owner, room)
+ if err != nil {
+ return "", "", "", "", err
+ }
+ return owner, repo, branch, htmlURL, nil
+}
diff --git a/server/internal/models/exam.go b/server/internal/models/exam.go
index 713f091..92f682e 100644
--- a/server/internal/models/exam.go
+++ b/server/internal/models/exam.go
@@ -18,8 +18,11 @@ type ExamRoom struct {
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
- QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
- Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
+ QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
+ GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
+ GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
+ GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
+ Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
}
func (ExamRoom) TableName() string { return "exam_rooms" }
diff --git a/server/internal/models/staff_github.go b/server/internal/models/staff_github.go
new file mode 100644
index 0000000..b508d07
--- /dev/null
+++ b/server/internal/models/staff_github.go
@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+// StaffGitHubAuth — token OAuth GitHub của từng giáo viên
+type StaffGitHubAuth struct {
+ StaffID uint `gorm:"primaryKey;column:staff_id" json:"staffId"`
+ GitHubLogin string `gorm:"column:github_login;size:128;not null" json:"githubLogin"`
+ AccessTokenEnc string `gorm:"column:access_token_enc;type:text;not null" json:"-"`
+ Scope string `gorm:"column:scope;size:255" json:"scope"`
+ ConnectedAt time.Time `gorm:"column:connected_at;not null" json:"connectedAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+func (StaffGitHubAuth) TableName() string { return "staff_github_auth" }
diff --git a/server/main.go b/server/main.go
index b663fc8..597bb2f 100644
--- a/server/main.go
+++ b/server/main.go
@@ -15,6 +15,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
+ "github.com/gofiber/fiber/v2/middleware/recover"
gofiberWs "github.com/gofiber/websocket/v2"
"github.com/joho/godotenv"
)
@@ -57,10 +58,12 @@ func main() {
BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề
})
+ app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
+ ExposeHeaders: "Content-Disposition, Content-Length",
}))
// WebSocket Upgrade Check
@@ -82,6 +85,7 @@ func main() {
api.Post("/auth/login", handlers.LoginStaffHandler(gormDB))
api.Post("/auth/forgot-password", handlers.ForgotPasswordHandler(gormDB, mailer))
api.Post("/auth/reset-password", handlers.ResetPasswordHandler(gormDB))
+ api.Get("/auth/github/callback", handlers.GitHubOAuthCallbackHandler(gormDB))
// Student client (không cần JWT staff)
api.Post("/student/sync-log", handlers.SyncStudentSessionLogHandler(gormDB))
@@ -105,6 +109,9 @@ func main() {
staff := api.Group("", middleware.RequireStaff())
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB))
+ staff.Get("/auth/github/authorize", handlers.GitHubAuthorizeURLHandler(gormDB))
+ staff.Get("/auth/github/status", handlers.GitHubStatusHandler(gormDB))
+ staff.Delete("/auth/github", handlers.GitHubDisconnectHandler(gormDB))
staff.Get("/stats", handlers.GetStatsHandler(gormDB))
@@ -176,6 +183,9 @@ func main() {
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
+ staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
+ staff.Post("/exam-rooms/:id/submissions/publish-git", handlers.PublishExamSubmissionsGitHandler(gormDB))
+ staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
go func() {
diff --git a/server/test-dl.zip b/server/test-dl.zip
new file mode 100644
index 0000000..fd30fbc
--- /dev/null
+++ b/server/test-dl.zip
@@ -0,0 +1 @@
+{"error":"Unauthorized"}
\ No newline at end of file
diff --git a/server/uploads/exams/6/papers/6/main.pdf b/server/uploads/exams/6/papers/6/main.pdf
new file mode 100644
index 0000000..7a9dded
Binary files /dev/null and b/server/uploads/exams/6/papers/6/main.pdf differ
diff --git a/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_7259rr7259rr7259-removebg-preview.png b/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_7259rr7259rr7259-removebg-preview.png
new file mode 100644
index 0000000..86636e8
Binary files /dev/null and b/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_7259rr7259rr7259-removebg-preview.png differ
diff --git a/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_agi111agi111agi1 (1).png b/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_agi111agi111agi1 (1).png
new file mode 100644
index 0000000..139f88d
Binary files /dev/null and b/server/uploads/exams/6/papers/6/resources/Gemini_Generated_Image_agi111agi111agi1 (1).png differ
diff --git a/server/uploads/exams/6/papers/6/resources/gunny-admin-profiles-20260629-150338.json b/server/uploads/exams/6/papers/6/resources/gunny-admin-profiles-20260629-150338.json
new file mode 100644
index 0000000..2bfc0ce
--- /dev/null
+++ b/server/uploads/exams/6/papers/6/resources/gunny-admin-profiles-20260629-150338.json
@@ -0,0 +1,28 @@
+{
+ "version": 1,
+ "exportedAt": "2026-06-29T08:03:38.6066807Z",
+ "profiles": [
+ {
+ "id": "4e08fbd2-15ff-4f37-ba79-44d9f633a248",
+ "name": "Gà Chill",
+ "createdAt": "2026-06-27T03:50:45.9698419Z",
+ "updatedAt": "2026-06-29T01:46:39.7112252Z",
+ "sshHost": "103.245.236.191",
+ "sshPort": 22,
+ "sshUser": "Administrator",
+ "sshPassword": "pass@123123aA@",
+ "remoteDbHost": "127.0.0.1",
+ "remoteDbPort": 1433,
+ "dbUser": "sa",
+ "dbPassword": "123123aA@",
+ "dbTank41": "Db_Tank41",
+ "dbTank": "Db_Tank",
+ "dbMember": "Db_Member",
+ "remoteRequestHost": "127.0.0.1",
+ "remoteRequestPort": 81,
+ "resourceUrl": "https://resource.gunnychill.net",
+ "deployRoot": "C:\\Gunny",
+ "devRootXml": "E:\\gunny3"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/server/uploads/exams/6/submissions/596/20260630_141621_596.zip b/server/uploads/exams/6/submissions/596/20260630_141621_596.zip
new file mode 100644
index 0000000..1648244
Binary files /dev/null and b/server/uploads/exams/6/submissions/596/20260630_141621_596.zip differ