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

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