Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ bin/
obj/
reservations.db
reservations.db-shm
reservations.db-wal
reservations.db-wal
.DS_Store
.vs
21 changes: 20 additions & 1 deletion api/Controllers/GuestController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Models;
using Models.Errors;
using Repositories;

namespace Controllers
Expand All @@ -8,10 +9,12 @@ namespace Controllers
public class GuestController : Controller
{
private GuestRepository _repo;
private ILogger<GuestController> Logger { get; set; }

public GuestController(GuestRepository guestRepository)
public GuestController(GuestRepository guestRepository, ILogger<GuestController> logger)
{
_repo = guestRepository;
Logger = logger;
}

[HttpGet, Produces("application/json"), Route("")]
Expand All @@ -21,5 +24,21 @@ public async Task<ActionResult<Guest>> GetGuests()

return Json(guests);
}

[HttpPost, Produces("application/json"), Route("")]
public async Task<ActionResult<Guest>> AddGuest([FromBody] Guest guest)
{
try
{
var registeredGuest = await _repo.CreateGuest(guest);
return Created($"/guest/{registeredGuest.Email}", registeredGuest);
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred when trying to register a new guest");

return BadRequest("Invalid guest data");
Comment on lines +36 to +40
}
}
}
}
33 changes: 27 additions & 6 deletions api/Controllers/ReservationController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Models;
using Models.Errors;
Expand All @@ -9,14 +10,16 @@ namespace Controllers
public class ReservationController : Controller
{
private ReservationRepository _repo { get; set; }
private ILogger<ReservationController> Logger { get; set; }

public ReservationController(ReservationRepository reservationRepository)
public ReservationController(ReservationRepository reservationRepository, ILogger<ReservationController> logger)
{
_repo = reservationRepository;
Logger = logger;
}

[HttpGet, Produces("application/json"), Route("")]
public async Task<ActionResult<Reservation>> GetReservations()
public async Task<ActionResult<IEnumerable<Reservation>>> GetReservations()
{
var reservations = await _repo.GetReservations();

Expand All @@ -37,6 +40,21 @@ public async Task<ActionResult<Reservation>> GetRoom(Guid reservationId)
}
}

[HttpGet, Produces("application/json"), Route("room/{roomNumber}")]
public async Task<ActionResult<IEnumerable<Reservation>>> GetRoomReservations(string roomNumber)
{
var reservations = await _repo.GetRoomReservations(roomNumber);

return Json(reservations);
}

[HttpGet, Produces("application/json"), Route("upcoming"), Authorize]
public async Task<ActionResult<IEnumerable<Reservation>>> GetUpcomingReservations()
{
var reservations = await _repo.GetUpcomingReservations();
return Json(reservations);
}

/// <summary>
/// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s)
/// </summary>
Expand All @@ -56,13 +74,16 @@ [FromBody] Reservation newBooking
try
{
var createdReservation = await _repo.CreateReservation(newBooking);
return Created($"/reservation/${createdReservation.Id}", createdReservation);
return Created($"/reservation/{createdReservation.Id}", createdReservation);
}
catch (ReservationConflictException ex)
{
Logger.LogWarning(ex, "A reservation conflict occurred when trying to book a reservation");
return Conflict("Invalid reservation, dates collide with another booking");
}
catch (Exception ex)
{
Console.WriteLine("An error occured when trying to book a reservation:");
Console.WriteLine(ex.ToString());

Logger.LogError(ex, "An error occurred when trying to book a reservation");
return BadRequest("Invalid reservation");
}
}
Expand Down
57 changes: 15 additions & 42 deletions api/Controllers/StaffController.cs
Original file line number Diff line number Diff line change
@@ -1,68 +1,41 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace Controllers
{
[Route("staff")]
public class StaffController : Controller
{
private IConfiguration Config { get; set; }
private ILogger<StaffController> Logger { get; set; }

public StaffController(IConfiguration config)
public StaffController(IConfiguration config, ILogger<StaffController> logger)
{
Config = config;
Logger = logger;
}

/// <summary>
/// Checks if the request is from a staff member, if not returns true and a 403 result
/// </summary>
/// <param name="request"></param>
private bool IsNotStaff(HttpRequest request, out IActionResult? result)
{
// TODO explore UseAuthentication
request.Cookies.TryGetValue("access", out string? accessValue);

if (accessValue == null || accessValue == "0")
{
result = StatusCode(403);
return true;
}

result = null;
return false;
}

[HttpGet, Route("login")]
public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode)
[HttpPost, Route("login")]
public async Task<IActionResult> CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode)
{
var configuredSecret = Config.GetValue<string>("staffAccessCode");
if (configuredSecret != accessCode)
{
// don't set cookie, don't indicate anything
return NoContent();
Logger.LogWarning("Unauthorised access attempt");
return Unauthorized();
}
Response.Cookies.Append(
"access",
"1",
new CookieOptions
// TODO evaluate cookie options & auth mechanism for best security practices
{
IsEssential = true,
SameSite = SameSiteMode.Strict,
HttpOnly = true,
Secure = true
}
);

var claimsIdentity = new ClaimsIdentity("StaffCookies");
await HttpContext.SignInAsync("StaffCookies", new ClaimsPrincipal(claimsIdentity));

return NoContent();
}

[HttpGet, Route("check")]
[HttpGet, Route("check"), Authorize]
public IActionResult CheckCookie()
{
if (IsNotStaff(Request, out IActionResult? result))
{
return result!;
}

return Ok("Authorized");
}
}
Expand Down
15 changes: 15 additions & 0 deletions api/Db/GuidTypeHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Dapper;
using System.Data;

