git feature
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user