Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/cmd/labkit-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
adminsvc "labkit.local/apps/api/internal/service/admin"
authsvc "labkit.local/apps/api/internal/service/auth"
authproviders "labkit.local/apps/api/internal/service/auth/providers"
gradesvc "labkit.local/apps/api/internal/service/grade"
labsvc "labkit.local/apps/api/internal/service/labs"
boardsvc "labkit.local/apps/api/internal/service/leaderboard"
personalsvc "labkit.local/apps/api/internal/service/personal"
Expand Down Expand Up @@ -82,6 +83,7 @@ func main() {
httpapi.WithAuthService(authsvc.NewServiceWithProvider(authsvc.NewRepository(pool), authProvider, oauthConfig)),
httpapi.WithLabsService(labsvc.NewService(db.New(pool))),
httpapi.WithAdminService(adminsvc.NewService(adminsvc.NewRepository(pool))),
httpapi.WithGradeService(gradesvc.NewService(gradesvc.NewRepository(pool))),
httpapi.WithLeaderboardService(leaderboardService),
httpapi.WithPersonalService(personalService),
httpapi.WithWebSessionService(websession.NewPersistentService(websession.NewRepository(pool))),
Expand Down
13 changes: 13 additions & 0 deletions apps/api/internal/http/auth_router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,19 @@ func (r *routerAuthRepo) GetUserByID(_ context.Context, id int64) (sqlc.Users, e
return user, nil
}

func (r *routerAuthRepo) UpsertUser(_ context.Context, studentID string) (sqlc.UpsertUserRow, error) {
for _, user := range r.usersByID {
if user.StudentID == studentID {
return sqlc.UpsertUserRow{ID: user.ID, StudentID: user.StudentID}, nil
}
}
id := r.nextID
r.nextID++
user := sqlc.Users{ID: id, StudentID: studentID}
r.usersByID[id] = user
return sqlc.UpsertUserRow{ID: id, StudentID: studentID}, nil
}

type noopOAuthClient struct{}

func (noopOAuthClient) ExchangeCode(context.Context, string) (string, error) {
Expand Down
18 changes: 18 additions & 0 deletions apps/api/internal/http/browser_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import (
const (
browserSessionCookieName = "labkit_browser_session"
browserSessionTTL = 8 * time.Hour

// browserSessionSource* records how a browser session was minted, for
// auditing and for any future "web sessions are read-only" enforcement.
browserSessionSourceDevice = "device"
browserSessionSourceWeb = "web"
)

var browserSessions sync.Map
Expand All @@ -19,10 +24,22 @@ type browserSession struct {
UserID int64
KeyID int64
StudentID string
Source string
ExpiresAt time.Time
}

// issueBrowserSession mints a session for the CLI device flow (key-bound).
func issueBrowserSession(userID, keyID int64, studentID string) (string, error) {
return issueBrowserSessionWithSource(userID, keyID, studentID, browserSessionSourceDevice)
}

// issueWebBrowserSession mints a session for the 微人大 web login. It is not
// bound to a device key (KeyID == 0); read handlers rely on UserID/StudentID.
func issueWebBrowserSession(userID int64, studentID string) (string, error) {
return issueBrowserSessionWithSource(userID, 0, studentID, browserSessionSourceWeb)
}

func issueBrowserSessionWithSource(userID, keyID int64, studentID, source string) (string, error) {
token, err := randomBrowserSessionToken()
if err != nil {
return "", err
Expand All @@ -31,6 +48,7 @@ func issueBrowserSession(userID, keyID int64, studentID string) (string, error)
UserID: userID,
KeyID: keyID,
StudentID: studentID,
Source: source,
ExpiresAt: time.Now().UTC().Add(browserSessionTTL),
})
return token, nil
Expand Down
13 changes: 12 additions & 1 deletion apps/api/internal/http/device_verify_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
type DeviceVerifyHandler struct {
Service *authsvc.Service
BrowserSessionSecure bool
// WebLogin handles the browser-only login path that shares this callback.
WebLogin *WebLoginHandler
}

func (h *DeviceVerifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -20,7 +22,16 @@ func (h *DeviceVerifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}

query := r.URL.Query()
if strings.TrimSpace(query.Get("code")) != "" || strings.TrimSpace(query.Get("state")) != "" {
state := strings.TrimSpace(query.Get("state"))
code := strings.TrimSpace(query.Get("code"))
if code != "" || state != "" {
// Web login and device flow share this redirect_uri. A matching
// labkit_oauth_state cookie means the browser started a web login;
// otherwise fall through to the device flow (unchanged).
if h.WebLogin != nil && hasMatchingOAuthStateCookie(r, state) {
h.WebLogin.handleCallback(w, r, state, code)
return
}
callback := &OAuthCallbackHandler{Service: h.Service, BrowserSessionSecure: h.BrowserSessionSecure}
callback.ServeHTTP(w, r)
return
Expand Down
190 changes: 190 additions & 0 deletions apps/api/internal/http/grade_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package httpapi

import (
"context"
"errors"
"io"
"net/http"
"strings"

"labkit.local/apps/api/internal/http/middleware"
gradesvc "labkit.local/apps/api/internal/service/grade"
)

// GradeService is the grade surface used by the HTTP layer (student read +
// admin import/publish).
type GradeService interface {
GetGrade(context.Context, string, string) (gradesvc.Grade, error)
ImportGrades(context.Context, string, io.Reader) (gradesvc.ImportGradesResult, error)
PublishGrades(context.Context, string) (gradesvc.PublishGradesResult, error)
DeleteGrades(context.Context, string) (gradesvc.DeleteGradesResult, error)
}

// GradeHandler serves the student-facing grade view and the admin grade import.
type GradeHandler struct {
Service GradeService
Personal PersonalService
}

// maxGradeCSVBytes caps an uploaded grades CSV (defensive; class-sized files
// are tiny).
const maxGradeCSVBytes = 8 << 20 // 8 MiB

// GetMyGrade handles GET /api/labs/{labID}/grade. Browser session is preferred
// (it already carries the student id); CLI signature auth is also accepted.
func (h *GradeHandler) GetMyGrade(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Service == nil {
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
studentID, ok := h.resolveStudentID(w, r)
if !ok {
return
}
grade, err := h.Service.GetGrade(r.Context(), r.PathValue("labID"), studentID)
if err != nil {
switch {
case errors.Is(err, gradesvc.ErrGradeNotFound):
middleware.WriteError(w, r, http.StatusNotFound, "grade_not_found", "成绩尚未发布")
default:
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
}
return
}
writeJSON(w, http.StatusOK, grade)
}

// ImportGrades handles POST /api/admin/labs/{labID}/grades/import. The CSV may
// arrive as a multipart "file" field or as a raw text/csv body.
func (h *GradeHandler) ImportGrades(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Service == nil {
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
reader, cleanup, err := gradeCSVReader(r)
if err != nil {
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", err.Error())
return
}
defer cleanup()

result, err := h.Service.ImportGrades(r.Context(), r.PathValue("labID"), reader)
if err != nil {
h.writeImportError(w, r, err)
return
}
writeJSON(w, http.StatusOK, result)
}

// PublishGrades handles POST /api/admin/labs/{labID}/grades/publish.
func (h *GradeHandler) PublishGrades(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Service == nil {
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
result, err := h.Service.PublishGrades(r.Context(), r.PathValue("labID"))
if err != nil {
if errors.Is(err, gradesvc.ErrInvalidLab) {
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required")
return
}
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
writeJSON(w, http.StatusOK, result)
}

// DeleteGrades handles DELETE /api/admin/labs/{labID}/grades, clearing all
// grade rows for a lab so a corrected CSV can be re-imported cleanly.
func (h *GradeHandler) DeleteGrades(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Service == nil {
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
result, err := h.Service.DeleteGrades(r.Context(), r.PathValue("labID"))
if err != nil {
if errors.Is(err, gradesvc.ErrInvalidLab) {
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required")
return
}
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return
}
writeJSON(w, http.StatusOK, result)
}

func (h *GradeHandler) writeImportError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, gradesvc.ErrInvalidCSV):
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_csv", err.Error())
case errors.Is(err, gradesvc.ErrInvalidLab):
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "lab id is required")
default:
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
}
}

// resolveStudentID returns the requesting student's id from a browser session
// (preferred) or from CLI signature auth (reverse-lookup via profile).
func (h *GradeHandler) resolveStudentID(w http.ResponseWriter, r *http.Request) (string, bool) {
if session, ok := browserSessionFromRequest(r); ok {
if sid := strings.TrimSpace(session.StudentID); sid != "" {
return sid, true
}
if h.Personal != nil {
if profile, err := h.Personal.GetProfile(r.Context(), session.UserID); err == nil {
if sid := strings.TrimSpace(profile.StudentID); sid != "" {
return sid, true
}
}
}
middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized))
return "", false
}

if h.Personal == nil {
middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized))
return "", false
}
body, err := readRequestBody(r)
if err != nil {
middleware.WriteError(w, r, http.StatusBadRequest, "invalid_request", "invalid request body")
return "", false
}
user, ok := authenticatePersonalRequest(w, r, h.Personal, body)
if !ok {
return "", false
}
profile, err := h.Personal.GetProfile(r.Context(), user.UserID)
if err != nil {
middleware.WriteError(w, r, http.StatusInternalServerError, "internal_server_error", http.StatusText(http.StatusInternalServerError))
return "", false
}
sid := strings.TrimSpace(profile.StudentID)
if sid == "" {
middleware.WriteError(w, r, http.StatusUnauthorized, "unauthorized", http.StatusText(http.StatusUnauthorized))
return "", false
}
return sid, true
}

// gradeCSVReader extracts the CSV body from either a multipart upload or a raw
// request body, returning a reader and a cleanup func.
func gradeCSVReader(r *http.Request) (io.Reader, func(), error) {
noop := func() {}
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "multipart/form-data") {
if err := r.ParseMultipartForm(maxGradeCSVBytes); err != nil {
return nil, noop, errors.New("failed to parse multipart upload")
}
file, _, err := r.FormFile("file")
if err != nil {
return nil, noop, errors.New("missing CSV file field \"file\"")
}
return io.LimitReader(file, maxGradeCSVBytes), func() { _ = file.Close() }, nil
}
if r.Body == nil {
return nil, noop, errors.New("empty request body")
}
return io.LimitReader(r.Body, maxGradeCSVBytes), noop, nil
}
Loading
Loading