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