feat(public-tokens): одноразовые ссылки для кандидатов
PublicTokenRepository:
- Create — генерирует 256-битный URL-safe токен через crypto/rand;
intended_email нормализуется в lower-case; max_attempts<=0 → 1;
- GetByToken — поиск по URL-токену для public-endpoint'ов;
- ListByResource — все токены для теста/курса (HR-UI);
- Revoke — soft-cancel (revoked_at = NOW());
- CheckUsable — валидирует токен: revoked/expired/exhausted →
типизированные ошибки (ErrTokenInvalid/Expired/Exhausted/Email);
- MatchEmail — case-insensitive сравнение;
- MarkOpened / IncrementUsed — для аудита и счётчика попыток.
PublicTokenHandler — два слоя:
HR (/api, под service.learning.access + owner-проверка):
- POST /public-tokens — Create;
- GET /public-tokens?resource_type=...&resource_id=... — ListByResource;
- DELETE /public-tokens/{id} — Revoke.
Public (/public, без auth):
- GET /public/learning/tokens/{token}/info — title + status
({valid|revoked|expired|exhausted}). IntendedEmail НЕ возвращаем,
чтобы любой со ссылкой не узнал чей это email.
- POST /public/learning/tokens/{token}/resolve {email} — сверяет
email с intended (case-insensitive), создаёт attempt со
public_token_id, помечает opened_at. IncrementUsed на submit'е
(а не resolve'е), чтобы кандидат не сжёг попытку случайным
открытием.
- GET /public/learning/attempts/{id}?token=… — текущий attempt +
questions (is_correct/explanation скрыты).
- POST /public/learning/attempts/{id}/submit?token=… — сабмит +
автогрейд + IncrementUsed.
MVP поддерживает только resource_type='test'. Courses через public-
ссылку — следующая итерация (нужен view-mode без логина).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -70,12 +70,14 @@ func main() {
|
|||||||
attemptRepo := repository.NewAttemptRepository(pool)
|
attemptRepo := repository.NewAttemptRepository(pool)
|
||||||
courseRepo := repository.NewCourseRepository(pool)
|
courseRepo := repository.NewCourseRepository(pool)
|
||||||
lessonRepo := repository.NewLessonRepository(pool)
|
lessonRepo := repository.NewLessonRepository(pool)
|
||||||
|
publicTokenRepo := repository.NewPublicTokenRepository(pool)
|
||||||
|
|
||||||
healthH := handler.NewHealthHandler(pool)
|
healthH := handler.NewHealthHandler(pool)
|
||||||
testH := handler.NewTestHandler(testRepo)
|
testH := handler.NewTestHandler(testRepo)
|
||||||
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
|
||||||
courseH := handler.NewCourseHandler(courseRepo)
|
courseH := handler.NewCourseHandler(courseRepo)
|
||||||
lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store)
|
lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store)
|
||||||
|
publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
@@ -136,17 +138,25 @@ func main() {
|
|||||||
r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo)
|
r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo)
|
||||||
r.Delete("/lessons/{id}/video", lessonH.DeleteVideo)
|
r.Delete("/lessons/{id}/video", lessonH.DeleteVideo)
|
||||||
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
|
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
|
||||||
r.HandleFunc("/public-tokens", notImplemented)
|
|
||||||
r.HandleFunc("/public-tokens/{id}", notImplemented)
|
// Public tokens — HR-side: создать ссылку для кандидата, посмотреть
|
||||||
|
// список, отозвать. Сам прохождение тестa по токену — в /public ниже.
|
||||||
|
r.Post("/public-tokens", publicTokenH.Create)
|
||||||
|
r.Get("/public-tokens", publicTokenH.ListByResource)
|
||||||
|
r.Delete("/public-tokens/{id}", publicTokenH.Revoke)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Public endpoints — без InternalAuth (кандидаты ходят анонимно
|
// Public endpoints — без InternalAuth (кандидаты ходят анонимно
|
||||||
// по token'у). Открываем минимум: проверка токена + старт attempt +
|
// по token'у). Гейтят через сам token внутри handler'ов.
|
||||||
// сабмит. Содержательно гейтим через token-валидацию в самом handler'е.
|
|
||||||
r.Route("/public", func(r chi.Router) {
|
r.Route("/public", func(r chi.Router) {
|
||||||
r.HandleFunc("/learning/resolve/{token}", notImplemented)
|
// Info — лёгкий read для лэндинга (проверка валидности + title).
|
||||||
r.HandleFunc("/learning/attempts/{id}", notImplemented)
|
r.Get("/learning/tokens/{token}/info", publicTokenH.PublicInfo)
|
||||||
r.HandleFunc("/learning/attempts/{id}/submit", notImplemented)
|
// Resolve — кандидат вводит email, бэк сверяет с intended_email
|
||||||
|
// и создаёт attempt. Возвращает attempt_id + первое чтение теста.
|
||||||
|
r.Post("/learning/tokens/{token}/resolve", publicTokenH.PublicResolve)
|
||||||
|
// Attempts — read/submit с обязательным ?token=… в query.
|
||||||
|
r.Get("/learning/attempts/{id}", publicTokenH.PublicGetAttempt)
|
||||||
|
r.Post("/learning/attempts/{id}/submit", publicTokenH.PublicSubmit)
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
362
internal/handler/public_token.go
Normal file
362
internal/handler/public_token.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"learning-service/internal/model"
|
||||||
|
"learning-service/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicTokenHandler — два слоя:
|
||||||
|
//
|
||||||
|
// 1. HR-методы под /api (защищены X-User-Id через portal-gateway):
|
||||||
|
// Create / List / Revoke. Только владелец ресурса может создавать
|
||||||
|
// ссылки на свой тест/курс.
|
||||||
|
//
|
||||||
|
// 2. Public-методы под /public (без auth, гейтят через сам token):
|
||||||
|
// Resolve (email-check + create attempt), GetAttempt, SubmitAttempt.
|
||||||
|
// Используются на лэндинге кандидата.
|
||||||
|
type PublicTokenHandler struct {
|
||||||
|
repo *repository.PublicTokenRepository
|
||||||
|
testRepo *repository.TestRepository
|
||||||
|
courseRepo *repository.CourseRepository
|
||||||
|
attemptRepo *repository.AttemptRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicTokenHandler(
|
||||||
|
repo *repository.PublicTokenRepository,
|
||||||
|
testRepo *repository.TestRepository,
|
||||||
|
courseRepo *repository.CourseRepository,
|
||||||
|
attemptRepo *repository.AttemptRepository,
|
||||||
|
) *PublicTokenHandler {
|
||||||
|
return &PublicTokenHandler{
|
||||||
|
repo: repo, testRepo: testRepo, courseRepo: courseRepo, attemptRepo: attemptRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HR-side (/api)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// authorizeResourceOwner — проверяет что X-User-Id владеет ресурсом
|
||||||
|
// (тест или курс). Если нет — 403, если ресурс не найден — 404.
|
||||||
|
func (h *PublicTokenHandler) authorizeResourceOwner(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uuid.UUID) (uuid.UUID, bool) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
var owner uuid.UUID
|
||||||
|
switch resourceType {
|
||||||
|
case "test":
|
||||||
|
t, err := h.testRepo.Get(r.Context(), resourceID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
owner = t.OwnerUserID
|
||||||
|
case "course":
|
||||||
|
c, err := h.courseRepo.Get(r.Context(), resourceID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get course")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
owner = c.OwnerUserID
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "resource_type must be test|course")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
if owner != uid {
|
||||||
|
writeError(w, http.StatusForbidden, "only owner can issue tokens")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return uid, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create — POST /public-tokens. Body: CreatePublicTokenRequest.
|
||||||
|
func (h *PublicTokenHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.CreatePublicTokenRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := h.authorizeResourceOwner(w, r, req.ResourceType, req.ResourceID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Create(r.Context(), uid, req)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "create public token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByResource — GET /public-tokens?resource_type=test&resource_id=uuid.
|
||||||
|
// Только владелец ресурса.
|
||||||
|
func (h *PublicTokenHandler) ListByResource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resourceType := r.URL.Query().Get("resource_type")
|
||||||
|
rawID := r.URL.Query().Get("resource_id")
|
||||||
|
if resourceType == "" || rawID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "resource_type and resource_id are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceID, err := parseUUID(rawID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid resource_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.repo.ListByResource(r.Context(), resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "list public tokens")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke — DELETE /public-tokens/{id}. Только владелец ресурса.
|
||||||
|
func (h *PublicTokenHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.authorizeResourceOwner(w, r, t.ResourceType, t.ResourceID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.Revoke(r.Context(), id); err != nil {
|
||||||
|
writeRepoError(w, r, err, "revoke token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public-side (/public — без auth)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// publicTokenStatus — что отдаём кандидату при resolve'е/info-запросе.
|
||||||
|
// Безопасные поля: тип ресурса, заголовок (для preview), shows valid'ность.
|
||||||
|
// IntendedEmail не возвращаем — иначе любой со ссылкой узнает на чей email
|
||||||
|
// она выпущена.
|
||||||
|
type publicTokenStatus struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
ResourceTitle string `json:"resource_title"`
|
||||||
|
Status string `json:"status"` // valid | expired | revoked | exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info — GET /public/learning/tokens/{token}/info.
|
||||||
|
// Возвращает «безопасные» данные для лэндинга (без intended_email) и
|
||||||
|
// статус (для отображения «ссылка отозвана» / «истекла» / etc.).
|
||||||
|
func (h *PublicTokenHandler) PublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
t, err := h.repo.GetByToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := publicTokenStatus{
|
||||||
|
Token: t.Token,
|
||||||
|
ResourceType: t.ResourceType,
|
||||||
|
ResourceID: t.ResourceID.String(),
|
||||||
|
Status: "valid",
|
||||||
|
}
|
||||||
|
if err := h.repo.CheckUsable(t); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrTokenInvalid):
|
||||||
|
out.Status = "revoked"
|
||||||
|
case errors.Is(err, repository.ErrTokenExpired):
|
||||||
|
out.Status = "expired"
|
||||||
|
case errors.Is(err, repository.ErrTokenExhausted):
|
||||||
|
out.Status = "exhausted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Резолвим title ресурса (тест — title теста; курс — title курса)
|
||||||
|
// чтобы кандидат на лэндинге увидел «Вы открываете тест: Базовая…»
|
||||||
|
// и понял что собирается проходить.
|
||||||
|
switch t.ResourceType {
|
||||||
|
case "test":
|
||||||
|
test, err := h.testRepo.Get(r.Context(), t.ResourceID)
|
||||||
|
if err == nil {
|
||||||
|
out.ResourceTitle = test.Title
|
||||||
|
}
|
||||||
|
case "course":
|
||||||
|
c, err := h.courseRepo.Get(r.Context(), t.ResourceID)
|
||||||
|
if err == nil {
|
||||||
|
out.ResourceTitle = c.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve — POST /public/learning/tokens/{token}/resolve {email}.
|
||||||
|
// Полный flow «кандидат жмёт ссылку из письма»:
|
||||||
|
//
|
||||||
|
// 1. Найти токен; CheckUsable (не revoked/expired/exhausted).
|
||||||
|
// 2. Сравнить email с intended_email (case-insensitive).
|
||||||
|
// 3. Создать attempt в БД, привязать к public_token_id.
|
||||||
|
// 4. Пометить opened_at; вернуть attempt_id + начальные данные.
|
||||||
|
//
|
||||||
|
// IncrementUsed НЕ дёргаем здесь — это происходит на submit'е, чтобы
|
||||||
|
// не «съесть» попытку если кандидат открыл и закрыл вкладку.
|
||||||
|
func (h *PublicTokenHandler) PublicResolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := chi.URLParam(r, "token")
|
||||||
|
t, err := h.repo.GetByToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.CheckUsable(t); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrTokenInvalid):
|
||||||
|
writeError(w, http.StatusForbidden, "link is revoked")
|
||||||
|
case errors.Is(err, repository.ErrTokenExpired):
|
||||||
|
writeError(w, http.StatusGone, "link expired")
|
||||||
|
case errors.Is(err, repository.ErrTokenExhausted):
|
||||||
|
writeError(w, http.StatusTooManyRequests, "link already used")
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusForbidden, "link unavailable")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.PublicTokenResolveRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.MatchEmail(t, req.Email); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "email does not match the recipient")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// На этой итерации поддерживаем только resource_type='test'. Курсы
|
||||||
|
// через public-ссылку — следующая итерация (нужен view-mode для
|
||||||
|
// urls'а без логина).
|
||||||
|
if t.ResourceType != "test" {
|
||||||
|
writeError(w, http.StatusNotImplemented, "only tests are supported via public links in this version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.attemptRepo.Start(r.Context(), repository.StartParams{
|
||||||
|
TestID: t.ResourceID,
|
||||||
|
PublicTokenID: &t.ID,
|
||||||
|
CandidateID: t.CandidateID,
|
||||||
|
RespondentEmail: req.Email,
|
||||||
|
IP: r.RemoteAddr,
|
||||||
|
UserAgent: r.UserAgent(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "start attempt from public token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.MarkOpened(r.Context(), t.ID); err != nil {
|
||||||
|
// Не критично — не блокируем кандидата.
|
||||||
|
// (логирование на стороне slog'а repo'а не делаем — это hot-path).
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"attempt": att,
|
||||||
|
"token": token,
|
||||||
|
"test_id": t.ResourceID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicGetAttempt — GET /public/learning/attempts/{id}?token=…
|
||||||
|
// Возвращает текущий attempt + вопросы (без is_correct/explanation).
|
||||||
|
// Проверка: attempt.public_token_id == token и токен ещё валиден.
|
||||||
|
func (h *PublicTokenHandler) PublicGetAttempt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "token query param is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.attemptRepo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.GetByToken(r.Context(), token)
|
||||||
|
if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID {
|
||||||
|
writeError(w, http.StatusForbidden, "token does not match attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Сами вопросы — через TestRepository, скрываем is_correct/explanation.
|
||||||
|
qs, err := h.testRepo.ListQuestions(r.Context(), att.TestID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "list questions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range qs {
|
||||||
|
for j := range qs[i].Answers {
|
||||||
|
qs[i].Answers[j].IsCorrect = false
|
||||||
|
}
|
||||||
|
qs[i].Explanation = ""
|
||||||
|
}
|
||||||
|
test, _ := h.testRepo.Get(r.Context(), att.TestID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"attempt": att,
|
||||||
|
"test": test,
|
||||||
|
"questions": qs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicSubmit — POST /public/learning/attempts/{id}/submit?token=…
|
||||||
|
// Сабмит ответов + автогрейд. После успешного submit'а
|
||||||
|
// IncrementUsed увеличивает used_attempts токена.
|
||||||
|
func (h *PublicTokenHandler) PublicSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "token query param is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
att, err := h.attemptRepo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.GetByToken(r.Context(), token)
|
||||||
|
if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID {
|
||||||
|
writeError(w, http.StatusForbidden, "token does not match attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if att.UserID != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "attempt is not from public token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.SubmitAttemptRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := h.attemptRepo.SubmitAndGrade(r.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "submit attempt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// IncrementUsed best-effort. Если упадёт — попытка уже засчитана,
|
||||||
|
// токен не отметится как «использован», ребро-кейс редкий.
|
||||||
|
_ = h.repo.IncrementUsed(r.Context(), t.ID)
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
209
internal/repository/public_token.go
Normal file
209
internal/repository/public_token.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"learning-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PublicTokenRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicTokenRepository(pool *pgxpool.Pool) *PublicTokenRepository {
|
||||||
|
return &PublicTokenRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenCols = `
|
||||||
|
id, token, resource_type, resource_id, intended_email, candidate_id,
|
||||||
|
max_attempts, used_attempts, expires_at, opened_at, used_at, revoked_at,
|
||||||
|
created_by, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func scanToken(scan func(...any) error) (*model.PublicToken, error) {
|
||||||
|
var t model.PublicToken
|
||||||
|
if err := scan(
|
||||||
|
&t.ID, &t.Token, &t.ResourceType, &t.ResourceID, &t.IntendedEmail, &t.CandidateID,
|
||||||
|
&t.MaxAttempts, &t.UsedAttempts, &t.ExpiresAt, &t.OpenedAt, &t.UsedAt, &t.RevokedAt,
|
||||||
|
&t.CreatedBy, &t.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateToken — длинный URL-safe random для токена в URL'е. Используем
|
||||||
|
// crypto/rand с 32 байтами → 43-символьная base64-строка, ~256 бит
|
||||||
|
// энтропии. Brute-force по такому пространству неосуществим.
|
||||||
|
func generateToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("rand: %w", err)
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create — создаёт токен. Если max_attempts <= 0, считаем 1 (одноразовая
|
||||||
|
// ссылка). intended_email нормализуется в lower-case (сравнение тоже
|
||||||
|
// case-insensitive — см. ResolveByEmail).
|
||||||
|
func (r *PublicTokenRepository) Create(ctx context.Context, createdBy uuid.UUID, req model.CreatePublicTokenRequest) (*model.PublicToken, error) {
|
||||||
|
if strings.TrimSpace(req.IntendedEmail) == "" {
|
||||||
|
return nil, fmt.Errorf("intended_email is required")
|
||||||
|
}
|
||||||
|
if req.ResourceType != "test" && req.ResourceType != "course" {
|
||||||
|
return nil, fmt.Errorf("resource_type must be test|course")
|
||||||
|
}
|
||||||
|
maxAttempts := req.MaxAttempts
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
maxAttempts = 1
|
||||||
|
}
|
||||||
|
tok, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO public_tokens (
|
||||||
|
token, resource_type, resource_id, intended_email, candidate_id,
|
||||||
|
max_attempts, expires_at, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
tok, req.ResourceType, req.ResourceID,
|
||||||
|
strings.ToLower(strings.TrimSpace(req.IntendedEmail)),
|
||||||
|
req.CandidateID, maxAttempts, req.ExpiresAt, createdBy,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicTokenRepository) Get(ctx context.Context, id uuid.UUID) (*model.PublicToken, error) {
|
||||||
|
t, err := scanToken(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+tokenCols+` FROM public_tokens WHERE id = $1`, id).Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByToken — поиск по URL-токену. Используется на public-endpoint'ах
|
||||||
|
// для resolve'а кандидата.
|
||||||
|
func (r *PublicTokenRepository) GetByToken(ctx context.Context, token string) (*model.PublicToken, error) {
|
||||||
|
t, err := scanToken(r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+tokenCols+` FROM public_tokens WHERE token = $1`, token).Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByResource — все токены для конкретного теста/курса (для HR-UI).
|
||||||
|
// Сортировка — свежие сверху.
|
||||||
|
func (r *PublicTokenRepository) ListByResource(ctx context.Context, resourceType string, resourceID uuid.UUID) ([]model.PublicToken, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT `+tokenCols+` FROM public_tokens
|
||||||
|
WHERE resource_type = $1 AND resource_id = $2
|
||||||
|
ORDER BY created_at DESC LIMIT 200`,
|
||||||
|
resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.PublicToken{}
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanToken(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke — soft-cancel токена. Открыть нельзя, но история сохраняется.
|
||||||
|
func (r *PublicTokenRepository) Revoke(ctx context.Context, id uuid.UUID) error {
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE public_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckUsable — валидирует токен на момент resolve'а:
|
||||||
|
// - revoked_at IS NULL
|
||||||
|
// - expires_at либо NULL либо > NOW()
|
||||||
|
// - used_attempts < max_attempts
|
||||||
|
//
|
||||||
|
// Возвращает ErrTokenInvalid / ErrTokenExpired / ErrTokenExhausted —
|
||||||
|
// handler маппит на специфические HTTP-коды (403/410/429).
|
||||||
|
var (
|
||||||
|
ErrTokenInvalid = errors.New("token invalid or revoked")
|
||||||
|
ErrTokenExpired = errors.New("token expired")
|
||||||
|
ErrTokenExhausted = errors.New("token max attempts reached")
|
||||||
|
ErrTokenEmail = errors.New("email does not match")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *PublicTokenRepository) CheckUsable(t *model.PublicToken) error {
|
||||||
|
if t.RevokedAt != nil {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
|
||||||
|
return ErrTokenExpired
|
||||||
|
}
|
||||||
|
if t.UsedAttempts >= t.MaxAttempts {
|
||||||
|
return ErrTokenExhausted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchEmail — case-insensitive сравнение. Возвращает ErrTokenEmail
|
||||||
|
// при несовпадении (handler → 403, чтобы кандидат понял что ссылка
|
||||||
|
// для другого получателя).
|
||||||
|
func (r *PublicTokenRepository) MatchEmail(t *model.PublicToken, candidateEmail string) error {
|
||||||
|
a := strings.ToLower(strings.TrimSpace(t.IntendedEmail))
|
||||||
|
b := strings.ToLower(strings.TrimSpace(candidateEmail))
|
||||||
|
if a != b {
|
||||||
|
return ErrTokenEmail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkOpened — первое открытие; если уже было, no-op.
|
||||||
|
func (r *PublicTokenRepository) MarkOpened(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE public_tokens SET opened_at = COALESCE(opened_at, NOW()) WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsed — после успешного submit'а attempt'а. Атомарно растит
|
||||||
|
// used_attempts и ставит used_at (если это первое использование).
|
||||||
|
// max_attempts ENFORCE'ится отдельно через CheckUsable перед resolve'ом.
|
||||||
|
func (r *PublicTokenRepository) IncrementUsed(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE public_tokens
|
||||||
|
SET used_attempts = used_attempts + 1,
|
||||||
|
used_at = COALESCE(used_at, NOW())
|
||||||
|
WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user