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 }