using System.Net; using System.Net.Mail; using System.Net.Mime; using System.Security.Cryptography; using System.Text; using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; // Stateless contact relay (TECH_SPEC §7): validate -> honeypot -> rate-limit -> // SMTP send -> forget. No persistence, no message bodies logged. var builder = WebApplication.CreateBuilder(args); var cfg = builder.Configuration; var allowedOrigin = cfg["Relay:AllowedOrigin"] ?? "https://cesnimda.co.uk"; var rateLimit = cfg.GetValue("Relay:RateLimitPerWindow", 5); var windowSeconds = cfg.GetValue("Relay:WindowSeconds", 600); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default)); // Real client IP from our own reverse proxy. builder.Services.Configure(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor; o.KnownNetworks.Clear(); o.KnownProxies.Clear(); }); builder.Services.AddCors(o => o.AddPolicy("site", p => p.WithOrigins(allowedOrigin).WithMethods("POST").WithHeaders("Content-Type"))); builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; o.AddPolicy("contact", ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = rateLimit, Window = TimeSpan.FromSeconds(windowSeconds), QueueLimit = 0, })); }); var app = builder.Build(); app.UseForwardedHeaders(); app.UseCors("site"); app.UseRateLimiter(); app.MapGet("/healthz", () => Results.Ok("ok")); app.MapPost("/api/contact", async (ContactRequest req, HttpContext http, ILogger log) => { var ipHash = HashIp(http.Connection.RemoteIpAddress?.ToString()); // Honeypot: a filled hidden field means a bot. Feign success, send nothing. if (!string.IsNullOrWhiteSpace(req.Company)) { log.LogInformation("contact rejected (honeypot) ip={Ip}", ipHash); return Results.Ok(new ContactResponse(true)); } var name = (req.Name ?? "").Trim(); var email = (req.Email ?? "").Trim(); var message = (req.Message ?? "").Trim(); if (name.Length is 0 or > 200 || email.Length is 0 or > 320 || !IsEmail(email) || message.Length is 0 or > 5000) { return Results.BadRequest(new ContactResponse(false, "invalid")); } try { await SendAsync(cfg, name, email, message); log.LogInformation("contact sent ip={Ip}", ipHash); return Results.Ok(new ContactResponse(true)); } catch (Exception ex) { log.LogError(ex, "contact send failed ip={Ip}", ipHash); return Results.StatusCode(StatusCodes.Status502BadGateway); } }).RequireRateLimiting("contact"); app.Run(); static bool IsEmail(string s) { var at = s.IndexOf('@'); var dot = s.LastIndexOf('.'); return at > 0 && dot > at + 1 && dot < s.Length - 1 && !s.Contains(' '); } static string HashIp(string? ip) { if (string.IsNullOrEmpty(ip)) return "unknown"; var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip)); return Convert.ToHexString(bytes, 0, 4); // short, non-reversible marker } static async Task SendAsync(IConfiguration cfg, string name, string email, string message) { var host = cfg["Smtp:Host"] ?? throw new InvalidOperationException("Smtp:Host not configured"); var port = cfg.GetValue("Smtp:Port", 587); var user = cfg["Smtp:User"]; var password = cfg["Smtp:Password"]; var to = cfg["Relay:ToAddress"] ?? throw new InvalidOperationException("Relay:ToAddress not configured"); var from = cfg["Relay:FromAddress"] ?? user ?? to; using var msg = new MailMessage { From = new MailAddress(from, "Portfolio contact form"), Subject = $"Portfolio contact from {name}", Body = $"From: {name} <{email}>\n\n{message}\n\n— sent via cesnimda.co.uk contact form", BodyEncoding = Encoding.UTF8, SubjectEncoding = Encoding.UTF8, }; msg.To.Add(to); msg.ReplyToList.Add(new MailAddress(email, name)); using var client = new SmtpClient(host, port) { EnableSsl = true, DeliveryMethod = SmtpDeliveryMethod.Network, Credentials = string.IsNullOrEmpty(user) ? null : new NetworkCredential(user, password), }; await client.SendMailAsync(msg); } record ContactRequest( [property: JsonPropertyName("name")] string? Name, [property: JsonPropertyName("email")] string? Email, [property: JsonPropertyName("message")] string? Message, [property: JsonPropertyName("company")] string? Company); record ContactResponse(bool Ok, string? Error = null); [JsonSerializable(typeof(ContactRequest))] [JsonSerializable(typeof(ContactResponse))] partial class AppJsonContext : JsonSerializerContext;