git feature

This commit is contained in:
2026-06-30 14:46:19 +07:00
parent 251c1b7573
commit 11e5a76977
23 changed files with 1680 additions and 28 deletions

View File

@@ -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
})
}

View File

@@ -152,15 +152,6 @@ function App() {
Phòng thi
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('network')}
>
<span className="nav-icon"><IconNetwork /></span>
Quản mạng
</button>
</li>
<div className="nav-header">Hệ thống</div>
<li className="nav-item">
@@ -172,6 +163,15 @@ function App() {
Đuôi email
</button>
</li>
<li className="nav-item">
<button
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
onClick={() => navigate('network')}
>
<span className="nav-icon"><IconNetwork /></span>
Quản mạng
</button>
</li>
</ul>
</nav>

View File

@@ -81,6 +81,31 @@ export const apiAuth = {
},
};
export interface GitHubStatus {
connected: boolean;
githubLogin?: string;
connectedAt?: string;
scope?: string;
}
export const apiGitHub = {
status: async (): Promise<GitHubStatus> => {
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 }>;
},
};

View File

@@ -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() {
<div className="page-stack">
<header className="page-header">
<h1 className="page-title">Tài khoản của tôi</h1>
<p className="page-desc">Thông tin đăng nhập bảo mật nhân.</p>
<p className="page-desc">Thông tin đăng nhập, GitHub bảo mật nhân.</p>
</header>
<div className="card" style={{ padding: '1.25rem' }}>
@@ -161,6 +163,41 @@ export function MyAccountTab() {
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
</div>
<div className="card account-github-card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">GitHub</h2>
<p className="page-desc" style={{ marginTop: 0 }}>
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.
</p>
<div className="exam-git-oauth-row">
{gh.connected ? (
<>
<span className="exam-git-connected">
Đã kết nối: <strong>@{gh.githubLogin}</strong>
</span>
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
Ngắt kết nối
</button>
</>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={gh.connecting}
onClick={gh.connect}
>
{gh.connecting ? 'Đang mở GitHub...' : 'Kết nối GitHub'}
</button>
)}
</div>
{gh.error && <div className="login-error">{gh.error}</div>}
{gh.message && <div className="login-success">{gh.message}</div>}
{!gh.connected && (
<p className="exam-git-hint">
Sau khi kết nối, vào <strong>Phòng thi</strong> chọn phòng tab <strong>Bài nộp</strong> bấm <strong>Đy lên Git</strong>.
</p>
)}
</div>
<div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Đi mật khẩu</h2>
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
@@ -172,8 +209,8 @@ export function MyAccountTab() {
<span>Mật khẩu mới</span>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
</label>
{err && <div className="login-error">{err}</div>}
{msg && <div className="login-success">{msg}</div>}
{pwErr && <div className="login-error">{pwErr}</div>}
{pwMsg && <div className="login-success">{pwMsg}</div>}
<button type="submit" className="btn btn-primary">Lưu</button>
</form>
</div>

View File

@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ examId, onBack }) => {
<span>Link trắc nghiệm</span>
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
</label>
<div className="exam-git-settings">
<p className="exam-git-status-line">
GitHub:{' '}
{gh.connected ? (
<strong>@{gh.githubLogin}</strong>
) : (
<>
<span className="exam-git-not-connected">chưa kết nối</span>
{' — '}
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối trong Tài khoản
</button>
</>
)}
</p>
<p className="exam-git-hint">
Bấm <strong>Đy lên Git</strong> 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ề.
</p>
{gitPublishUrl && (
<p className="exam-git-last">
Repo gần nhất:{' '}
<a href={gitPublishUrl} target="_blank" rel="noreferrer">{gitPublishUrl}</a>
</p>
)}
</div>
<label className="login-field">
<span>Bắt đu</span>
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
@@ -766,6 +830,38 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
) : (
<div className="session-logs-panel">
{submissions.length > 0 && (
<div className="exam-submissions-toolbar">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={bundlingSubs}
onClick={downloadAllSubs}
>
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={publishingGit || !gh.connected}
onClick={publishToGit}
title={gh.connected ? 'Tạo repo + đẩy từng folder sinh viên lên GitHub' : 'Kết nối GitHub trong Tài khoản trước'}
>
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
</button>
{!gh.connected && (
<span className="exam-submissions-toolbar-hint">
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
Kết nối GitHub
</button>
{' '}trong Tài khoản của tôi trước khi đy.
</span>
)}
<span className="exam-submissions-toolbar-hint">
ZIP gộp: mỗi sinh viên một folder giải nén chấm ngay.
</span>
</div>
)}
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
{submissions.length === 0 ? (
<div className="empty-state" style={{ minHeight: '300px' }}>

View File

@@ -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,
};
}

View File

@@ -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);
}

