diff options
Diffstat (limited to 'src/server')
57 files changed, 1703 insertions, 0 deletions
diff --git a/src/server/Api/Internal/Account/CreateSessionRequest.cs b/src/server/Api/Internal/Account/CreateSessionRequest.cs new file mode 100644 index 0000000..24c28b4 --- /dev/null +++ b/src/server/Api/Internal/Account/CreateSessionRequest.cs @@ -0,0 +1,8 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class CreateSessionRequest +{ + public string Username { get; set; } + public string Password { get; set; } + public bool Persist { get; set; } +} diff --git a/src/server/Api/Internal/Account/CreateSessionRoute.cs b/src/server/Api/Internal/Account/CreateSessionRoute.cs new file mode 100644 index 0000000..09e05b6 --- /dev/null +++ b/src/server/Api/Internal/Account/CreateSessionRoute.cs @@ -0,0 +1,41 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class CreateSessionRoute : RouteBaseInternalAsync.WithRequest<CreateSessionRequest>.WithActionResult +{ + private readonly AppDbContext _context; + + public CreateSessionRoute(AppDbContext context) { + _context = context; + } + + [AllowAnonymous] + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpPost("~/v{version:apiVersion}/account/create-session")] + public override async Task<ActionResult> HandleAsync(CreateSessionRequest payload, CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(u => u.Username == payload.Username); + if (user == default || !user.VerifyPassword(payload.Password)) { + return BadRequest(new ErrorResult("Invalid username or password")); + } + + var claims = user.DefaultClaims(); + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var authenticationProperties = new AuthenticationProperties { + AllowRefresh = true, + IssuedUtc = DateTimeOffset.UtcNow, + }; + + if (payload.Persist) { + authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddYears(100); + authenticationProperties.IsPersistent = true; + } + + await HttpContext.SignInAsync(principal, authenticationProperties); + // Return new LoggedInInternalUser here, because it is not materialised in AppControllerBase yet. + return Ok(new LoggedInInternalUser { + Id = user.Id, + Username = user.Username + }); + } +} diff --git a/src/server/Api/Internal/Account/CreateTokenRequest.cs b/src/server/Api/Internal/Account/CreateTokenRequest.cs new file mode 100644 index 0000000..399bdfc --- /dev/null +++ b/src/server/Api/Internal/Account/CreateTokenRequest.cs @@ -0,0 +1,6 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class CreateTokenRequest +{ + public string Name { get; set; } +} diff --git a/src/server/Api/Internal/Account/CreateTokenRoute.cs b/src/server/Api/Internal/Account/CreateTokenRoute.cs new file mode 100644 index 0000000..ea0e01f --- /dev/null +++ b/src/server/Api/Internal/Account/CreateTokenRoute.cs @@ -0,0 +1,34 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class CreateTokenRoute : RouteBaseInternalSync.WithRequest<CreateTokenRequest>.WithActionResult +{ + private readonly AppDbContext _context; + + public CreateTokenRoute(AppDbContext context) { + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpPost("~/v{version:apiVersion}/account/create-token")] + public override ActionResult Handle(CreateTokenRequest request) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + return NotFound(new ErrorResult("User does not exist")); + } + + if (request.Name.IsNullOrWhiteSpace()) { + return BadRequest(new ErrorResult("Token name is required")); + } + + var token = new AccessToken { + Id = Guid.NewGuid(), + Name = request.Name, + User = user + }; + + _context.AccessTokens.Add(token); + _context.SaveChanges(); + return Ok(token); + } +} diff --git a/src/server/Api/Internal/Account/DeleteTokenRoute.cs b/src/server/Api/Internal/Account/DeleteTokenRoute.cs new file mode 100644 index 0000000..f423b6f --- /dev/null +++ b/src/server/Api/Internal/Account/DeleteTokenRoute.cs @@ -0,0 +1,24 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class DeleteTokenRoute : RouteBaseInternalSync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + + public DeleteTokenRoute(AppDbContext context) { + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpDelete("~/v{version:apiVersion}/account/delete-token")] + public override ActionResult Handle(Guid id) { + var token = _context.AccessTokens.SingleOrDefault(c => c.Id == id); + if (token == default) { + return NotFound(); + } + + _context.AccessTokens.Remove(token); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/src/server/Api/Internal/Account/EndSessionRoute.cs b/src/server/Api/Internal/Account/EndSessionRoute.cs new file mode 100644 index 0000000..4f32168 --- /dev/null +++ b/src/server/Api/Internal/Account/EndSessionRoute.cs @@ -0,0 +1,13 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class EndSessionRoute : RouteBaseInternalAsync.WithoutRequest.WithActionResult +{ + [AllowAnonymous] + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpGet("~/v{version:apiVersion}/account/end-session")] + public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) { + await HttpContext.SignOutAsync(); + return Ok(); + } +} diff --git a/src/server/Api/Internal/Account/GetArchiveRoute.cs b/src/server/Api/Internal/Account/GetArchiveRoute.cs new file mode 100644 index 0000000..5dc006e --- /dev/null +++ b/src/server/Api/Internal/Account/GetArchiveRoute.cs @@ -0,0 +1,36 @@ +using IOL.BookmarkThing.Server.Api.Internal.Dtos; + +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class GetArchiveRoute : RouteBaseInternalSync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public GetArchiveRoute(AppDbContext context) { + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpGet("~/v{version:apiVersion}/account/archive")] + public override ActionResult Handle() { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + return NotFound(); + } + + var entries = _context.Entries.Where(c => c.UserId == user.Id); + var archive = new UserArchiveDto() { + Created = DateTime.UtcNow, + User = new UserArchiveDto.UserArchiveUser(user), + Entries = entries.Select(c => new UserArchiveDto.UserArchiveEntry(c)).ToList() + }; + var jsonOptions = new JsonSerializerOptions { + WriteIndented = true + }; + var archiveBytes = JsonSerializer.SerializeToUtf8Bytes(archive, jsonOptions); + return File(archiveBytes, + "application/json", + "bookmark-thing-archive-" + user.Username + "-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json"); + } +} diff --git a/src/server/Api/Internal/Account/GetProfileDataRoute.cs b/src/server/Api/Internal/Account/GetProfileDataRoute.cs new file mode 100644 index 0000000..adf1cba --- /dev/null +++ b/src/server/Api/Internal/Account/GetProfileDataRoute.cs @@ -0,0 +1,11 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class GetProfileDataRoute : RouteBaseInternalSync.WithoutRequest.WithActionResult<LoggedInInternalUser> +{ + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpGet("~/v{version:apiVersion}/account/profile-data")] + public override ActionResult<LoggedInInternalUser> Handle() { + return Ok(LoggedInUser); + } +} diff --git a/src/server/Api/Internal/Account/GetTokensRoute.cs b/src/server/Api/Internal/Account/GetTokensRoute.cs new file mode 100644 index 0000000..7e87bc7 --- /dev/null +++ b/src/server/Api/Internal/Account/GetTokensRoute.cs @@ -0,0 +1,17 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class GetTokensRoute : RouteBaseInternalSync.WithoutRequest.WithResult<ActionResult<IList<AccessToken>>> +{ + private readonly AppDbContext _context; + + public GetTokensRoute(AppDbContext context) { + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpGet("~/v{version:apiVersion}/account/tokens")] + public override ActionResult<IList<AccessToken>> Handle() { + return Ok(_context.AccessTokens.Where(c => c.User.Id == LoggedInUser.Id)); + } +} diff --git a/src/server/Api/Internal/Account/UpdatePasswordRequest.cs b/src/server/Api/Internal/Account/UpdatePasswordRequest.cs new file mode 100644 index 0000000..1773bb0 --- /dev/null +++ b/src/server/Api/Internal/Account/UpdatePasswordRequest.cs @@ -0,0 +1,6 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class UpdatePasswordRequest +{ + public string NewPassword { get; set; } +} diff --git a/src/server/Api/Internal/Account/UpdatePasswordRoute.cs b/src/server/Api/Internal/Account/UpdatePasswordRoute.cs new file mode 100644 index 0000000..d06e850 --- /dev/null +++ b/src/server/Api/Internal/Account/UpdatePasswordRoute.cs @@ -0,0 +1,35 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Account; + +public class UpdatePasswordRoute : RouteBaseInternalSync.WithRequest<UpdatePasswordRequest>.WithActionResult +{ + private readonly AppDbContext _context; + + public UpdatePasswordRoute(AppDbContext context) { + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpPost("~/v{version:apiVersion}/account/update-password")] + public override ActionResult Handle(UpdatePasswordRequest payload) { + if (payload.NewPassword.IsNullOrWhiteSpace()) { + return BadRequest(new ErrorResult("Invalid request", + "The new password field is required")); + } + + if (payload.NewPassword.Length < 6) { + return BadRequest(new ErrorResult("Invalid request", + "The new password must contain atleast 6 characters")); + } + + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + HttpContext.SignOutAsync(); + return StatusCode(403); + } + + user.HashAndSetPassword(payload.NewPassword); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/src/server/Api/Internal/BaseInternalRoute.cs b/src/server/Api/Internal/BaseInternalRoute.cs new file mode 100644 index 0000000..2f92c8e --- /dev/null +++ b/src/server/Api/Internal/BaseInternalRoute.cs @@ -0,0 +1,15 @@ +namespace IOL.BookmarkThing.Server.Api.Internal; + +/// <inheritdoc /> +[Authorize] +[ApiController] +public class BaseInternalRoute : ControllerBase +{ + /// <summary> + /// User data for the currently logged on user. + /// </summary> + protected LoggedInInternalUser LoggedInUser => new() { + Username = User.Identity?.Name, + Id = User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value.ToGuid() ?? default + }; +} diff --git a/src/server/Api/Internal/Dtos/SiteReportDto.cs b/src/server/Api/Internal/Dtos/SiteReportDto.cs new file mode 100644 index 0000000..9c4aba8 --- /dev/null +++ b/src/server/Api/Internal/Dtos/SiteReportDto.cs @@ -0,0 +1,8 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Dtos; + +public class SiteReportDto +{ + public string Description { get; set; } + public bool Duplicate { get; set; } + public bool Unreachable { get; set; } +} diff --git a/src/server/Api/Internal/Dtos/UserArchiveDto.cs b/src/server/Api/Internal/Dtos/UserArchiveDto.cs new file mode 100644 index 0000000..ec691b3 --- /dev/null +++ b/src/server/Api/Internal/Dtos/UserArchiveDto.cs @@ -0,0 +1,32 @@ +namespace IOL.BookmarkThing.Server.Api.Internal.Dtos; + +public class UserArchiveDto +{ + public DateTime Created { get; set; } + public UserArchiveUser User { get; set; } + public List<UserArchiveEntry> Entries { get; set; } + + public class UserArchiveUser + { + public UserArchiveUser(User user) { + Created = user.Created; + Username = user.Username; + } + + public DateTime Created { get; set; } + public string Username { get; set; } + } + + public class UserArchiveEntry + { + public UserArchiveEntry(Entry entry) { + Url = entry.Url; + Description = entry.Description; + Tags = entry.Tags; + } + + public Uri Url { get; set; } + public string Description { get; set; } + public string Tags { get; set; } + } +} diff --git a/src/server/Api/Internal/GetSiteReportRequest.cs b/src/server/Api/Internal/GetSiteReportRequest.cs new file mode 100644 index 0000000..52fbfe8 --- /dev/null +++ b/src/server/Api/Internal/GetSiteReportRequest.cs @@ -0,0 +1,6 @@ +namespace IOL.BookmarkThing.Server.Api.Internal; + +public class GetSiteReportRequest +{ + public Uri Url { get; set; } +} diff --git a/src/server/Api/Internal/GetSiteReportRoute.cs b/src/server/Api/Internal/GetSiteReportRoute.cs new file mode 100644 index 0000000..58d6637 --- /dev/null +++ b/src/server/Api/Internal/GetSiteReportRoute.cs @@ -0,0 +1,54 @@ +using HtmlAgilityPack; +using IOL.BookmarkThing.Server.Api.Internal.Dtos; + +namespace IOL.BookmarkThing.Server.Api.Internal; + +public class GetSiteReportRoute : RouteBaseInternalAsync.WithRequest<GetSiteReportRequest>.WithActionResult +{ + private readonly HttpClient _client; + private readonly ILogger<GetSiteReportRequest> _logger; + private readonly AppDbContext _context; + + public GetSiteReportRoute(HttpClient client, ILogger<GetSiteReportRequest> logger, AppDbContext context) { + _client = client; + _logger = logger; + _context = context; + } + + [ApiVersionNeutral] + [ApiExplorerSettings(IgnoreApi = true)] + [HttpPost("~/v{version:apiVersion}/site-report")] + public override async Task<ActionResult> HandleAsync(GetSiteReportRequest request, CancellationToken cancellationToken = default) { + var report = new SiteReportDto(); + var exists = _context.Entries.Any(c => c.Url == request.Url && c.UserId == LoggedInUser.Id); + if (exists) { + report.Duplicate = true; + return Ok(report); + } + + try { + var http_request = await _client.GetAsync(request.Url, cancellationToken); + if (http_request.IsSuccessStatusCode) { + try { + var document = new HtmlDocument(); + document.Load(await http_request.Content.ReadAsStreamAsync(cancellationToken)); + var htmlNodes = document.DocumentNode.Descendants("meta") + .Where(p => p.GetAttributeValue("name", string.Empty).Equals("description", StringComparison.InvariantCultureIgnoreCase)); + report.Description = htmlNodes.FirstOrDefault()?.GetAttributeValue("content", string.Empty); + } catch (Exception e) { + _logger.LogWarning(e, "An error occured when parsing site for site report"); + } + } else { + report.Unreachable = true; + } + } catch (Exception e) { + if (e is HttpRequestException) { + report.Unreachable = true; + } + + _logger.LogError(e, "An error occured when getting external site for site report"); + } + + return Ok(report); + } +} diff --git a/src/server/Api/Internal/LoggedInInternalUser.cs b/src/server/Api/Internal/LoggedInInternalUser.cs new file mode 100644 index 0000000..e08dd51 --- /dev/null +++ b/src/server/Api/Internal/LoggedInInternalUser.cs @@ -0,0 +1,7 @@ +namespace IOL.BookmarkThing.Server.Api.Internal; + +public class LoggedInInternalUser +{ + public Guid Id { get; set; } + public string Username { get; set; } +} diff --git a/src/server/Api/Internal/RouteBaseAsync.cs b/src/server/Api/Internal/RouteBaseAsync.cs new file mode 100644 index 0000000..b5ad709 --- /dev/null +++ b/src/server/Api/Internal/RouteBaseAsync.cs @@ -0,0 +1,73 @@ +namespace IOL.BookmarkThing.Server.Api.Internal; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseInternalAsync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseInternalRoute + { + public abstract Task<TResponse> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseInternalRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseInternalRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseInternalRoute + { + public abstract Task<ActionResult> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseInternalRoute + { + public abstract Task<TResponse> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseInternalRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseInternalRoute + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseInternalRoute + { + public abstract Task<ActionResult> HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/src/server/Api/Internal/RouteBaseSync.cs b/src/server/Api/Internal/RouteBaseSync.cs new file mode 100644 index 0000000..128a3a9 --- /dev/null +++ b/src/server/Api/Internal/RouteBaseSync.cs @@ -0,0 +1,53 @@ +namespace IOL.BookmarkThing.Server.Api.Internal; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseInternalSync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseInternalRoute + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseInternalRoute + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult<TResponse> : BaseInternalRoute + { + public abstract ActionResult<TResponse> Handle(TRequest request); + } + + public abstract class WithActionResult : BaseInternalRoute + { + public abstract ActionResult Handle(TRequest entry); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseInternalRoute + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseInternalRoute + { + public abstract void Handle(); + } + + public abstract class WithActionResult<TResponse> : BaseInternalRoute + { + public abstract ActionResult<TResponse> Handle(); + } + + public abstract class WithActionResult : BaseInternalRoute + { + public abstract ActionResult Handle(); + } + } +} diff --git a/src/server/Api/V1/ApiSpecV1.cs b/src/server/Api/V1/ApiSpecV1.cs new file mode 100644 index 0000000..c1d1cbf --- /dev/null +++ b/src/server/Api/V1/ApiSpecV1.cs @@ -0,0 +1,18 @@ +namespace IOL.BookmarkThing.Server.Api.V1; + +public static class ApiSpecV1 +{ + private const int MAJOR = 1; + private const int MINOR = 0; + public const string VERSION_STRING = "1.0"; + + public static ApiSpecDocument Document => new() { + Version = new ApiVersion(MAJOR, MINOR), + VersionName = VERSION_STRING, + SwaggerPath = $"/swagger/{VERSION_STRING}/swagger.json", + OpenApiInfo = new OpenApiInfo { + Title = Constants.API_NAME, + Version = VERSION_STRING + } + }; +} diff --git a/src/server/Api/V1/BaseV1Route.cs b/src/server/Api/V1/BaseV1Route.cs new file mode 100644 index 0000000..ba7d978 --- /dev/null +++ b/src/server/Api/V1/BaseV1Route.cs @@ -0,0 +1,17 @@ +namespace IOL.BookmarkThing.Server.Api.V1; + +/// <inheritdoc /> +[Authorize(AuthenticationSchemes = AuthSchemes)] +[ApiController] +public class BaseV1Route : ControllerBase +{ + private const string AuthSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + Constants.BASIC_AUTH_SCHEME; + + /// <summary> + /// User data for the currently logged on user. + /// </summary> + protected LoggedInV1User LoggedInUser => new() { + Username = User.Identity?.Name, + Id = User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value.ToGuid() ?? default + }; +} diff --git a/src/server/Api/V1/Entries/CreateEntryRequest.cs b/src/server/Api/V1/Entries/CreateEntryRequest.cs new file mode 100644 index 0000000..a9c5070 --- /dev/null +++ b/src/server/Api/V1/Entries/CreateEntryRequest.cs @@ -0,0 +1,27 @@ +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class CreateEntryRequest +{ + public Uri Url { get; set; } + public string Description { get; set; } + public string Tags { get; set; } + + public List<ErrorResult> GetErrors() { + var errors = new List<ErrorResult>(); + if (Url == default) { + errors.Add(new ErrorResult("Url is a required field")); + } + + return errors; + } + + public Entry AsDbEntity(Guid userId) { + return new Entry { + Id = Guid.NewGuid(), + UserId = userId, + Url = Url, + Description = Description, + Tags = Tags + }; + } +} diff --git a/src/server/Api/V1/Entries/CreateEntryRoute.cs b/src/server/Api/V1/Entries/CreateEntryRoute.cs new file mode 100644 index 0000000..ebe49fc --- /dev/null +++ b/src/server/Api/V1/Entries/CreateEntryRoute.cs @@ -0,0 +1,34 @@ +using IOL.BookmarkThing.Server.Api.V1.Entries.Dtos; + +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class CreateEntryRoute : RouteBaseV1Sync.WithRequest<CreateEntryRequest>.WithActionResult<EntryDto> +{ + private readonly AppDbContext _context; + + public CreateEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Create a new entry + /// </summary> + /// <param name="entry">The entry to create</param> + /// <response code="200">Entry created successfully</response> + /// <response code="400">Invalid entry</response> + [ProducesResponseType(typeof(EntryDto), 200)] + [ProducesResponseType(typeof(List<ErrorResult>), 400)] + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpPost("~/v{version:apiVersion}/entries/create")] + public override ActionResult<EntryDto> Handle(CreateEntryRequest entry) { + var errors = entry.GetErrors(); + if (errors.Count != 0) { + return BadRequest(errors); + } + + var dbEntry = entry.AsDbEntity(LoggedInUser.Id); + _context.Entries.Add(dbEntry); + _context.SaveChanges(); + return Ok(new EntryDto(dbEntry)); + } +} diff --git a/src/server/Api/V1/Entries/DeleteEntryRoute.cs b/src/server/Api/V1/Entries/DeleteEntryRoute.cs new file mode 100644 index 0000000..fc79049 --- /dev/null +++ b/src/server/Api/V1/Entries/DeleteEntryRoute.cs @@ -0,0 +1,30 @@ +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class DeleteEntryRoute : RouteBaseV1Sync.WithRequest<Guid>.WithActionResult +{ + private readonly AppDbContext _context; + + public DeleteEntryRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Delete a entry + /// </summary> + /// <param name="entryId">The guid id of the entry to delete</param> + /// <response code="200">Entry deleted successfully</response> + /// <response code="404">Entry not found</response> + [ProducesResponseType(typeof(ErrorResult), 404)] + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpDelete("~/v{version:apiVersion}/entries/{entryId:guid}")] + public override ActionResult Handle(Guid entryId) { + var entry = _context.Entries.SingleOrDefault(c => c.Id == entryId && c.UserId == LoggedInUser.Id); + if (entry == default) { + return NotFound(new ErrorResult("Entry does not exist")); + } + + _context.Remove(entry); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/src/server/Api/V1/Entries/Dtos/EntryDto.cs b/src/server/Api/V1/Entries/Dtos/EntryDto.cs new file mode 100644 index 0000000..5161373 --- /dev/null +++ b/src/server/Api/V1/Entries/Dtos/EntryDto.cs @@ -0,0 +1,16 @@ +namespace IOL.BookmarkThing.Server.Api.V1.Entries.Dtos; + +public class EntryDto +{ + public EntryDto(Entry dbEntry) { + Id = dbEntry.Id; + Url = dbEntry.Url; + Description = dbEntry.Description; + Tags = dbEntry.Tags; + } + + public Guid Id { get; set; } + public Uri Url { get; set; } + public string Description { get; set; } + public string Tags { get; set; } +} diff --git a/src/server/Api/V1/Entries/GetEntriesRoute.cs b/src/server/Api/V1/Entries/GetEntriesRoute.cs new file mode 100644 index 0000000..adadf01 --- /dev/null +++ b/src/server/Api/V1/Entries/GetEntriesRoute.cs @@ -0,0 +1,21 @@ +using IOL.BookmarkThing.Server.Api.V1.Entries.Dtos; + +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class GetEntriesRoute : RouteBaseV1Sync.WithoutRequest.WithActionResult<List<EntryDto>> +{ + private readonly AppDbContext _context; + + public GetEntriesRoute(AppDbContext context) { + _context = context; + } + + /// <summary> + /// Get all entries + /// </summary> + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpGet("~/v{version:apiVersion}/entries")] + public override ActionResult<List<EntryDto>> Handle() { + return Ok(_context.Entries.Where(c => c.UserId == LoggedInUser.Id).Select(c => new EntryDto(c))); + } +} diff --git a/src/server/Api/V1/Entries/UpdateEntryRequest.cs b/src/server/Api/V1/Entries/UpdateEntryRequest.cs new file mode 100644 index 0000000..819e2d2 --- /dev/null +++ b/src/server/Api/V1/Entries/UpdateEntryRequest.cs @@ -0,0 +1,18 @@ +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class UpdateEntryRequest +{ + public Guid Id { get; set; } + public Uri Url { get; set; } + public string Description { get; set; } + public string Tags { get; set; } + + public List<ErrorResult> GetErrors() { + var errors = new List<ErrorResult>(); + if (Url == default) { + errors.Add(new ErrorResult("Url is a required field")); + } + + return errors; + } +} diff --git a/src/server/Api/V1/Entries/UpdateEntryRoute.cs b/src/server/Api/V1/Entries/UpdateEntryRoute.cs new file mode 100644 index 0000000..96c60fe --- /dev/null +++ b/src/server/Api/V1/Entries/UpdateEntryRoute.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using IOL.BookmarkThing.Server.Api.V1.Entries.Dtos; + +namespace IOL.BookmarkThing.Server.Api.V1.Entries; + +public class UpdateEntryRoute : RouteBaseV1Sync.WithRequest<UpdateEntryRequest>.WithActionResult<EntryDto> +{ + private readonly AppDbContext _context; + + public UpdateEntryRoute(AppDbContext context) { + _context = context; + } + + + /// <summary> + /// Update an entry + /// </summary> + /// <param name="entryToUpdate">The entry to update</param> + /// <response code="200">Entry updated successfully</response> + /// <response code="400">Invalid entry</response> + [ProducesResponseType(typeof(EntryDto), 200)] + [ProducesResponseType(typeof(List<ErrorResult>), 400)] + [ProducesResponseType(typeof(ErrorResult), 404)] + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpPost("~/v{version:apiVersion}/entries/update")] + public override ActionResult<EntryDto> Handle(UpdateEntryRequest entryToUpdate) { + var entry = _context.Entries.SingleOrDefault(c => c.Id == entryToUpdate.Id && c.UserId == LoggedInUser.Id); + if (entry == default) { + return NotFound(new ErrorResult("Entry does not exist")); + } + + var errors = entryToUpdate.GetErrors(); + if (errors.Count != 0) { + return BadRequest(errors); + } + + entry.Description = entry.Description; + entry.Tags = entry.Tags; + entry.Url = entry.Url; + + _context.Update(entry); + _context.SaveChanges(); + return Ok(new EntryDto(entry)); + } +} diff --git a/src/server/Api/V1/LoggedInV1User.cs b/src/server/Api/V1/LoggedInV1User.cs new file mode 100644 index 0000000..8c9f67a --- /dev/null +++ b/src/server/Api/V1/LoggedInV1User.cs @@ -0,0 +1,7 @@ +namespace IOL.BookmarkThing.Server.Api.V1; + +public class LoggedInV1User +{ + public Guid Id { get; set; } + public string Username { get; set; } +} diff --git a/src/server/Api/V1/RouteBaseV1Async.cs b/src/server/Api/V1/RouteBaseV1Async.cs new file mode 100644 index 0000000..d86bc7c --- /dev/null +++ b/src/server/Api/V1/RouteBaseV1Async.cs @@ -0,0 +1,73 @@ +namespace IOL.BookmarkThing.Server.Api.V1; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseV1Async +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseV1Route + { + public abstract Task<TResponse> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseV1Route + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseV1Route + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseV1Route + { + public abstract Task<ActionResult> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseV1Route + { + public abstract Task<TResponse> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseV1Route + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult<TResponse> : BaseV1Route + { + public abstract Task<ActionResult<TResponse>> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseV1Route + { + public abstract Task<ActionResult> HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/src/server/Api/V1/RouteBaseV1Sync.cs b/src/server/Api/V1/RouteBaseV1Sync.cs new file mode 100644 index 0000000..0bcc0ed --- /dev/null +++ b/src/server/Api/V1/RouteBaseV1Sync.cs @@ -0,0 +1,53 @@ +namespace IOL.BookmarkThing.Server.Api.V1; + +/// <summary> +/// A base class for an endpoint that accepts parameters. +/// </summary> +public static class RouteBaseV1Sync +{ + public static class WithRequest<TRequest> + { + public abstract class WithResult<TResponse> : BaseV1Route + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseV1Route + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult<TResponse> : BaseV1Route + { + public abstract ActionResult<TResponse> Handle(TRequest request); + } + + public abstract class WithActionResult : BaseV1Route + { + public abstract ActionResult Handle(TRequest entry); + } + } + + public static class WithoutRequest + { + public abstract class WithResult<TResponse> : BaseV1Route + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseV1Route + { + public abstract void Handle(); + } + + public abstract class WithActionResult<TResponse> : BaseV1Route + { + public abstract ActionResult<TResponse> Handle(); + } + + public abstract class WithActionResult : BaseV1Route + { + public abstract ActionResult Handle(); + } + } +} diff --git a/src/server/GlobalUsings.cs b/src/server/GlobalUsings.cs new file mode 100644 index 0000000..882de1c --- /dev/null +++ b/src/server/GlobalUsings.cs @@ -0,0 +1,23 @@ +global using System.Security.Claims; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using Swashbuckle.AspNetCore.SwaggerGen; +global using Serilog; +global using Microsoft.AspNetCore.Mvc.Versioning; +global using IOL.Helpers; +global using IOL.BookmarkThing.Server.Models.General; +global using IOL.BookmarkThing.Server.Models.Result; +global using IOL.BookmarkThing.Server.Models.Database; +global using IOL.BookmarkThing.Server.Api; +global using IOL.BookmarkThing.Server.StaticData; +global using IOL.BookmarkThing.Server.Utilities; +global using IOL.BookmarkThing.Server.Api.V1; +global using Microsoft.OpenApi.Models; +global using Microsoft.AspNetCore.Authentication; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Authentication.Cookies; +global using Microsoft.AspNetCore.DataProtection; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.AspNetCore.Mvc.ApiExplorer; +global using Microsoft.AspNetCore.Mvc.Controllers; diff --git a/src/server/IOL.BookmarkThing.Server.csproj b/src/server/IOL.BookmarkThing.Server.csproj new file mode 100644 index 0000000..e68b37e --- /dev/null +++ b/src/server/IOL.BookmarkThing.Server.csproj @@ -0,0 +1,38 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <UserSecretsId>29e502ec-7a83-4028-bc69-3cfcdb827037</UserSecretsId> + <ImplicitUsings>true</ImplicitUsings> + <Nullable>disable</Nullable> + <NoWarn>CS1591</NoWarn> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <DocumentationFile>bin\Debug\net6.0\IOL.BookmarkThing.Server.xml</DocumentationFile> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Release' "> + <DocumentationFile>bin\Release\net6.0\IOL.BookmarkThing.Server.xml</DocumentationFile> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="EFCore.NamingConventions" Version="6.0.0" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.40" /> + <PackageReference Include="IOL.Helpers" Version="1.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.2" /> + <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> + <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.2.3" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="AppData\data-protection-keys" /> + </ItemGroup> +</Project> diff --git a/src/server/Migrations/20210627082118_Initial_Migration.Designer.cs b/src/server/Migrations/20210627082118_Initial_Migration.Designer.cs new file mode 100644 index 0000000..b6002fd --- /dev/null +++ b/src/server/Migrations/20210627082118_Initial_Migration.Designer.cs @@ -0,0 +1,51 @@ +// <auto-generated /> +using System; +using IOL.BookmarkThing.Server.Models.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace IOL.BookmarkThing.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20210627082118_Initial_Migration")] + partial class Initial_Migration + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/server/Migrations/20210627082118_Initial_Migration.cs b/src/server/Migrations/20210627082118_Initial_Migration.cs new file mode 100644 index 0000000..20f5c4a --- /dev/null +++ b/src/server/Migrations/20210627082118_Initial_Migration.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace IOL.BookmarkThing.Server.Migrations +{ + public partial class Initial_Migration : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + username = table.Column<string>(type: "text", nullable: true), + password = table.Column<string>(type: "text", nullable: true), + created = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/src/server/Migrations/20211217163105_Entries.Designer.cs b/src/server/Migrations/20211217163105_Entries.Designer.cs new file mode 100644 index 0000000..952c9c0 --- /dev/null +++ b/src/server/Migrations/20211217163105_Entries.Designer.cs @@ -0,0 +1,84 @@ +// <auto-generated /> +using System; +using IOL.BookmarkThing.Server.Models.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace IOL.BookmarkThing.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20211217163105_Entries")] + partial class Entries + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.Entry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<string>("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property<string>("Url") + .HasColumnType("text") + .HasColumnName("url"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_entries"); + + b.ToTable("entries"); + }); + + modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/server/Migrations/20211217163105_Entries.cs b/src/server/Migrations/20211217163105_Entries.cs new file mode 100644 index 0000000..e8acecf --- /dev/null +++ b/src/server/Migrations/20211217163105_Entries.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace IOL.BookmarkThing.Server.Migrations +{ + public partial class Entries : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "entries", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + user_id = table.Column<Guid>(type: "uuid", nullable: false), + url = table.Column<string>(type: "text", nullable: true), + description = table.Column<string>(type: "text", nullable: true), + tags = table.Column<string>(type: "text", nullable: true), + created = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_entries", x => x.id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "entries"); + } + } +} diff --git a/src/server/Migrations/AppDbContextModelSnapshot.cs b/src/server/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..5476b90 --- /dev/null +++ b/src/server/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// <auto-generated /> +using System; +using IOL.BookmarkThing.Server.Models.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace IOL.BookmarkThing.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.Entry", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<string>("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property<string>("Url") + .HasColumnType("text") + .HasColumnName("url"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_entries"); + + b.ToTable("entries", (string)null); + }); + + modelBuilder.Entity("IOL.BookmarkThing.Server.Data.Database.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTime>("Created") + .HasColumnType("timestamp without time zone") + .HasColumnName("created"); + + b.Property<string>("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property<string>("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/server/Models/Database/AccessToken.cs b/src/server/Models/Database/AccessToken.cs new file mode 100644 index 0000000..51ada27 --- /dev/null +++ b/src/server/Models/Database/AccessToken.cs @@ -0,0 +1,11 @@ +namespace IOL.BookmarkThing.Server.Models.Database; + +public class AccessToken : Base +{ + public User User { get; set; } + public string Name { get; set; } + + public string PublicId() { + return Convert.ToBase64String(Id.ToByteArray()); + } +} diff --git a/src/server/Models/Database/AppDbContext.cs b/src/server/Models/Database/AppDbContext.cs new file mode 100644 index 0000000..ae1f45b --- /dev/null +++ b/src/server/Models/Database/AppDbContext.cs @@ -0,0 +1,25 @@ +namespace IOL.BookmarkThing.Server.Models.Database; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } + public DbSet<User> Users { get; set; } + public DbSet<Entry> Entries { get; set; } + public DbSet<AccessToken> AccessTokens { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity<User>(e => { + e.ToTable("users"); + }); + + modelBuilder.Entity<Entry>(e => { + e.ToTable("entries"); + }); + + modelBuilder.Entity<AccessToken>(e => { + e.ToTable("access_tokens"); + }); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/server/Models/Database/Base.cs b/src/server/Models/Database/Base.cs new file mode 100644 index 0000000..e6d6646 --- /dev/null +++ b/src/server/Models/Database/Base.cs @@ -0,0 +1,11 @@ +namespace IOL.BookmarkThing.Server.Models.Database; + +public class Base +{ + public Base() { + Created = DateTime.UtcNow; + } + + public Guid Id { get; set; } + public DateTime Created { get; set; } +} diff --git a/src/server/Models/Database/Entry.cs b/src/server/Models/Database/Entry.cs new file mode 100644 index 0000000..a992a87 --- /dev/null +++ b/src/server/Models/Database/Entry.cs @@ -0,0 +1,9 @@ +namespace IOL.BookmarkThing.Server.Models.Database; + +public class Entry : Base +{ + public Guid UserId { get; set; } + public Uri Url { get; set; } + public string Description { get; set; } + public string Tags { get; set; } +} diff --git a/src/server/Models/Database/User.cs b/src/server/Models/Database/User.cs new file mode 100644 index 0000000..1215f4e --- /dev/null +++ b/src/server/Models/Database/User.cs @@ -0,0 +1,24 @@ +namespace IOL.BookmarkThing.Server.Models.Database; + +public class User : Base +{ + public User(string username) => Username = username; + + public string Username { get; set; } + public string Password { get; set; } + + public void HashAndSetPassword(string password) { + Password = PasswordHelper.HashPassword(password); + } + + public bool VerifyPassword(string password) { + return PasswordHelper.Verify(password, Password); + } + + public IEnumerable<Claim> DefaultClaims() { + return new Claim[] { + new(ClaimTypes.NameIdentifier, Id.ToString()), + new(ClaimTypes.Name, Username), + }; + } +} diff --git a/src/server/Models/General/ApiSpecDocument.cs b/src/server/Models/General/ApiSpecDocument.cs new file mode 100644 index 0000000..5f5c9df --- /dev/null +++ b/src/server/Models/General/ApiSpecDocument.cs @@ -0,0 +1,9 @@ +namespace IOL.BookmarkThing.Server.Models.General; + +public class ApiSpecDocument +{ + public string VersionName { get; set; } + public string SwaggerPath { get; set; } + public ApiVersion Version { get; set; } + public OpenApiInfo OpenApiInfo { get; set; } +} diff --git a/src/server/Models/General/AppPath.cs b/src/server/Models/General/AppPath.cs new file mode 100644 index 0000000..063fafe --- /dev/null +++ b/src/server/Models/General/AppPath.cs @@ -0,0 +1,23 @@ +namespace IOL.BookmarkThing.Server.Models.General; + +public sealed record AppPath +{ + public string HostPath { get; init; } + public string WebPath { get; init; } + + public string GetHostPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(HostPath, filename); + } + + public string GetWebPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(WebPath, filename); + } +} diff --git a/src/server/Models/Result/ErrorResult.cs b/src/server/Models/Result/ErrorResult.cs new file mode 100644 index 0000000..64c5a28 --- /dev/null +++ b/src/server/Models/Result/ErrorResult.cs @@ -0,0 +1,12 @@ +namespace IOL.BookmarkThing.Server.Models.Result; + +public class ErrorResult +{ + public ErrorResult(string title = default, string text = default) { + Title = title; + Text = text; + } + + public string Title { get; set; } + public string Text { get; set; } +} diff --git a/src/server/Program.cs b/src/server/Program.cs new file mode 100644 index 0000000..206d22d --- /dev/null +++ b/src/server/Program.cs @@ -0,0 +1,31 @@ +namespace IOL.BookmarkThing.Server; + +public class Program +{ + public static int Main(string[] args) { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + try { + Log.Information("Starting web host"); + CreateHostBuilder(args).Build().Run(); + return 0; + } catch (Exception ex) { + Log.Fatal(ex, "Host terminated unexpectedly"); + return 1; + } finally { + Log.CloseAndFlush(); + } + } + + private static IHostBuilder CreateHostBuilder(string[] args) { + return Host.CreateDefaultBuilder(args) + .UseSerilog() + .ConfigureWebHostDefaults(webBuilder => { + webBuilder.UseKestrel(o => o.AddServerHeader = false); + webBuilder.UseStartup<Startup>(); + }); + } +} diff --git a/src/server/Properties/launchSettings.json b/src/server/Properties/launchSettings.json new file mode 100644 index 0000000..e494e11 --- /dev/null +++ b/src/server/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "IOL.BookmarkThing.Server": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": false, + "applicationUrl": "http://localhost:5003", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/server/Startup.cs b/src/server/Startup.cs new file mode 100644 index 0000000..8fd6955 --- /dev/null +++ b/src/server/Startup.cs @@ -0,0 +1,131 @@ +using System.Reflection; + +namespace IOL.BookmarkThing.Server; + +public class Startup +{ + public Startup(IConfiguration configuration, IWebHostEnvironment webHostEnvironment) { + Configuration = configuration; + WebHostEnvironment = webHostEnvironment; + } + + private IWebHostEnvironment WebHostEnvironment { get; } + private IConfiguration Configuration { get; } + + private string AppDatabaseConnectionString() { + var host = Configuration.GetValue<string>("DB_HOST"); + var port = Configuration.GetValue<string>("DB_PORT"); + var database = Configuration.GetValue<string>("DB_NAME"); + var user = Configuration.GetValue<string>("DB_USER"); + var password = Configuration.GetValue<string>("DB_PASSWORD"); + return $"Server={host};Port={port};Database={database};User Id={user};Password={password}"; + } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) { + services.AddDataProtection() + .PersistKeysToFileSystem(new(AppPaths.DataProtectionKeys.HostPath)); + + if (WebHostEnvironment.IsDevelopment()) { + services.AddCors(); + } + + services.Configure(AppJsonSettings.Default); + + services.AddDbContext<AppDbContext>(options => { + options.UseNpgsql(AppDatabaseConnectionString(), + builder => { + builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + builder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), default); + }) + .UseSnakeCaseNamingConvention(); + if (WebHostEnvironment.IsDevelopment()) { + options.EnableSensitiveDataLogging(); + } + }); + + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => { + options.Cookie.Name = "bookmarkthing_session"; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.IsEssential = true; + options.SlidingExpiration = true; + options.Events.OnRedirectToAccessDenied = + options.Events.OnRedirectToLogin = c => { + c.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.FromResult<object>(null); + }; + }) + .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(Constants.BASIC_AUTH_SCHEME, default); + + services.AddLogging(); + services.AddHttpClient(); + services.AddControllers() + .AddJsonOptions(AppJsonSettings.Default); + + services.AddApiVersioning(options => { + options.ApiVersionReader = new UrlSegmentApiVersionReader(); + options.ReportApiVersions = true; + options.AssumeDefaultVersionWhenUnspecified = false; + }); + services.AddVersionedApiExplorer(options => { + options.SubstituteApiVersionInUrl = true; + }); + services.AddSwaggerGen(options => { + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, Assembly.GetExecutingAssembly().GetName().Name + ".xml")); + options.UseApiEndpoints(); + options.OperationFilter<SwaggerDefaultValues>(); + options.SwaggerDoc(ApiSpecV1.Document.VersionName, ApiSpecV1.Document.OpenApiInfo); + options.AddSecurityDefinition("Basic", + new OpenApiSecurityScheme { + Name = "Authorization", + Type = SecuritySchemeType.ApiKey, + Scheme = "Basic", + BearerFormat = "Custom", + In = ParameterLocation.Header, + Description = + "Enter your token in the text input below.\r\n\r\nExample: \"12345abcdef\"", + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement { + { + new OpenApiSecurityScheme { + Reference = new OpenApiReference { + Type = ReferenceType.SecurityScheme, + Id = "Basic" + } + }, + Array.Empty<string>() + } + }); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) { + if (WebHostEnvironment.IsDevelopment()) { + app.UseDeveloperExceptionPage(); + app.UseCors(x => x + .AllowAnyMethod() + .AllowAnyHeader() + .SetIsOriginAllowed(_ => true) // allow any origin + .AllowCredentials()); // allow credentials + } + + app.UseRouting(); + app.UseSerilogRequestLogging(); + app.UseStatusCodePages(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + app.UseSwagger(); + app.UseSwaggerUI(options => { + options.SwaggerEndpoint(ApiSpecV1.Document.SwaggerPath, ApiSpecV1.Document.VersionName); + options.DocumentTitle = Constants.API_NAME; + }); + } +} diff --git a/src/server/StartupTasks.cs b/src/server/StartupTasks.cs new file mode 100644 index 0000000..0284f34 --- /dev/null +++ b/src/server/StartupTasks.cs @@ -0,0 +1,30 @@ +namespace IOL.BookmarkThing.Server; + +public static class StartupTasks +{ + private static IEnumerable<string> PathsToEnsureCreated => new List<string> { + AppPaths.DataProtectionKeys.HostPath, + AppPaths.AppData.HostPath, + }; + + /// <summary> + /// Execute startup tasks. + /// </summary> + /// <param name="stoppingToken"></param> + /// <returns></returns> + public static Task ExecuteAsync() { + EnsureCreated(); + return Task.CompletedTask; + } + + private static void EnsureCreated() { + foreach (var path in PathsToEnsureCreated) { + if (path.IsNullOrWhiteSpace() || Directory.Exists(path)) { + continue; + } + + Directory.CreateDirectory(path!); + Console.WriteLine("EnsuredCreated: " + path); + } + } +} diff --git a/src/server/StaticData/AppJsonSettings.cs b/src/server/StaticData/AppJsonSettings.cs new file mode 100644 index 0000000..47c67a7 --- /dev/null +++ b/src/server/StaticData/AppJsonSettings.cs @@ -0,0 +1,11 @@ +namespace IOL.BookmarkThing.Server.StaticData; + +public static class AppJsonSettings +{ + public static Action<JsonOptions> Default { get; } = options => { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }; +} diff --git a/src/server/StaticData/AppPaths.cs b/src/server/StaticData/AppPaths.cs new file mode 100644 index 0000000..f7b5adc --- /dev/null +++ b/src/server/StaticData/AppPaths.cs @@ -0,0 +1,12 @@ +namespace IOL.BookmarkThing.Server.StaticData; + +public static class AppPaths +{ + public static AppPath AppData => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData") + }; + + public static AppPath DataProtectionKeys => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData", "data-protection-keys") + }; +} diff --git a/src/server/StaticData/Constants.cs b/src/server/StaticData/Constants.cs new file mode 100644 index 0000000..818004f --- /dev/null +++ b/src/server/StaticData/Constants.cs @@ -0,0 +1,7 @@ +namespace IOL.BookmarkThing.Server.StaticData; + +public static class Constants +{ + public const string API_NAME = "Bookmark API"; + public const string BASIC_AUTH_SCHEME = "BasicAuthenticationScheme"; +} diff --git a/src/server/Utilities/BasicAuthenticationHandler.cs b/src/server/Utilities/BasicAuthenticationHandler.cs new file mode 100644 index 0000000..7961b82 --- /dev/null +++ b/src/server/Utilities/BasicAuthenticationHandler.cs @@ -0,0 +1,49 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.Extensions.Options; + +namespace IOL.BookmarkThing.Server.Utilities; + +public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> +{ + private readonly AppDbContext _context; + + public BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, AppDbContext context) : + base(options, logger, encoder, clock) { + _context = context; + } + + protected override Task<AuthenticateResult> HandleAuthenticateAsync() { + var endpoint = Context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null) + return Task.FromResult(AuthenticateResult.NoResult()); + + if (!Request.Headers.ContainsKey("Authorization")) + return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); + + try { + var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + var credentialBytes = Convert.FromBase64String(authHeader.Parameter); + var token_is_guid = Guid.TryParse(Encoding.UTF8.GetString(credentialBytes), out var token_id); + if (token_is_guid) { + var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id); + if (token == default) { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + + var claims = token.User.DefaultClaims(); + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } catch { + return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); + } + } +} diff --git a/src/server/Utilities/ConfigurationExtensions.cs b/src/server/Utilities/ConfigurationExtensions.cs new file mode 100644 index 0000000..d42b117 --- /dev/null +++ b/src/server/Utilities/ConfigurationExtensions.cs @@ -0,0 +1,19 @@ +namespace IOL.BookmarkThing.Server.Utilities; + +public static class ConfigurationExtensions +{ + /// <summary> + /// Get the contents of AppData/version.txt. + /// </summary> + /// <param name="configuration"></param> + /// <returns></returns> + public static string GetVersion(this IConfiguration configuration) { + var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt"); + if (File.Exists(versionFilePath)) { + var versionText = File.ReadAllText(versionFilePath); + return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } + + return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT"); + } +} diff --git a/src/server/Utilities/SwaggerDefaultValues.cs b/src/server/Utilities/SwaggerDefaultValues.cs new file mode 100644 index 0000000..24855ae --- /dev/null +++ b/src/server/Utilities/SwaggerDefaultValues.cs @@ -0,0 +1,55 @@ +namespace IOL.BookmarkThing.Server.Utilities; + +/// <summary> +/// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter. +/// </summary> +/// <remarks>This <see cref="IOperationFilter"/> is only required due to bugs in the <see cref="SwaggerGenerator"/>. +/// Once they are fixed and published, this class can be removed.</remarks> +public class SwaggerDefaultValues : IOperationFilter +{ + /// <summary> + /// Applies the filter to the specified operation using the given context. + /// </summary> + /// <param name="operation">The operation to apply the filter to.</param> + /// <param name="context">The current operation filter context.</param> + public void Apply(OpenApiOperation operation, OperationFilterContext context) { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); + var response = operation.Responses[responseKey]; + + foreach (var contentType in response.Content.Keys) { + if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) { + response.Content.Remove(contentType); + } + } + } + + if (operation.Parameters == null) { + return; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach (var parameter in operation.Parameters) { + var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + + if (parameter.Description == null) { + parameter.Description = description.ModelMetadata?.Description; + } + + if (parameter.Schema.Default == null && description.DefaultValue != null) { + // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 + var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata.ModelType); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + } + + parameter.Required |= description.IsRequired; + } + } +} diff --git a/src/server/Utilities/SwaggerGenOptionsExtensions.cs b/src/server/Utilities/SwaggerGenOptionsExtensions.cs new file mode 100644 index 0000000..0203f77 --- /dev/null +++ b/src/server/Utilities/SwaggerGenOptionsExtensions.cs @@ -0,0 +1,37 @@ +namespace IOL.BookmarkThing.Server.Utilities; + +public static class SwaggerGenOptionsExtensions +{ + /// <summary> + /// Updates Swagger document to support ApiEndpoints.<br/><br/> + /// For controllers inherited from <see cref="BaseRoute"/>:<br/> + /// - Replaces action Tag with <c>[namespace]</c><br/> + /// </summary> + public static void UseApiEndpoints(this SwaggerGenOptions options) { + options.TagActionsBy(EndpointNamespaceOrDefault); + } + + private static IEnumerable<Type> GetBaseTypesAndThis(this Type type) { + var current = type; + while (current != null) { + yield return current; + current = current.BaseType; + } + } + + private static IList<string> EndpointNamespaceOrDefault(ApiDescription api) { + if (api.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) { + throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}"); + } + + if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(BaseV1Route))) { + return new[] { + actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last(), + }; + } + + return new[] { + actionDescriptor.ControllerName, + }; + } +} |
