wip
This commit is contained in:
parent
0dc41337da
commit
14fd359ea8
28 changed files with 156 additions and 52 deletions
53
Femto.Api/Auth/SessionAuthenticationHandler.cs
Normal file
53
Femto.Api/Auth/SessionAuthenticationHandler.cs
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Femto.Api.Sessions;
|
||||||
|
using Femto.Common;
|
||||||
|
using Femto.Modules.Auth.Application;
|
||||||
|
using Femto.Modules.Auth.Application.Commands.ValidateSession;
|
||||||
|
using Femto.Modules.Auth.Errors;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Femto.Api.Auth;
|
||||||
|
|
||||||
|
internal class SessionAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IAuthenticationModule authModule,
|
||||||
|
CurrentUserContext currentUserContext
|
||||||
|
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var sessionId = this.Request.Cookies["session"];
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await authModule.PostCommand(new ValidateSessionCommand(sessionId));
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.Name, result.Username),
|
||||||
|
new("sub", result.UserId.ToString()),
|
||||||
|
new("user_id", result.UserId.ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, this.Scheme.Name);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
this.Context.SetSession(result.Session);
|
||||||
|
currentUserContext.CurrentUser = new CurrentUser(result.UserId, result.Username);
|
||||||
|
|
||||||
|
return AuthenticateResult.Success(
|
||||||
|
new AuthenticationTicket(principal, this.Scheme.Name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (InvalidSessionError)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Invalid session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,8 +22,8 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase
|
||||||
return new LoginResponse(result.UserId, result.Username);
|
return new LoginResponse(result.UserId, result.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("signup")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<SignupResponse>> Signup([FromBody] SignupRequest request)
|
public async Task<ActionResult<RegisterResponse>> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
var result = await authModule.PostCommand(
|
var result = await authModule.PostCommand(
|
||||||
new RegisterCommand(request.Username, request.Password)
|
new RegisterCommand(request.Username, request.Password)
|
||||||
|
@ -31,7 +31,7 @@ public class AuthController(IAuthenticationModule authModule) : ControllerBase
|
||||||
|
|
||||||
HttpContext.SetSession(result.Session);
|
HttpContext.SetSession(result.Session);
|
||||||
|
|
||||||
return new SignupResponse(result.UserId, result.Username);
|
return new RegisterResponse(result.UserId, result.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("delete-session")]
|
[HttpPost("delete-session")]
|
||||||
|
|
3
Femto.Api/Controllers/Auth/RegisterRequest.cs
Normal file
3
Femto.Api/Controllers/Auth/RegisterRequest.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record RegisterRequest(string Username, string Password, string SignupCode, string? Email);
|
3
Femto.Api/Controllers/Auth/RegisterResponse.cs
Normal file
3
Femto.Api/Controllers/Auth/RegisterResponse.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
public record RegisterResponse(Guid UserId, string Username);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Auth;
|
|
||||||
|
|
||||||
public record SignupRequest(string Username, string Password, string SignupCode, string? Email);
|
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Api.Controllers.Auth;
|
|
||||||
|
|
||||||
public record SignupResponse(Guid UserId, string Username);
|
|
|
@ -4,6 +4,7 @@ using Femto.Modules.Blog.Application;
|
||||||
using Femto.Modules.Blog.Application.Commands.CreatePost;
|
using Femto.Modules.Blog.Application.Commands.CreatePost;
|
||||||
using Femto.Modules.Blog.Application.Queries.GetPosts;
|
using Femto.Modules.Blog.Application.Queries.GetPosts;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Femto.Api.Controllers.Posts;
|
namespace Femto.Api.Controllers.Posts;
|
||||||
|
@ -13,6 +14,7 @@ namespace Femto.Api.Controllers.Posts;
|
||||||
public class PostsController(IBlogModule blogModule) : ControllerBase
|
public class PostsController(IBlogModule blogModule) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
|
public async Task<ActionResult<GetAllPublicPostsResponse>> GetAllPublicPosts(
|
||||||
[FromQuery] GetPublicPostsSearchParams searchParams,
|
[FromQuery] GetPublicPostsSearchParams searchParams,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|
8
Femto.Api/CurrentUserContext.cs
Normal file
8
Femto.Api/CurrentUserContext.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
using Femto.Common;
|
||||||
|
|
||||||
|
namespace Femto.Api;
|
||||||
|
|
||||||
|
internal class CurrentUserContext : ICurrentUserContext
|
||||||
|
{
|
||||||
|
public CurrentUser? CurrentUser { get; set; }
|
||||||
|
}
|
|
@ -28,4 +28,8 @@
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Middleware\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Femto.Api;
|
||||||
|
using Femto.Api.Auth;
|
||||||
|
using Femto.Common;
|
||||||
using Femto.Modules.Auth.Application;
|
using Femto.Modules.Auth.Application;
|
||||||
using Femto.Modules.Blog.Application;
|
using Femto.Modules.Blog.Application;
|
||||||
using Femto.Modules.Media.Application;
|
using Femto.Modules.Media.Application;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
|
const string CorsPolicyName = "DefaultCorsPolicy";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
@ -20,15 +26,18 @@ builder.Services.InitializeBlogModule(connectionString);
|
||||||
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
|
builder.Services.InitializeMediaModule(connectionString, blobStorageRoot);
|
||||||
builder.Services.InitializeAuthenticationModule(connectionString);
|
builder.Services.InitializeAuthenticationModule(connectionString);
|
||||||
|
|
||||||
|
builder.Services.AddScoped<CurrentUserContext, CurrentUserContext>();
|
||||||
|
builder.Services.AddScoped<ICurrentUserContext>(s => s.GetRequiredService<CurrentUserContext>());
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy(
|
options.AddPolicy(
|
||||||
"DefaultCorsPolicy",
|
CorsPolicyName,
|
||||||
b =>
|
b =>
|
||||||
{
|
{
|
||||||
b.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin();
|
b.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:5173");
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -44,9 +53,18 @@ builder
|
||||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
|
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder
|
||||||
|
.Services.AddAuthentication("SessionAuth")
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(
|
||||||
|
"SessionAuth",
|
||||||
|
options => { }
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(); // if not already added
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors("DefaultCorsPolicy");
|
app.UseCors(CorsPolicyName);
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|
|
@ -12,8 +12,8 @@ internal static class HttpContextSessionExtensions
|
||||||
new CookieOptions
|
new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = true,
|
// Secure = true,
|
||||||
SameSite = SameSiteMode.Strict,
|
// SameSite = SameSiteMode.Strict,
|
||||||
Expires = session.Expires,
|
Expires = session.Expires,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
7
Femto.Common/Domain/DomainError.cs
Normal file
7
Femto.Common/Domain/DomainError.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public class DomainError : Exception
|
||||||
|
{
|
||||||
|
public DomainError(string message, Exception innerException) : base(message, innerException) {}
|
||||||
|
public DomainError(string message) : base(message) {}
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
namespace Femto.Common.Domain;
|
|
||||||
|
|
||||||
public class DomainException : Exception
|
|
||||||
{
|
|
||||||
public DomainException(string message, Exception innerException) : base(message, innerException) {}
|
|
||||||
public DomainException(string message) : base(message) {}
|
|
||||||
}
|
|
|
@ -16,6 +16,6 @@ public abstract class Entity
|
||||||
protected void CheckRule(IRule rule)
|
protected void CheckRule(IRule rule)
|
||||||
{
|
{
|
||||||
if (!rule.Check())
|
if (!rule.Check())
|
||||||
throw new RuleBrokenException(rule.Message);
|
throw new RuleBrokenError(rule.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
3
Femto.Common/Domain/RuleBrokenError.cs
Normal file
3
Femto.Common/Domain/RuleBrokenError.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace Femto.Common.Domain;
|
||||||
|
|
||||||
|
public class RuleBrokenError(string message) : DomainError(message);
|
|
@ -1,3 +0,0 @@
|
||||||
namespace Femto.Common.Domain;
|
|
||||||
|
|
||||||
public class RuleBrokenException(string message) : DomainException(message);
|
|
8
Femto.Common/ICurrentUserContext.cs
Normal file
8
Femto.Common/ICurrentUserContext.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Femto.Common;
|
||||||
|
|
||||||
|
public interface ICurrentUserContext
|
||||||
|
{
|
||||||
|
CurrentUser? CurrentUser { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CurrentUser(Guid Id, string Username);
|
|
@ -53,4 +53,11 @@ CREATE TABLE media.saved_blob
|
||||||
uploaded_on timestamp DEFAULT now() NOT NULL,
|
uploaded_on timestamp DEFAULT now() NOT NULL,
|
||||||
type varchar(64) NOT NULL,
|
type varchar(64) NOT NULL,
|
||||||
size int
|
size int
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SCHEMA authn;
|
||||||
|
|
||||||
|
CREATE TABLE authn.user_identity
|
||||||
|
(
|
||||||
|
|
||||||
);
|
);
|
|
@ -1,10 +1,8 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -35,6 +35,8 @@ public static class AuthenticationStartup
|
||||||
builder.EnableSensitiveDataLogging();
|
builder.EnableSensitiveDataLogging();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddScoped<DbContext>(s => s.GetRequiredService<AuthContext>());
|
||||||
|
|
||||||
services.AddMediatR(c =>
|
services.AddMediatR(c =>
|
||||||
{
|
{
|
||||||
c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly);
|
c.RegisterServicesFromAssembly(typeof(AuthenticationStartup).Assembly);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using Femto.Common.Domain;
|
using Femto.Common.Domain;
|
||||||
using Femto.Modules.Auth.Application.Dto;
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
using Femto.Modules.Auth.Application.Services;
|
|
||||||
using Femto.Modules.Auth.Data;
|
using Femto.Modules.Auth.Data;
|
||||||
using Femto.Modules.Auth.Models;
|
using Femto.Modules.Auth.Models;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
@ -8,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application.Commands.Login;
|
namespace Femto.Modules.Auth.Application.Commands.Login;
|
||||||
|
|
||||||
internal class LoginCommandHandler(AuthContext context, SessionGenerator sessionGenerator)
|
internal class LoginCommandHandler(AuthContext context)
|
||||||
: ICommandHandler<LoginCommand, LoginResult>
|
: ICommandHandler<LoginCommand, LoginResult>
|
||||||
{
|
{
|
||||||
public async Task<LoginResult> Handle(LoginCommand request, CancellationToken cancellationToken)
|
public async Task<LoginResult> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||||
|
@ -19,14 +18,12 @@ internal class LoginCommandHandler(AuthContext context, SessionGenerator session
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
throw new DomainException("invalid credentials");
|
throw new DomainError("invalid credentials");
|
||||||
|
|
||||||
if (!user.HasPassword(request.Password))
|
if (!user.HasPassword(request.Password))
|
||||||
throw new DomainException("invalid credentials");
|
throw new DomainError("invalid credentials");
|
||||||
|
|
||||||
var session = sessionGenerator.GenerateSession();
|
var session = user.StartNewSession();
|
||||||
|
|
||||||
await context.AddAsync(session, cancellationToken);
|
|
||||||
|
|
||||||
return new(new Session(session.Id, session.Expires), user.Id, user.Username);
|
return new(new Session(session.Id, session.Expires), user.Id, user.Username);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
using Femto.Common.Domain;
|
using Femto.Common.Domain;
|
||||||
using Femto.Modules.Auth.Application.Dto;
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
using Femto.Modules.Auth.Application.Services;
|
|
||||||
using Femto.Modules.Auth.Data;
|
using Femto.Modules.Auth.Data;
|
||||||
using Femto.Modules.Auth.Models;
|
using Femto.Modules.Auth.Models;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application.Commands.Register;
|
namespace Femto.Modules.Auth.Application.Commands.Register;
|
||||||
|
|
||||||
internal class RegisterCommandHandler(AuthContext context, SessionGenerator sessionGenerator) : ICommandHandler<RegisterCommand, RegisterResult>
|
internal class RegisterCommandHandler(AuthContext context) : ICommandHandler<RegisterCommand, RegisterResult>
|
||||||
{
|
{
|
||||||
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
|
public async Task<RegisterResult> Handle(RegisterCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,4 +3,8 @@ using Femto.Modules.Auth.Application.Dto;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
|
namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
|
||||||
|
|
||||||
public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;
|
/// <summary>
|
||||||
|
/// Validate an existing session, and then return either the current session, or a new one in case the expiry is further in the future
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="SessionId"></param>
|
||||||
|
public record ValidateSessionCommand(string SessionId) : ICommand<ValidateSessionResult>;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using Femto.Common.Domain;
|
using Femto.Common.Domain;
|
||||||
using Femto.Modules.Auth.Application.Dto;
|
using Femto.Modules.Auth.Application.Dto;
|
||||||
using Femto.Modules.Auth.Data;
|
using Femto.Modules.Auth.Data;
|
||||||
|
using Femto.Modules.Auth.Errors;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
|
namespace Femto.Modules.Auth.Application.Commands.ValidateSession;
|
||||||
|
@ -21,7 +22,7 @@ internal class ValidateSessionCommandHandler(AuthContext context)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
throw new DomainException("invalid session");
|
throw new InvalidSessionError();
|
||||||
|
|
||||||
var session = user.StartNewSession();
|
var session = user.StartNewSession();
|
||||||
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
using Femto.Modules.Auth.Models;
|
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Application.Services;
|
|
||||||
|
|
||||||
public class SessionGenerator
|
|
||||||
{
|
|
||||||
public UserSession GenerateSession()
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Femto.Modules.Auth.Data;
|
namespace Femto.Modules.Auth.Data;
|
||||||
|
|
||||||
internal class AuthContext : DbContext, IOutboxContext
|
internal class AuthContext(DbContextOptions<AuthContext> options) : DbContext(options), IOutboxContext
|
||||||
{
|
{
|
||||||
public virtual DbSet<UserIdentity> Users { get; }
|
public virtual DbSet<UserIdentity> Users { get; }
|
||||||
public virtual DbSet<OutboxEntry> Outbox { get; }
|
public virtual DbSet<OutboxEntry> Outbox { get; }
|
||||||
|
|
14
Femto.Modules.Auth/Errors/InvalidSessionError.cs
Normal file
14
Femto.Modules.Auth/Errors/InvalidSessionError.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
using Femto.Common.Domain;
|
||||||
|
|
||||||
|
namespace Femto.Modules.Auth.Errors;
|
||||||
|
|
||||||
|
public class InvalidSessionError : DomainError
|
||||||
|
{
|
||||||
|
private const string Code = "invalid_session";
|
||||||
|
|
||||||
|
public InvalidSessionError()
|
||||||
|
: base("invalid_session") { }
|
||||||
|
|
||||||
|
public InvalidSessionError(Exception e)
|
||||||
|
: base("invalid_session", e) { }
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ internal class UserIdentity : Entity
|
||||||
|
|
||||||
public UserPassword Password { get; private set; }
|
public UserPassword Password { get; private set; }
|
||||||
|
|
||||||
public ICollection<UserSession> Sessions { get; private set; }
|
public ICollection<UserSession> Sessions { get; private set; } = [];
|
||||||
|
|
||||||
private UserIdentity() { }
|
private UserIdentity() { }
|
||||||
|
|
||||||
|
@ -57,4 +57,4 @@ internal class UserIdentity : Entity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SetPasswordError(string message, Exception inner) : DomainException(message, inner);
|
public class SetPasswordError(string message, Exception inner) : DomainError(message, inner);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue