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; }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,11 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
container_name: myai-mssql
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD:-Your_strong_password123}
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- myai-mssql-data:/var/opt/mssql
|
||||
networks:
|
||||
- myai-network
|
||||
restart: unless-stopped
|
||||
|
||||
rag-api:
|
||||
build:
|
||||
context: ../rag-api
|
||||
dockerfile: Dockerfile
|
||||
container_name: myai-rag-api
|
||||
depends_on:
|
||||
- mssql
|
||||
ports:
|
||||
- "8081:8080"
|
||||
env_file:
|
||||
@@ -30,7 +13,7 @@ services:
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
- ConnectionStrings__RagDb=Server=mssql,1433;Database=MyAiRag;User Id=sa;Password=${MSSQL_SA_PASSWORD:-Your_strong_password123};TrustServerCertificate=True
|
||||
- ConnectionStrings__RagDb=Server=mssql,1433;Database=MyAi;User Id=sa;Password=bpdTUyb3;TrustServerCertificate=True
|
||||
- InternalApi__RequireApiKey=true
|
||||
- InternalApi__ApiKey=${INTERNAL_API_KEY:-change-this-internal-key}
|
||||
- Ai__Provider=${AI_PROVIDER:-OpenAI}
|
||||
|
||||
+13
-13
@@ -267,19 +267,19 @@
|
||||
console.error('API health check failed:', textStatus, errorThrown);
|
||||
});
|
||||
}
|
||||
function getRecaptchaWebKey() {
|
||||
return $.get('/api/contact').done(function (res) {
|
||||
reCaptchaSiteKey = res;
|
||||
if (reCaptchaSiteKey && !window.__recaptcha_loaded) {
|
||||
window.__recaptcha_loaded = true;
|
||||
var script = document.createElement('script');
|
||||
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}).fail(function () {
|
||||
console.warn('Could not load reCaptcha site key from /api/contact');
|
||||
});
|
||||
}
|
||||
function getRecaptchaWebKey() {
|
||||
return $.get('/api/captcha').done(function (res) {
|
||||
reCaptchaSiteKey = res;
|
||||
if (reCaptchaSiteKey && !window.__recaptcha_loaded) {
|
||||
window.__recaptcha_loaded = true;
|
||||
var script = document.createElement('script');
|
||||
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?render=' + reCaptchaSiteKey);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}).fail(function () {
|
||||
console.warn('Could not load reCaptcha site key from /api/captcha');
|
||||
});
|
||||
}
|
||||
|
||||
function getGoogleTagManagerId() {
|
||||
return $.get('/api/google/tagmanager').done(function (res) {
|
||||
|
||||
Reference in New Issue
Block a user