Files
learning/internal/handler/public_token.go
Ilya d773999296
All checks were successful
CI / test (push) Successful in 19s
Build and Deploy / build-and-deploy (push) Successful in 28s
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>
2026-05-26 00:45:49 +03:00

363 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}