View File

@@ -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"
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

View File

@@ -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
}

View File

@@ -62,6 +62,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.ExamPaperResource{},
&models.ExamRoomStudent{},
&models.ExamSubmission{},
&models.StaffGitHubAuth{},
); err != nil {
return err
}

View File

@@ -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
})
}

View File

@@ -0,0 +1,473 @@
package handlers
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
var invalidPathChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]+`)
type submissionRow struct {
models.ExamSubmission
FullName string
StudentCode string
}
func sanitizePathPart(s string) string {
s = strings.TrimSpace(s)
s = invalidPathChars.ReplaceAllString(s, "_")
s = strings.Trim(s, " .")
if s == "" {
return "unknown"
}
return s
}
func studentSubmissionFolder(code, fullName string) string {
code = sanitizePathPart(code)
name := sanitizePathPart(fullName)
if code != "unknown" && name != "unknown" {
return code + "_" + name
}
if code != "unknown" {
return code
}
if name != "unknown" {
return name
}
return "sinh_vien"
}
func shouldSkipBundledPath(rel string) bool {
rel = filepath.ToSlash(strings.TrimSpace(rel))
if rel == "" {
return true
}
for _, part := range strings.Split(rel, "/") {
switch part {
case ".git", "__MACOSX", ".svn", ".hg":
return true
}
}
base := filepath.Base(rel)
switch base {
case ".DS_Store", "Thumbs.db", "desktop.ini":
return true
}
return false
}
func unzipToDir(zipPath, destDir string) (skipped []string, err error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
cleanDest := filepath.Clean(destDir)
for _, f := range r.File {
name := filepath.FromSlash(f.Name)
if shouldSkipBundledPath(name) {
continue
}
target := filepath.Join(destDir, name)
cleanTarget := filepath.Clean(target)
if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(cleanTarget, 0755); err != nil {
skipped = append(skipped, name+": "+err.Error())
}
continue
}
if err := os.MkdirAll(filepath.Dir(cleanTarget), 0755); err != nil {
skipped = append(skipped, name+": "+err.Error())
continue
}
rc, err := f.Open()
if err != nil {
skipped = append(skipped, name+": "+err.Error())
continue
}
out, err := os.Create(cleanTarget)
if err != nil {
rc.Close()
skipped = append(skipped, name+": "+err.Error())
continue
}
_, copyErr := io.Copy(out, rc)
out.Close()
rc.Close()
if copyErr != nil {
skipped = append(skipped, name+": "+copyErr.Error())
}
}
return skipped, nil
}
func loadExamSubmissionRows(db *gorm.DB, roomID uint) ([]submissionRow, error) {
var subs []models.ExamSubmission
if err := db.Where("exam_room_id = ?", roomID).Order("created_at asc").Find(&subs).Error; err != nil {
return nil, err
}
rows := make([]submissionRow, 0, len(subs))
for _, s := range subs {
row := submissionRow{ExamSubmission: s}
var st models.Student
if err := db.Where("rk_id = ?", s.StudentRkID).First(&st).Error; err == nil {
row.FullName = st.FullName
row.StudentCode = st.StudentCode
}
rows = append(rows, row)
}
return rows, nil
}
func copySubmissionZipEntries(srcZipPath, prefix string, addFile func(destPath string, open func() (io.ReadCloser, error), size int64) error) (skipped []string, err error) {
r, err := zip.OpenReader(srcZipPath)
if err != nil {
return nil, err
}
defer r.Close()
prefix = strings.Trim(prefix, "/\\")
for _, f := range r.File {
name := filepath.ToSlash(filepath.FromSlash(f.Name))
if shouldSkipBundledPath(name) {
continue
}
if f.FileInfo().IsDir() {
continue
}
dest := name
if prefix != "" {
dest = prefix + "/" + name
}
dest = strings.TrimPrefix(dest, "/")
entry := f
addErr := addFile(dest, entry.Open, int64(entry.UncompressedSize64))
if addErr != nil {
skipped = append(skipped, dest+": "+addErr.Error())
}
}
return skipped, nil
}
func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings []string, fileCount int, err error) {
f, err := os.Create(destZipPath)
if err != nil {
return nil, 0, err
}
zw := zip.NewWriter(f)
usedFolders := map[string]int{}
for _, row := range rows {
if _, statErr := os.Stat(row.FilePath); statErr != nil {
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
continue
}
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
skipped, copyErr := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
hdr := &zip.FileHeader{
Name: destPath,
Method: zip.Deflate,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
rc, err := open()
if err != nil {
return err
}
_, copyErr := io.Copy(w, rc)
rc.Close()
if copyErr == nil {
fileCount++
}
return copyErr
})
if copyErr != nil {
warnings = append(warnings, folder+": "+copyErr.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, folder+"/"+s)
}
}
if closeErr := zw.Close(); closeErr != nil {
f.Close()
_ = os.Remove(destZipPath)
return warnings, fileCount, closeErr
}
if closeErr := f.Close(); closeErr != nil {
_ = os.Remove(destZipPath)
return warnings, fileCount, closeErr
}
if fileCount == 0 {
_ = os.Remove(destZipPath)
return warnings, 0, errors.New("không có file bài nộp hợp lệ để gộp")
}
return warnings, fileCount, nil
}
func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) {
byPath := map[string]gitPathFile{}
usedFolders := map[string]int{}
for _, row := range rows {
if _, statErr := os.Stat(row.FilePath); statErr != nil {
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
continue
}
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
skipped, err := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
if size > githubBlobMaxBytes {
warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua")
return nil
}
rc, err := open()
if err != nil {
return err
}
data, readErr := io.ReadAll(rc)
rc.Close()
if readErr != nil {
return readErr
}
byPath[destPath] = gitPathFile{path: destPath, content: data}
return nil
})
if err != nil {
warnings = append(warnings, folder+": "+err.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, folder+"/"+s)
}
}
files = make([]gitPathFile, 0, len(byPath))
for _, f := range byPath {
files = append(files, f)
}
return files, warnings
}
func buildExamSubmissionsBundle(room models.ExamRoom, rows []submissionRow) (zipPath string, cleanup func(), err error) {
tmpBase, err := os.MkdirTemp("", "exam-bundle-*")
if err != nil {
return "", nil, err
}
cleanup = func() { _ = os.RemoveAll(tmpBase) }
rootName := sanitizePathPart(room.Name) + "_bai_nop"
outZip := filepath.Join(tmpBase, rootName+".zip")
_, _, err = bundleSubmissionZips(rows, outZip)
if err != nil {
cleanup()
return "", nil, err
}
return outZip, cleanup, nil
}
func prepareExamSubmissionsForGit(rows []submissionRow) (files []gitPathFile, warnings []string, err error) {
files, warnings = collectSubmissionZipFiles(rows)
if len(files) == 0 {
return nil, warnings, errors.New("không có file bài nộp hợp lệ để đẩy")
}
return files, warnings, nil
}
// GET /api/exam-rooms/:id/submissions/download-all
func DownloadAllExamSubmissionsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
zipPath, cleanup, err := buildExamSubmissionsBundle(room, rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
zipBytes, readErr := os.ReadFile(zipPath)
cleanup()
if readErr != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được file zip gộp"})
}
downloadName := sanitizePathPart(room.Name) + "_bai_nop.zip"
c.Set("Content-Type", "application/zip")
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, downloadName))
return c.Send(zipBytes)
}
}
// PATCH /api/exam-rooms/:id/git-settings
func UpdateExamRoomGitSettingsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
var req struct {
GitRepoURL *string `json:"gitRepoUrl"`
GitBranch *string `json:"gitBranch"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
if req.GitRepoURL != nil {
room.GitRepoURL = strings.TrimSpace(*req.GitRepoURL)
}
if req.GitBranch != nil {
b := strings.TrimSpace(*req.GitBranch)
if b == "" {
b = "main"
}
room.GitBranch = b
}
if room.GitBranch == "" {
room.GitBranch = "main"
}
if err := db.Save(&room).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"gitRepoUrl": room.GitRepoURL,
"gitBranch": room.GitBranch,
"gitPublishUrl": room.GitPublishURL,
})
}
}
func parseGitHubRepo(repoURL string) (owner, repo string, err error) {
u := strings.TrimSpace(repoURL)
u = strings.TrimSuffix(u, ".git")
u = strings.TrimPrefix(u, "https://github.com/")
u = strings.TrimPrefix(u, "http://github.com/")
u = strings.TrimPrefix(u, "github.com/")
parts := strings.Split(strings.Trim(u, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", errors.New("URL GitHub không hợp lệ (vd: https://github.com/org/repo)")
}
return parts[0], parts[1], nil
}
// POST /api/exam-rooms/:id/submissions/publish-git
func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
if staffID == 0 {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(401).JSON(fiber.Map{
"error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.",
"needsGitHubOAuth": true,
})
}
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"})
}
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
files, unpackWarn, err := prepareExamSubmissionsForGit(rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error(), "warnings": unpackWarn})
}
owner, repo, branch, repoHTML, err := resolvePublishRepo(token, ghLogin, &room)
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": err.Error()})
}
if repoHTML != "" {
room.GitRepoURL = repoHTML
room.GitBranch = branch
}
commitMsg := fmt.Sprintf("Simple Care: bài nộp phòng thi %s (#%d)", room.Name, roomID)
viewURL, pushWarn, err := githubPushFiles(token, owner, repo, branch, files, commitMsg)
allWarn := append(unpackWarn, pushWarn...)
if err != nil {
return c.Status(502).JSON(fiber.Map{
"error": "Đẩy Git thất bại: " + err.Error(),
"warnings": allWarn,
})
}
room.GitPublishURL = viewURL
_ = db.Save(&room).Error
msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows))
if len(allWarn) > 0 {
msg += fmt.Sprintf(" (%d file bỏ qua do lỗi)", len(allWarn))
}
return c.JSON(fiber.Map{
"ok": true,
"url": viewURL,
"gitPublishUrl": viewURL,
"gitRepoUrl": room.GitRepoURL,
"githubLogin": ghLogin,
"openUrl": viewURL,
"warnings": allWarn,
"message": msg,
})
}
}

View File

@@ -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
}

View File

@@ -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(`<!DOCTYPE html>
<html lang="vi"><head><meta charset="utf-8"><title>GitHub</title></head>
<body style="font-family:Segoe UI,sans-serif;padding:2rem;text-align:center">
<p>%s</p>
<script>
try {
if (window.opener) window.opener.postMessage(%s, "*");
} catch (e) {}
setTimeout(function(){ window.close(); }, 800);
</script>
</body></html>`, 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
}

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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() {

1
server/test-dl.zip Normal file
View File

@@ -0,0 +1 @@
{"error":"Unauthorized"}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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"
}
]
}