Added backend functions

This commit is contained in:
2025-03-04 13:56:38 +01:00
parent a98709b0a1
commit 3a118a9b34
22 changed files with 578 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ install-mobile:
stage: install stage: install
image: node:lts-alpine image: node:lts-alpine
before_script: before_script:
- cd src/WorkTime.Mobile - cd src/WorkTime.WebMobile
script: script:
- npm install --prefer-offline - npm install --prefer-offline
cache: cache:
@@ -30,7 +30,7 @@ lint-mobile:
image: node:lts-alpine image: node:lts-alpine
needs: ["install-mobile"] needs: ["install-mobile"]
before_script: before_script:
- cd src/WorkTime.Mobile - cd src/WorkTime.WebMobile
script: script:
- npm run lint - npm run lint
cache: cache:
@@ -46,7 +46,7 @@ build-mobile:
image: node:lts-alpine image: node:lts-alpine
needs: ["lint-mobile"] needs: ["lint-mobile"]
before_script: before_script:
- cd src/WorkTime.Mobile - cd src/WorkTime.WebMobile
script: script:
- npm run build - npm run build
artifacts: artifacts:
@@ -90,7 +90,7 @@ publish-mobile:
- name: docker:dind - name: docker:dind
alias: docker alias: docker
before_script: before_script:
- cd src/WorkTime.Mobile - cd src/WorkTime.WebMobile
script: script:
- export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//') - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//')
- docker login -u leon.hoppe -p ${CI_REGISTRY_PASSWORD} registry.leon-hoppe.de - docker login -u leon.hoppe -p ${CI_REGISTRY_PASSWORD} registry.leon-hoppe.de

View File