namespace Db;

public class GuidTypeHandler : SqlMapper.TypeHandler<Guid>
{
public override Guid Parse(object value) => Guid.Parse(value.ToString()!);

public override void SetValue(IDbDataParameter parameter, Guid value)
{
parameter.Value = value.ToString();
parameter.DbType = DbType.String;
}
}
6 changes: 5 additions & 1 deletion api/Db/Setup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public static class Setup
/// </summary>
public static async void EnsureDb(IServiceScope scope)
{
// Register custom handlers
SqlMapper.AddTypeHandler(new GuidTypeHandler());

using var db = scope.ServiceProvider.GetRequiredService<SqliteConnection>();

// SQLite WAL (write-ahead log) go brrrr
Expand All @@ -22,7 +25,8 @@ await db.ExecuteAsync(
$@"
CREATE TABLE IF NOT EXISTS Guests (
{nameof(Guest.Email)} TEXT PRIMARY KEY NOT NULL,
{nameof(Guest.Name)} TEXT NOT NULL
{nameof(Guest.Name)} TEXT NOT NULL,
{nameof(Guest.Surname)} TEXT NULL
);
Comment on lines 25 to 30
"
);
Expand Down
8 changes: 8 additions & 0 deletions api/Models/Errors/ReservationConflictException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Models.Errors
{
public class ReservationConflictException : Exception
{
public ReservationConflictException(string message)
: base(message) { }
}
}
41 changes: 41 additions & 0 deletions api/Models/Validators/ReservationValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using FluentValidation;
using Models;

namespace api.Models.Validators;

public class ReservationValidator : AbstractValidator<Reservation>
{
public ReservationValidator()
{
RuleFor(r => r.Start)
.NotEmpty()
.GreaterThan(_ => DateTime.UtcNow.Date)
.WithMessage("Time travels have not been discovered... yet");

RuleFor(r => r.End)
.GreaterThanOrEqualTo(r => r.Start.AddDays(1))
.WithMessage("Minimum reservation allowed is 1 night")
.LessThanOrEqualTo(r => r.Start.AddDays(30))
.WithMessage("Maximum reservation allowed is 30 nights")
.When(r => r.Start != default);

RuleFor(r => r.GuestEmail)
.Cascade(CascadeMode.Stop)
.EmailAddress()
.WithMessage("Invalid email address")
.Matches(@"^[^@]+@[^@]+\.[^@]+$")
.WithMessage("The email domain is incomplete");

RuleFor(r => r.RoomNumber)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.Must(r => r[..1] != "-")
.WithMessage("Underground rooms are not allowed")
.Must(r => r[..1] != "0")
.WithMessage("Rooms must be placed on the 1st floor or above")
.Matches(@"^\d{3}$")
.WithMessage("Invalid room number. Please enter a number between 101 and 999 avoiding 00s")
.Must(r => r[1..] != "00")
.WithMessage("Invalid door '00'");
}
}
42 changes: 36 additions & 6 deletions api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System.Data;
using api.Models.Validators;
using Db;
using FluentValidation;
using Microsoft.Data.Sqlite;
using Repositories;
using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -12,15 +15,40 @@
builder.Configuration.GetConnectionString("ReservationsDb")
?? "Data Source=reservations.db;Cache=Shared";

Services.AddSingleton(_ => new SqliteConnection(connectionString));
Services.AddSingleton<IDbConnection>(sp => sp.GetRequiredService<SqliteConnection>());
Services.AddSingleton<GuestRepository>();
Services.AddSingleton<RoomRepository>();
Services.AddSingleton<ReservationRepository>();
Services.AddScoped(_ => new SqliteConnection(connectionString));
Services.AddScoped<IDbConnection>(sp => sp.GetRequiredService<SqliteConnection>());
Services.AddScoped<GuestRepository>();
Services.AddScoped<RoomRepository>();
Services.AddScoped<ReservationRepository>();
Services.AddMvc(opt =>
{
opt.EnableEndpointRouting = false;
});
Services
.AddFluentValidationAutoValidation()
.AddValidatorsFromAssemblyContaining<ReservationValidator>();

Services.AddAuthentication("StaffCookies")
.AddCookie("StaffCookies", options =>
{
options.Cookie.Name = "access";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.IsEssential = true;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = 403;
return Task.CompletedTask;
};
});
Services.AddAuthorization();

Services.AddCors();
Services.AddEndpointsApiExplorer();
Services.AddSwaggerGen();
Expand All @@ -43,8 +71,10 @@
}

app.UsePathBase("/api")
.UseMvc()
.UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())
.UseAuthentication()
.UseAuthorization()
.UseMvc()
.UseSwagger()
.UseSwaggerUI();
}
Expand Down
6 changes: 3 additions & 3 deletions api/Repositories/GuestRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ public async Task<Guest> GetGuestByEmail(string guestEmail)
return guest;
}

public Task<Guest> CreateGuest(Guest newGuest)
public async Task<Guest> CreateGuest(Guest newGuest)
{
return _db.QuerySingleAsync<Guest>(
"INSERT INTO Guests(Email, Name) Values(@Email, @Name) RETURNING *",
return await _db.QuerySingleAsync<Guest>(
"INSERT INTO Guests(Email, Name, Surname) Values(@Email, @Name, @Surname) RETURNING *",
newGuest
);
}
Expand Down
Loading