diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a193913 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "site", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"], + "port": 4321 + } + ] +} diff --git a/relay/.dockerignore b/relay/.dockerignore new file mode 100644 index 0000000..d5365a9 --- /dev/null +++ b/relay/.dockerignore @@ -0,0 +1,5 @@ +bin/ +obj/ +**/appsettings.*.local.json +Dockerfile +.dockerignore diff --git a/relay/ContactRelay.csproj b/relay/ContactRelay.csproj new file mode 100644 index 0000000..c3f9ef0 --- /dev/null +++ b/relay/ContactRelay.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + enable + enable + true + ContactRelay + ContactRelay + + + diff --git a/relay/Dockerfile b/relay/Dockerfile new file mode 100644 index 0000000..b4d6be3 --- /dev/null +++ b/relay/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +# ---- build ---- +FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +WORKDIR /src +COPY ContactRelay.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app --no-restore + +# ---- runtime ---- +FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final +WORKDIR /app +RUN addgroup -S app && adduser -S app -G app +COPY --from=build /app . +USER app +ENV ASPNETCORE_URLS=http://+:8081 \ + DOTNET_EnableDiagnostics=0 +EXPOSE 8081 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -qO- http://localhost:8081/healthz || exit 1 +ENTRYPOINT ["dotnet", "ContactRelay.dll"] diff --git a/relay/Program.cs b/relay/Program.cs new file mode 100644 index 0000000..b032109 --- /dev/null +++ b/relay/Program.cs @@ -0,0 +1,146 @@ +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; diff --git a/relay/appsettings.json b/relay/appsettings.json new file mode 100644 index 0000000..fb04949 --- /dev/null +++ b/relay/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Relay": { + "AllowedOrigin": "https://cesnimda.co.uk", + "RateLimitPerWindow": 5, + "WindowSeconds": 600 + }, + "Smtp": { + "Port": 587 + } +}