diff --git a/api/App/App.csproj b/api/App/App.csproj index 1bb0572..88a1b49 100644 --- a/api/App/App.csproj +++ b/api/App/App.csproj @@ -7,6 +7,7 @@ + diff --git a/api/App/Endpoints/FeedbackEndpoints.cs b/api/App/Endpoints/FeedbackEndpoints.cs new file mode 100644 index 0000000..3ec83d0 --- /dev/null +++ b/api/App/Endpoints/FeedbackEndpoints.cs @@ -0,0 +1,71 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +public record FeedbackRequest(string? Message); + +public class EmailOptions +{ + public string SmtpHost { get; set; } = string.Empty; + public int SmtpPort { get; set; } = 587; + public string FromAddress { get; set; } = string.Empty; + public string ToAddress { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public static class FeedbackEndpoints +{ + public static void MapFeedbackEndpoints(WebApplication app) + { + app.MapPost("/feedback", async ( + FeedbackRequest request, + IOptions emailOptions, + ILogger logger) => + { + if (string.IsNullOrWhiteSpace(request.Message)) + { + return Results.BadRequest(new { Message = "Feedback message is required." }); + } + + var message = request.Message.Trim(); + + if (message.Length > 5000) + { + return Results.BadRequest(new { Message = "Feedback message is too long." }); + } + + var options = emailOptions.Value; + + var email = new MimeMessage(); + email.From.Add(MailboxAddress.Parse(options.FromAddress)); + email.To.Add(MailboxAddress.Parse(options.ToAddress)); + email.Subject = "Klapi – New feedback"; + email.Body = new TextPart("plain") { Text = message }; + + try + { + using var smtp = new SmtpClient(); + await smtp.ConnectAsync(options.SmtpHost, options.SmtpPort, SecureSocketOptions.StartTls); + + if (!string.IsNullOrWhiteSpace(options.Username)) + { + await smtp.AuthenticateAsync(options.Username, options.Password); + } + + await smtp.SendAsync(email); + await smtp.DisconnectAsync(true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send feedback email."); + return Results.Problem("Failed to send feedback. Please try again later."); + } + + return Results.Ok(new { Message = "Feedback sent." }); + }) + .RequireCors("FrontendWriteCors") + .WithName("SubmitFeedback"); + } +} diff --git a/api/App/Program.cs b/api/App/Program.cs index 36db919..098e6d1 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -46,6 +46,29 @@ public class Program builder.Services.Configure(builder.Configuration.GetSection("Auth")); + var emailOptions = builder.Configuration.GetSection("Email").Get() + ?? throw new InvalidOperationException("Email configuration was not found."); + + if (builder.Environment.IsProduction()) + { + emailOptions.Username = + Environment.GetEnvironmentVariable("KLAPI_SMTP_USERNAME") + ?? throw new InvalidOperationException("SMTP username must be set in production using KLAPI_SMTP_USERNAME environment variable."); + emailOptions.Password = + Environment.GetEnvironmentVariable("KLAPI_SMTP_PASSWORD") + ?? throw new InvalidOperationException("SMTP password must be set in production using KLAPI_SMTP_PASSWORD environment variable."); + } + + builder.Services.Configure(o => + { + o.SmtpHost = emailOptions.SmtpHost; + o.SmtpPort = emailOptions.SmtpPort; + o.FromAddress = emailOptions.FromAddress; + o.ToAddress = emailOptions.ToAddress; + o.Username = emailOptions.Username; + o.Password = emailOptions.Password; + }); + builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -285,6 +308,7 @@ public class Program AuthEndpoints.MapAuthEndpoints(app); LokEndpoints.MapLokEndpoints(app); UserEndpoints.MapUserEndpoints(app); + FeedbackEndpoints.MapFeedbackEndpoints(app); app.Run(); } diff --git a/api/App/appsettings.Development.json b/api/App/appsettings.Development.json index 5955111..7cc8063 100644 --- a/api/App/appsettings.Development.json +++ b/api/App/appsettings.Development.json @@ -23,5 +23,13 @@ "Password": "changeme", "DisplayName": "Administrator" } + }, + "Email": { + "SmtpHost": "mail.tietokonepaja.fi", + "SmtpPort": 587, + "FromAddress": "feedback@tietokonepaja.fi", + "ToAddress": "veikko@lintujarvi.fi", + "Username": "", + "Password": "" } -} +} \ No newline at end of file diff --git a/api/App/appsettings.json b/api/App/appsettings.json index 0dc4bd3..a88a285 100644 --- a/api/App/appsettings.json +++ b/api/App/appsettings.json @@ -12,12 +12,22 @@ "Issuer": "klapi-api", "Audience": "klapi-ui", "SigningKey": "change-this-to-a-long-random-32-char-minimum-key", - "AllowedOrigins": ["https://klapi.tietokonepaja.fi"], + "AllowedOrigins": [ + "https://klapi.tietokonepaja.fi" + ], "Admin": { "Username": "admin", "Password": "", "DisplayName": "Administrator" } }, + "Email": { + "SmtpHost": "mail.tietokonepaja.fi", + "SmtpPort": 587, + "FromAddress": "feedback@tietokonepaja.fi", + "ToAddress": "veikko@lintujarvi.fi", + "Username": "", + "Password": "" + }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 02469c0..27f03cd 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -241,3 +241,10 @@ export async function deleteUser(username: string): Promise { }, }); } + +export async function submitFeedback(message: string): Promise { + await fetchApi<{ message: string }>("/feedback", { + method: "POST", + body: JSON.stringify({ message }), + }); +} diff --git a/ui/src/app.css b/ui/src/app.css index 31c85be..168c9cf 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -6,7 +6,6 @@ } #root { - user-select: none; } main { diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 88bdaf1..1da3191 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -4,6 +4,7 @@ import { useRecoilValue, useSetRecoilState } from "recoil"; import Nav from "~/components/Nav"; import Home from "~/routes/index"; import About from "~/routes/about"; +import Feedback from "~/routes/feedback"; import Login from "~/routes/login"; import Management from "~/routes/management"; import NotFound from "~/routes/[...404]"; @@ -53,6 +54,7 @@ function AppShell() { } /> } /> + } /> } /> { setSession(null); localStorage.removeItem("session-username"); @@ -28,68 +30,84 @@ export default function Nav() { }`; return ( -