git feature
This commit is contained in:
@@ -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
|
||||
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