feat(backend): Replace Node.js API with .NET 8

This commit completes the migration of the backend service from a Node.js/Express application to an ASP.NET Core 8 Minimal API.

- Re-implemented all API endpoints in C# for improved performance and type safety.
- Updated the Gitea Actions workflow to use the `setup-dotnet` action, `dotnet publish` for building, and now caches NuGet packages.
- Modified the deployment to create an `appsettings.Production.json` file from Gitea secrets instead of a `.env` file.
- Updated the PM2 ecosystem configuration to run the application using the `dotnet` interpreter.
This commit is contained in:
2025-10-29 19:14:20 -03:00
parent f14e298c46
commit 40fd792be1
30 changed files with 1017 additions and 1077 deletions

View File

@@ -0,0 +1,36 @@
# Stage 1: Build the application
# Use the .NET SDK image which contains all the tools to build and publish
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy all .csproj files and the .sln file first. This is a key optimization.
# Docker will cache this layer, and will only re-run `dotnet restore` if a project file changes.
COPY ["JoaoLoureiro.Portfolio.Core/JoaoLoureiro.Portfolio.Core.csproj", "JoaoLoureiro.Portfolio.Core/"]
COPY ["JoaoLoureiro.Portfolio.Infrastructure/JoaoLoureiro.Portfolio.Infrastructure.csproj", "JoaoLoureiro.Portfolio.Infrastructure/"]
COPY ["JoaoLoureiro.Portfolio.Api/JoaoLoureiro.Portfolio.Api.csproj", "JoaoLoureiro.Portfolio.Api/"]
COPY ["JoaoLoureiro.Portfolio.sln", "."]
# Restore all NuGet packages for the entire solution
RUN dotnet restore "JoaoLoureiro.Portfolio.sln"
# Copy the rest of the source code
COPY . .
# Publish the API project, creating the release-ready artifacts
WORKDIR "JoaoLoureiro.Portfolio.Api"
RUN dotnet publish "JoaoLoureiro.Portfolio.Api.csproj" -c Release -o /app/publish --no-restore
# Stage 2: Create the final, minimal runtime image
# Use the lightweight ASP.NET runtime image, which is much smaller than the SDK
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# The new .NET 8 images run as a non-root 'app' user by default for better security.
# We expose port 8080, as the default internal port 80 is often a privileged port.
EXPOSE 8080
# Copy the published output from the build stage
COPY --from=build /app/publish .
# Set the entrypoint for the container
ENTRYPOINT ["dotnet", "JoaoLoureiro.Portfolio.Api.dll"]

View File

@@ -0,0 +1,8 @@
namespace JoaoLoureiro.Portfolio.Api.Dtos;
public class ContactMessageRequest
{
public required string Name { get; set; }
public required string Email { get; set; }
public required string Message { get; set; }
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>13f24551-c620-43ab-9c94-073e5617c79d</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Controllers\**" />
<Content Remove="Controllers\**" />
<EmbeddedResource Remove="Controllers\**" />
<None Remove="Controllers\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.System" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="AutoMapper" Version="15.0.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0-preview.4" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JoaoLoureiro.Portfolio.Application\JoaoLoureiro.Portfolio.Core.csproj" />
<ProjectReference Include="..\JoaoLoureiro.Portfolio.Infrastructure\JoaoLoureiro.Portfolio.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@JoaoLoureiro.Portfolio.Api_HostAddress = http://localhost:5125
GET {{JoaoLoureiro.Portfolio.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,15 @@
using AutoMapper;
using JoaoLoureiro.Portfolio.Api.Dtos;
using JoaoLoureiro.Portfolio.Core.Models;
namespace JoaoLoureiro.Portfolio.Api.Mapping;
public class MappingProfile : Profile
{
public MappingProfile() {
CreateMap<ContactMessageRequest, EmailToSend>()
.ForMember(dest => dest.SenderName, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.ReplyToEmail, opt => opt.MapFrom(src => src.Email))
.ForMember(dest => dest.MessageBody, opt => opt.MapFrom(src => src.Message));
}
}

View File

@@ -0,0 +1,122 @@
using AutoMapper;
using FluentValidation;
using HealthChecks.UI.Client;
using JoaoLoureiro.Portfolio.Api.Dtos;
using JoaoLoureiro.Portfolio.Core.Interfaces;
using JoaoLoureiro.Portfolio.Core.Models;
using JoaoLoureiro.Portfolio.Infrastructure.HealthCheck;
using JoaoLoureiro.Portfolio.Infrastructure.Services;
using JoaoLoureiro.Portfolio.Infrastructure.Settings;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Serilog;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, config) => config.ReadFrom.Configuration(context.Configuration));
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var corsOrigins = builder.Configuration["CorsOrigins"];
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (corsOrigins is not null)
{
policy.WithOrigins(corsOrigins.Split(','))
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});
builder.Services.AddOptions<SmtpSettings>()
.Bind(builder.Configuration.GetSection("SmtpSettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddAutoMapper(cfg => { }, typeof(Program));
builder.Services.AddHealthChecks()
.AddCheck<SmtpHealthCheck>("SMTP");
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.KnownProxies.Add(IPAddress.Parse(builder.Configuration["ProxyIP"] ?? "10.0.10.10"));
});
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapPost("/api/email/send", async (
[FromBody] ContactMessageRequest request,
IValidator<ContactMessageRequest> validator,
IEmailSender emailSender,
IMapper mapper,
ILogger<Program> logger,
CancellationToken cancellationToken) =>
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
var error = validationResult.Errors.First();
return Results.BadRequest(new { errorKey = error.ErrorCode });
}
try
{
var email = mapper.Map<EmailToSend>(request);
await emailSender.SendEmailAsync(email, cancellationToken);
return Results.Ok(new { message = "Message sent successfully!" });
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred while sending email.");
return Results.Problem(
detail: "An unexpected error occurred on the server.",
statusCode: 500,
title: "Server Error",
extensions: new Dictionary<string, object?> { { "errorKey", "server_unexpected_error" } }
);
}
})
.WithName("SendContactMessage")
.WithDescription("Accepts a contact form submission and sends it as an email.")
.Produces(StatusCodes.Status200OK)
.Produces<HttpValidationProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
app.Run();

View File

@@ -0,0 +1,52 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5125"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7153;http://localhost:5125"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:36910",
"sslPort": 44372
}
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
using JoaoLoureiro.Portfolio.Api.Dtos;
namespace JoaoLoureiro.Portfolio.Api.Validators;
public class ContactMessageRequestValidator : AbstractValidator<ContactMessageRequest>
{
public ContactMessageRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithErrorCode("status_error_all_fields");
RuleFor(x => x.Message).NotEmpty().WithErrorCode("status_error_all_fields");
RuleFor(x => x.Email)
.NotEmpty().WithErrorCode("status_error_all_fields")
.EmailAddress().WithErrorCode("status_error_invalid_email");
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}