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