diff --git a/client/app.go b/client/app.go index eb0443d..761331a 100644 --- a/client/app.go +++ b/client/app.go @@ -1707,8 +1707,8 @@ func zipFolder(srcDir, destZip string) error { if err != nil { return err } - defer rf.Close() - _, err = io.Copy(w, rf) - return err + _, copyErr := io.Copy(w, rf) + rf.Close() + return copyErr }) } diff --git a/management/src/App.tsx b/management/src/App.tsx index 40b72d4..e12d934 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -152,15 +152,6 @@ function App() { Phòng thi -
  • - -
  • Hệ thống
  • @@ -172,6 +163,15 @@ function App() { Đuôi email
  • +
  • + +
  • diff --git a/management/src/api.ts b/management/src/api.ts index 3b26666..bbf98fb 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -81,6 +81,31 @@ export const apiAuth = { }, }; +export interface GitHubStatus { + connected: boolean; + githubLogin?: string; + connectedAt?: string; + scope?: string; +} + +export const apiGitHub = { + status: async (): Promise => { + const res = await staffFetch('/auth/github/status'); + if (!res.ok) await parseError(res, 'Không tải trạng thái GitHub'); + return res.json(); + }, + authorizeUrl: async (): Promise<{ authorizeUrl: string }> => { + const res = await staffFetch('/auth/github/authorize'); + if (!res.ok) await parseError(res, 'Không bắt đầu OAuth GitHub'); + return res.json(); + }, + disconnect: async () => { + const res = await staffFetch('/auth/github', { method: 'DELETE' }); + if (!res.ok) await parseError(res, 'Ngắt kết nối GitHub thất bại'); + return res.json(); + }, +}; + export interface EmailDomainItem { id: number; domain: string; @@ -586,6 +611,9 @@ export interface ExamRoomItem { endTime: string; allowedApps: string; quizUrl: string; + gitRepoUrl?: string; + gitBranch?: string; + gitPublishUrl?: string; status: 'draft' | 'ready' | 'ended' | 'cancelled'; studentCount: number; paperCount: number; @@ -752,4 +780,31 @@ export const apiExam = { return res.json() as Promise<{ data: ExamSubmission[] }>; }, downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`, + downloadAllSubmissions: async (id: number, fallbackName: string) => { + let res: Response; + try { + res = await staffFetch(`/exam-rooms/${id}/submissions/download-all`); + } catch { + throw new Error('Không kết nối được server (Failed to fetch). Kiểm tra server đang chạy và thử lại.'); + } + if (!res.ok) await parseError(res, 'Tải bài nộp gộp thất bại'); + const blob = await res.blob(); + if (!blob.size) throw new Error('File ZIP trống — có thể bài nộp trên server bị thiếu file'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fallbackName.replace(/[<>:"/\\|?*]+/g, '_')}_bai_nop.zip`; + a.click(); + URL.revokeObjectURL(url); + }, + saveGitSettings: async (id: number, payload: { gitRepoUrl?: string; gitBranch?: string }) => { + const res = await staffFetch(`/exam-rooms/${id}/git-settings`, { method: 'PATCH', body: JSON.stringify(payload) }); + if (!res.ok) await parseError(res, 'Lưu cấu hình Git thất bại'); + return res.json() as Promise<{ gitRepoUrl: string; gitBranch: string; gitPublishUrl?: string }>; + }, + publishSubmissionsGit: async (id: number) => { + const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' }); + if (!res.ok) await parseError(res, 'Đẩy lên Git thất bại'); + return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>; + }, }; diff --git a/management/src/components/AccountsTab.tsx b/management/src/components/AccountsTab.tsx index 120bddd..dde8fc0 100644 --- a/management/src/components/AccountsTab.tsx +++ b/management/src/components/AccountsTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '../auth/AuthContext'; import { apiAdmin, type EmailDomainItem } from '../api'; +import { useGitHubConnection } from '../hooks/useGitHubConnection'; export function ChangePasswordPage({ forced }: { forced?: boolean }) { const { changePassword, logout } = useAuth(); @@ -127,22 +128,23 @@ export function EmailDomainsTab() { export function MyAccountTab() { const { staff, changePassword } = useAuth(); + const gh = useGitHubConnection(); const [oldPw, setOldPw] = useState(''); const [newPw, setNewPw] = useState(''); - const [msg, setMsg] = useState(''); - const [err, setErr] = useState(''); + const [pwMsg, setPwMsg] = useState(''); + const [pwErr, setPwErr] = useState(''); const submitPw = async (e: React.FormEvent) => { e.preventDefault(); - setErr(''); - setMsg(''); + setPwErr(''); + setPwMsg(''); try { await changePassword(oldPw, newPw); - setMsg('Đã đổi mật khẩu'); + setPwMsg('Đã đổi mật khẩu'); setOldPw(''); setNewPw(''); } catch (e: any) { - setErr(e?.message || 'Lỗi'); + setPwErr(e?.message || 'Lỗi'); } }; @@ -152,7 +154,7 @@ export function MyAccountTab() {

    Tài khoản của tôi

    -

    Thông tin đăng nhập và bảo mật cá nhân.

    +

    Thông tin đăng nhập, GitHub và bảo mật cá nhân.

    @@ -161,6 +163,41 @@ export function MyAccountTab() {

    {staff?.email}

    +
    +

    GitHub

    +

    + Kết nối tài khoản GitHub của bạn để đẩy bài nộp phòng thi lên repo riêng. Mỗi giáo viên dùng GitHub của mình — không dùng chung token server. +

    +
    + {gh.connected ? ( + <> + + Đã kết nối: @{gh.githubLogin} + + + + ) : ( + + )} +
    + {gh.error &&
    {gh.error}
    } + {gh.message &&
    {gh.message}
    } + {!gh.connected && ( +

    + Sau khi kết nối, vào Phòng thi → chọn phòng → tab Bài nộp → bấm Đẩy lên Git. +

    + )} +
    +

    Đổi mật khẩu

    @@ -172,8 +209,8 @@ export function MyAccountTab() { Mật khẩu mới setNewPw(e.target.value)} required minLength={8} /> - {err &&
    {err}
    } - {msg &&
    {msg}
    } + {pwErr &&
    {pwErr}
    } + {pwMsg &&
    {pwMsg}
    }
    diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index 2aafa6a..45a7d48 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -7,8 +7,9 @@ import { type ExamSubmission, type StudentItem, } from '../api'; +import { useGitHubConnection } from '../hooks/useGitHubConnection'; +import { navigate, pushNav } from '../navigation'; import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants'; -import { pushNav } from '../navigation'; import { openStaffChat } from '../chatEvents'; import { AppPoolModal } from './AppPoolModal'; import { NavHistoryBar } from './NavHistoryBar'; @@ -49,6 +50,10 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const [end, setEnd] = useState(''); const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS); const [quizUrl, setQuizUrl] = useState(''); + const [gitPublishUrl, setGitPublishUrl] = useState(''); + const [bundlingSubs, setBundlingSubs] = useState(false); + const [publishingGit, setPublishingGit] = useState(false); + const gh = useGitHubConnection(); const [editable, setEditable] = useState(false); const [prepEditable, setPrepEditable] = useState(false); const [displayStatus, setDisplayStatus] = useState('draft'); @@ -87,6 +92,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { setEnd(toLocalInput(data.room.endTime)); setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS); setQuizUrl(data.room.quizUrl || ''); + setGitPublishUrl(data.room.gitPublishUrl || ''); setEditable(data.editable); setPrepEditable(data.prepEditable ?? data.editable); setDisplayStatus(data.displayStatus); @@ -230,6 +236,39 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { URL.revokeObjectURL(url); }; + const downloadAllSubs = async () => { + setErr(''); setMsg(''); + setBundlingSubs(true); + try { + await apiExam.downloadAllSubmissions(examId, roomName || `phong_thi_${examId}`); + setMsg('Đã tải ZIP gộp — giải nén sẽ thấy từng folder bài làm theo sinh viên.'); + } catch (e: any) { + setErr(e?.message || 'Tải thất bại'); + } finally { + setBundlingSubs(false); + } + }; + + const publishToGit = async () => { + if (!gh.connected) { + setErr('Kết nối GitHub trong Tài khoản của tôi (sidebar dưới cùng) trước khi đẩy.'); + return; + } + setErr(''); setMsg(''); + setPublishingGit(true); + try { + const res = await apiExam.publishSubmissionsGit(examId); + const openUrl = res.openUrl || res.gitPublishUrl || res.url; + setGitPublishUrl(openUrl); + setMsg(res.message || 'Đã đẩy bài nộp lên GitHub'); + if (openUrl) window.open(openUrl, '_blank', 'noopener,noreferrer'); + } catch (e: any) { + setErr(e?.message || 'Đẩy Git thất bại'); + } finally { + setPublishingGit(false); + } + }; + const handlePublish = async () => { setErr(''); setMsg(''); try { @@ -408,6 +447,31 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { Link trắc nghiệm setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} /> +
    +

    + GitHub:{' '} + {gh.connected ? ( + @{gh.githubLogin} + ) : ( + <> + chưa kết nối + {' — '} + + + )} +

    +

    + Bấm Đẩy lên Git sẽ tự tạo repo theo tên phòng thi — mỗi sinh viên một folder, giống ZIP tải về. +

    + {gitPublishUrl && ( +

    + Repo gần nhất:{' '} + {gitPublishUrl} +

    + )} +
    ) : (
    + {submissions.length > 0 && ( +
    + + + {!gh.connected && ( + + + {' '}trong Tài khoản của tôi trước khi đẩy. + + )} + + ZIP gộp: mỗi sinh viên một folder — giải nén là chấm ngay. + +
    + )}
    {submissions.length === 0 ? (
    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