Files
learning/internal/repository/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

210 lines
6.8 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 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
}