@@ -6,6 +6,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Host", "src\WorkTi
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.ServiceDefaults", "src\WorkTime.ServiceDefaults\WorkTime.ServiceDefaults.csproj", "{B66AA463-03D5-4814-B1D4-71663804248C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.ServiceDefaults", "src\WorkTime.ServiceDefaults\WorkTime.ServiceDefaults.csproj", "{B66AA463-03D5-4814-B1D4-71663804248C}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Shared", "src\WorkTime.Shared\WorkTime.Shared.csproj", "{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTime.Api", "src\WorkTime.Api\WorkTime.Api.csproj", "{CED653D6-A0B6-432B-9C36-FBB58EEA8229}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -14,6 +18,8 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382} {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382}
{B66AA463-03D5-4814-B1D4-71663804248C} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382} {B66AA463-03D5-4814-B1D4-71663804248C} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382}
{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382}
{CED653D6-A0B6-432B-9C36-FBB58EEA8229} = {25C5A6B2-A1F9-4244-9538-18E3FE76D382}
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6F5D4D47-1484-44EA-A5DD-D00AAD2F2F68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -24,5 +30,13 @@ Global
{B66AA463-03D5-4814-B1D4-71663804248C}.Debug|Any CPU.Build.0 = Debug|Any CPU {B66AA463-03D5-4814-B1D4-71663804248C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B66AA463-03D5-4814-B1D4-71663804248C}.Release|Any CPU.ActiveCfg = Release|Any CPU {B66AA463-03D5-4814-B1D4-71663804248C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B66AA463-03D5-4814-B1D4-71663804248C}.Release|Any CPU.Build.0 = Release|Any CPU {B66AA463-03D5-4814-B1D4-71663804248C}.Release|Any CPU.Build.0 = Release|Any CPU
{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6A73E21-39B8-4FA7-8D6D-D5DDB8FB8AF0}.Release|Any CPU.Build.0 = Release|Any CPU
{CED653D6-A0B6-432B-9C36-FBB58EEA8229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CED653D6-A0B6-432B-9C36-FBB58EEA8229}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CED653D6-A0B6-432B-9C36-FBB58EEA8229}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CED653D6-A0B6-432B-9C36-FBB58EEA8229}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using WorkTime.Shared.Services;
namespace WorkTime.Api;
public class AuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAuthService authService)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
if (! await authService.IsAuthenticated()) {
return AuthenticateResult.Fail("Invalid or missing Guid.");
}
var guid = await authService.GetCurrentUserId();
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, guid.ToString()) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
public static Task ConfigureOpenApi(OpenApiDocument document, OpenApiDocumentTransformerContext transformerContext, CancellationToken token) {
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, OpenApiSecurityScheme>();
document.SecurityRequirements ??= new List<OpenApiSecurityRequirement>();
document.Components.SecuritySchemes.Add(nameof(AuthHandler), new OpenApiSecurityScheme {
Type = SecuritySchemeType.Http,
In = ParameterLocation.Header,
Name = IAuthService.HeaderName,
Scheme = "Bearer",
Description = "GUID Authorization header using a custom scheme.\nExample: \"Authorization: {GUID}\""
});
//TODO: only add security requirement to authorized endpoints
document.SecurityRequirements.Add(new() {
{
new() {
Reference = new OpenApiReference {
Type = ReferenceType.SecurityScheme,
Id = nameof(AuthHandler)
}
}, []
}
});
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
namespace WorkTime.Api.Controllers;
[ApiController, Route("auth")]
public class AuthController : ControllerBase {
[HttpGet("register")]
public ActionResult<Guid> Register() {
return Ok(Guid.NewGuid().ToString());
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using WorkTime.Api.Logic;
using WorkTime.Shared.Models;
namespace WorkTime.Api.Controllers;
[ApiController, Route("entries"), Authorize]
public class EntryController(EntryLogic logic) : ControllerBase {
[HttpGet("{id:guid}")]
public async Task<IResult> GetAllEntries(Guid id) {
var result = await logic.GetAllEntries(id);
return result.MapResult();
}
[HttpGet("{id:guid}/{day:datetime}")]
public async Task<IResult> GetEntries(Guid id, DateTime day) {
var result = await logic.GetEntries(id, DateOnly.FromDateTime(day));
return result.MapResult();
}
[HttpPost]
public async Task<IResult> AddEntry(TimeEntry entry) {
var result = await logic.AddEntry(entry);
return result.MapResult();
}
[HttpDelete("{id:int}")]
public async Task<IResult> DeleteEntry(int id) {
var result = await logic.DeleteEntry(id);
return result.MapResult();
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
using WorkTime.Shared.Models;
namespace WorkTime.Api;
public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbContext(options) {
public DbSet<TimeEntry> Entries { get; set; }
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/WorkTime.Api/WorkTime.Api.csproj", "src/WorkTime.Api/"]
RUN dotnet restore "src/WorkTime.Api/WorkTime.Api.csproj"
COPY . .
WORKDIR "/src/src/WorkTime.Api"
RUN dotnet build "WorkTime.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "WorkTime.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WorkTime.Api.dll"]

View File

@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Http.HttpResults;
using WorkTime.ServiceDefaults.Results;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
namespace WorkTime.Api.Logic;
public class EntryLogic(IEntryRepository entryRepository, IAuthService authService) {
public async Task<LogicResult<TimeEntry[]>> GetAllEntries(Guid owner) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != owner)
return new(Results.Unauthorized());
return await entryRepository.GetAllEntries(owner);
}
public async Task<LogicResult<TimeEntry[]>> GetEntries(Guid owner, DateOnly date) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != owner)
return new(Results.Unauthorized());
return await entryRepository.GetEntries(owner, date);
}
public async Task<LogicResult> AddEntry(TimeEntry entry) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
if (await authService.GetCurrentUserId() != entry.Owner)
return new(Results.Unauthorized());
await entryRepository.AddEntry(entry);
return new();
}
public async Task<LogicResult> DeleteEntry(int id) {
if (!await authService.IsAuthenticated())
return new(Results.Unauthorized());
var entry = await entryRepository.GetEntry(id);
if (entry is null)
return new(Results.NotFound());
if (await authService.GetCurrentUserId() != entry.Owner)
return new(Results.Unauthorized());
await entryRepository.DeleteEntry(entry);
return new();
}
}

View File

@@ -0,0 +1,56 @@
using MartinCostello.OpenApi;
using Microsoft.AspNetCore.Authentication;
using WorkTime.Api;
using WorkTime.Api.Logic;
using WorkTime.Api.Repositories;
using WorkTime.Api.Services;
using WorkTime.Shared.Repositories;
using WorkTime.Shared.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(options => {
options.AddDocumentTransformer(AuthHandler.ConfigureOpenApi);
});
builder.Services.AddOpenApiExtensions(options => {
options.AddServerUrls = true;
});
builder.AddServiceDefaults();
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddHttpContextAccessor();
builder.Services.AddNpgsql<DatabaseContext>(builder.Configuration.GetConnectionString("data"));
builder.Services.AddScoped<IEntryRepository, EntryRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<EntryLogic>();
builder.Services.AddAuthentication(nameof(AuthHandler))
.AddScheme<AuthenticationSchemeOptions, AuthHandler>(nameof(AuthHandler), _ => { });
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
app.MapOpenApi();
app.UseSwaggerUI(options => {
options.SwaggerEndpoint("/openapi/v1.json", "WorkTime.Api");
});
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
db.Database.EnsureCreated();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7091;http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using WorkTime.Shared.Models;
using WorkTime.Shared.Repositories;
namespace WorkTime.Api.Repositories;
internal class EntryRepository(DatabaseContext context) : IEntryRepository {
public async Task<TimeEntry[]> GetAllEntries(Guid owner) {
return await context.Entries
.Where(entry => entry.Owner == owner)
.OrderBy(entry => entry.Timestamp)
.ToArrayAsync();
}
public async Task<TimeEntry[]> GetEntries(Guid owner, DateOnly date) {
return await context.Entries
.Where(entry => entry.Owner == owner)
.Where(entry => DateOnly.FromDateTime(entry.Timestamp) == date)
.OrderBy(entry => entry.Timestamp)
.ToArrayAsync();
}
public async Task<TimeEntry?> GetEntry(int id) {
return await context.Entries
.FindAsync(id);
}
public async Task AddEntry(TimeEntry entry) {
await context.Entries.AddAsync(entry);
await context.SaveChangesAsync();
}
public async Task DeleteEntry(TimeEntry entry) {
context.Entries.Remove(entry);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,29 @@
using WorkTime.Shared.Services;
namespace WorkTime.Api.Services;
internal class AuthService(IHttpContextAccessor accessor) : IAuthService {
public Task<bool> IsAuthenticated() {
var header = accessor.HttpContext?.Request.Headers[IAuthService.HeaderName];
if (header is not { Count: 1 })
return Task.FromResult(false);
var value = header.Value[0]!.Replace("Bearer ", "");
if (Guid.TryParse(value, out var guid))
return Task.FromResult(false);
return Task.FromResult(guid != Guid.Empty);
}
public async Task<Guid> GetCurrentUserId() {
if (!await IsAuthenticated())
return Guid.Empty;
var header = accessor.HttpContext?.Request.Headers[IAuthService.HeaderName]!;
var value = header.Value[0]!.Replace("Bearer ", "");
return Guid.Parse(value);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.1.0" />
<PackageReference Include="MartinCostello.OpenApi.Extensions" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.3.1" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WorkTime.ServiceDefaults\WorkTime.ServiceDefaults.csproj" />
<ProjectReference Include="..\WorkTime.Shared\WorkTime.Shared.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddPostgres("db") var db = builder.AddPostgres("db")
.WithDataVolume(); .WithDataVolume();
builder.AddProject<Projects.WorkTime_Api>("api")
.WithReference(db.AddDatabase("data"))
.WaitFor(db);
builder.Build().Run(); builder.Build().Run();

View File

@@ -16,4 +16,8 @@
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.1.0" /> <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WorkTime.Api\WorkTime.Api.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Http;
namespace WorkTime.ServiceDefaults.Results;
public interface ILogicResult<TResult, TError> {
public TResult? Result { get; init; }
public TError? Error { get; init; }
public bool IsSuccessful => Result is not null && Error is null;
IResult MapResult();
}
public interface ILogicResult<TResult> : ILogicResult<TResult, IResult>;
public interface ILogicResult : ILogicResult<object>;
public readonly struct LogicResult<TResult, TError> : ILogicResult<TResult, TError> {
public TResult? Result { get; init; }
public TError? Error { get; init; }
public bool IsSuccessful => Result is not null && Error is null;
public static implicit operator LogicResult<TResult, TError>(TResult result) {
return new LogicResult<TResult, TError> {
Result = result
};
}
public static implicit operator LogicResult<TResult, TError>(TError error) {
return new LogicResult<TResult, TError> {
Error = error
};
}
public IResult MapResult() {
if (!IsSuccessful)
return Microsoft.AspNetCore.Http.Results.Problem();
return Microsoft.AspNetCore.Http.Results.Ok(Result);
}
}
public readonly struct LogicResult<TResult> : ILogicResult<TResult> {
public TResult? Result { get; init; }
public IResult? Error { get; init; }
public bool IsSuccessful => Error is null;
public static implicit operator LogicResult<TResult>(TResult result) {
return new LogicResult<TResult> {
Result = result
};
}
public LogicResult() { }
public LogicResult(IResult error) {
Error = error;
}
public IResult MapResult() {
if (!IsSuccessful) {
return Error!;
}
return Microsoft.AspNetCore.Http.Results.Ok(Result);
}
}
public readonly struct LogicResult : ILogicResult {
public object? Result { get; init; }
public IResult? Error { get; init; }
public bool IsSuccessful => Error is null;
public LogicResult() { }
public LogicResult(IResult error) {
Error = error;
}
public IResult MapResult() {
if (!IsSuccessful) {
return Error!;
}
return Microsoft.AspNetCore.Http.Results.Ok(Result);
}
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WorkTime.Shared.Models;
public class TimeEntry {
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public required Guid Owner { get; set; }
public required TimeEntryType Type { get; set; }
public DateTime Timestamp { get; set; } = DateTime.Now;
public bool IsMoba { get; set; }
}
public enum TimeEntryType {
Login,
Logout,
LoginDrive,
LogoutDrive
}

View File

@@ -0,0 +1,17 @@
using WorkTime.Shared.Models;
namespace WorkTime.Shared.Repositories;
public interface IEntryRepository {
Task<TimeEntry[]> GetAllEntries(Guid owner);
Task<TimeEntry[]> GetEntries(Guid owner, DateOnly date);
Task<TimeEntry?> GetEntry(int id);
Task AddEntry(TimeEntry entry);
Task DeleteEntry(TimeEntry entry);
}

View File

@@ -0,0 +1,11 @@
namespace WorkTime.Shared.Services;
public interface IAuthService {
public const string HeaderName = "Authentication";
public Task<bool> IsAuthenticated();
public Task<Guid> GetCurrentUserId();
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.2" />
</ItemGroup>
</Project>