Changes
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Api.Models.Settings;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Endpoints that expose captcha configuration and verification.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class CaptchaController : ControllerBase
|
||||
{
|
||||
private readonly CaptchaSettings _captchaSettings;
|
||||
private readonly ICaptchaVerifier _captcha;
|
||||
private readonly ILogger<CaptchaController> _log;
|
||||
|
||||
public CaptchaController(IOptions<CaptchaSettings> options, ICaptchaVerifier captcha, ILogger<CaptchaController> log)
|
||||
{
|
||||
_captchaSettings = options.Value;
|
||||
_captcha = captcha;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the public reCAPTCHA site key used by the client to render the widget.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[SwaggerOperation(Summary = "Get reCAPTCHA site key")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult GetSiteKey()
|
||||
{
|
||||
return Ok(_captchaSettings.PublicKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify a captcha token and return the verification verdict.
|
||||
/// </summary>
|
||||
[HttpPost("verify")]
|
||||
[SwaggerOperation(Summary = "Verify captcha token")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Verify([FromBody] VerifyRequest req, CancellationToken ct)
|
||||
{
|
||||
if (req is null || string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Missing token" });
|
||||
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var verdict = await _captcha.VerifyAsync(req.Token, userIp, ct);
|
||||
if (!verdict.Success)
|
||||
{
|
||||
_log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}", userIp, verdict.Score, verdict.Error);
|
||||
return BadRequest(new { error = "Captcha verification failed.", score = verdict.Score });
|
||||
}
|
||||
|
||||
return Ok(verdict);
|
||||
}
|
||||
|
||||
public sealed class VerifyRequest
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,7 @@ namespace Api.Controllers
|
||||
/// the reCAPTCHA widget and obtain client-side tokens.
|
||||
/// </summary>
|
||||
/// <returns>200 OK with the public site key as a string.</returns>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetReCaptchaSiteKey(CancellationToken ct)
|
||||
{
|
||||
return Ok(_captchaSettings.PublicKey);
|
||||
}
|
||||
// ReCaptcha endpoints have been extracted to CaptchaController
|
||||
|
||||
/// <summary>
|
||||
/// Validates the provided reCAPTCHA token and sends a contact message
|
||||
@@ -62,9 +58,8 @@ namespace Api.Controllers
|
||||
return ValidationProblem(ModelState);
|
||||
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var res = await ValidateCaptcha(req.CaptchaToken, ct);
|
||||
if (!res.Verdict.Success) return BadRequest("Captcha verification failed.");
|
||||
var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, ct);
|
||||
if (!verdict.Success) return BadRequest("Captcha verification failed.");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -73,7 +68,7 @@ namespace Api.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Contact send failed. ip={Ip} from={From}", res.UserIp, req.Email);
|
||||
_log.LogError(ex, "Contact send failed. ip={Ip} from={From}", userIp, req.Email);
|
||||
return StatusCode(500, "Could not send message.");
|
||||
}
|
||||
}
|
||||
@@ -96,8 +91,9 @@ namespace Api.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return ValidationProblem(ModelState);
|
||||
|
||||
var res = await ValidateCaptcha(req.CaptchaToken, ct);
|
||||
if (!res.Verdict.Success) return BadRequest("Captcha verification failed.");
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, ct);
|
||||
if (!verdict.Success) return BadRequest("Captcha verification failed.");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -106,29 +102,12 @@ namespace Api.Controllers
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", res.UserIp, req.Email);
|
||||
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email);
|
||||
return StatusCode(500, "Failed.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper that runs reCAPTCHA verification for the supplied token and
|
||||
/// returns the verdict along with the resolved user IP address.
|
||||
/// </summary>
|
||||
/// <param name="token">Client-provided reCAPTCHA token.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Tuple containing the verification verdict and user IP.</returns>
|
||||
private async Task<(CaptchaVerdictModel Verdict, string? UserIp)> ValidateCaptcha(string token, CancellationToken ct)
|
||||
{
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var verdict = await _captcha.VerifyAsync(token, userIp, ct);
|
||||
if (!verdict.Success)
|
||||
{
|
||||
_log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}",
|
||||
userIp, verdict.Score, verdict.Error);
|
||||
}
|
||||
return (verdict, userIp);
|
||||
}
|
||||
// Captcha verification helper was moved to CaptchaController; ContactController calls _captcha.VerifyAsync directly.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Models.Requests;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -13,12 +14,12 @@ namespace Api.Controllers;
|
||||
[EnableRateLimiting("cv-matcher")]
|
||||
public sealed class CvMatcherController : ControllerBase
|
||||
{
|
||||
private readonly Api.Clients.Api.Contracts.ICvMatcherApi _cvApi;
|
||||
private readonly ICvMatcherApi _cvApi;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<CvMatcherController> _logger;
|
||||
|
||||
public CvMatcherController(
|
||||
Api.Clients.Api.Contracts.ICvMatcherApi cvApi,
|
||||
ICvMatcherApi cvApi,
|
||||
IConfiguration configuration,
|
||||
ILogger<CvMatcherController> logger)
|
||||
{
|
||||
@@ -34,6 +35,7 @@ public sealed class CvMatcherController : ControllerBase
|
||||
/// <param name="gdprConsent">Whether the user consented to GDPR processing.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
[HttpPost("upload")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -43,21 +45,16 @@ public sealed class CvMatcherController : ControllerBase
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing or invalid input")]
|
||||
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
|
||||
public async Task<IActionResult> UploadCv(
|
||||
[FromForm(Name = "cv")] IFormFile? cv,
|
||||
[FromForm] bool gdprConsent,
|
||||
[FromForm] UploadCvRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (cv is null)
|
||||
if (request.Cv is null)
|
||||
{
|
||||
return BadRequest(new { error = "Missing CV PDF." });
|
||||
}
|
||||
|
||||
var baseUrl = GetCvMatcherBaseUrl();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy CV upload requests.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." });
|
||||
}
|
||||
var cv = request.Cv;
|
||||
var gdprConsent = request.GdprConsent;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -96,13 +93,6 @@ public sealed class CvMatcherController : ControllerBase
|
||||
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
|
||||
public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = GetCvMatcherBaseUrl();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy job matching requests.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
||||
@@ -124,9 +114,6 @@ public sealed class CvMatcherController : ControllerBase
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." });
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
|
||||
// Refit client is configured in Program.cs; this helper only reads config for diagnostics
|
||||
|
||||
private static async Task<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Api.Models.Requests
|
||||
{
|
||||
public sealed class UploadCvRequest
|
||||
{
|
||||
[Required]
|
||||
public IFormFile Cv { get; set; } = default!;
|
||||
|
||||
public bool GdprConsent { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user