summaryrefslogtreecommitdiffstats
path: root/src/server
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-01-22 22:43:38 +0100
committerivarlovlie <git@ivarlovlie.no>2022-01-22 22:43:38 +0100
commit88110f536f9c3843ecf5016122e101f8a424af77 (patch)
treee8be4e77ccfb5ad37f49f89adad59ff12b4c85ea /src/server
downloadbookmark-thing-88110f536f9c3843ecf5016122e101f8a424af77.tar.xz
bookmark-thing-88110f536f9c3843ecf5016122e101f8a424af77.zip
Initial commit
Diffstat (limited to 'src/server')
-rw-r--r--src/server/Api/Internal/Account/CreateSessionRequest.cs8
-rw-r--r--src/server/Api/Internal/Account/CreateSessionRoute.cs41
-rw-r--r--src/server/Api/Internal/Account/CreateTokenRequest.cs6
-rw-r--r--src/server/Api/Internal/Account/CreateTokenRoute.cs34
-rw-r--r--src/server/Api/Internal/Account/DeleteTokenRoute.cs24
-rw-r--r--src/server/Api/Internal/Account/EndSessionRoute.cs13
-rw-r--r--src/server/Api/Internal/Account/GetArchiveRoute.cs36
-rw-r--r--src/server/Api/Internal/Account/GetProfileDataRoute.cs11
-rw-r--r--src/server/Api/Internal/Account/GetTokensRoute.cs17
-rw-r--r--src/server/Api/Internal/Account/UpdatePasswordRequest.cs6
-rw-r--r--src/server/Api/Internal/Account/UpdatePasswordRoute.cs35
-rw-r--r--src/server/Api/Internal/BaseInternalRoute.cs15
-rw-r--r--src/server/Api/Internal/Dtos/SiteReportDto.cs8
-rw-r--r--src/server/Api/Internal/Dtos/UserArchiveDto.cs32
-rw-r--r--src/server/Api/Internal/GetSiteReportRequest.cs6
-rw-r--r--src/server/Api/Internal/GetSiteReportRoute.cs54
-rw-r--r--src/server/Api/Internal/LoggedInInternalUser.cs7
-rw-r--r--src/server/Api/Internal/RouteBaseAsync.cs73
-rw-r--r--src/server/Api/Internal/RouteBaseSync.cs53
-rw-r--r--src/server/Api/V1/ApiSpecV1.cs18
-rw-r--r--src/server/Api/V1/BaseV1Route.cs17
-rw-r--r--src/server/Api/V1/Entries/CreateEntryRequest.cs27
-rw-r--r--src/server/Api/V1/Entries/CreateEntryRoute.cs34
-rw-r--r--src/server/Api/V1/Entries/DeleteEntryRoute.cs30
-rw-r--r--src/server/Api/V1/Entries/Dtos/EntryDto.cs16
-rw-r--r--src/server/Api/V1/Entries/GetEntriesRoute.cs21
-rw-r--r--src/server/Api/V1/Entries/UpdateEntryRequest.cs18
-rw-r--r--src/server/Api/V1/Entries/UpdateEntryRoute.cs45
-rw-r--r--src/server/Api/V1/LoggedInV1User.cs7
-rw-r--r--src/server/Api/V1/RouteBaseV1Async.cs73
-rw-r--r--src/server/Api/V1/RouteBaseV1Sync.cs53
-rw-r--r--src/server/GlobalUsings.cs23
-rw-r--r--src/server/IOL.BookmarkThing.Server.csproj38
-rw-r--r--src/server/Migrations/20210627082118_Initial_Migration.Designer.cs51
-rw-r--r--src/server/Migrations/20210627082118_Initial_Migration.cs31
-rw-r--r--src/server/Migrations/20211217163105_Entries.Designer.cs84
-rw-r--r--src/server/Migrations/20211217163105_Entries.cs33
-rw-r--r--src/server/Migrations/AppDbContextModelSnapshot.cs85
-rw-r--r--src/server/Models/Database/AccessToken.cs11
-rw-r--r--src/server/Models/Database/AppDbContext.cs25
-rw-r--r--src/server/Models/Database/Base.cs11
-rw-r--r--src/server/Models/Database/Entry.cs9
-rw-r--r--src/server/Models/Database/User.cs24
-rw-r--r--src/server/Models/General/ApiSpecDocument.cs9
-rw-r--r--src/server/Models/General/AppPath.cs23
-rw-r--r--src/server/Models/Result/ErrorResult.cs12
-rw-r--r--src/server/Program.cs31
-rw-r--r--src/server/Properties/launchSettings.json14
-rw-r--r--src/server/Startup.cs131
-rw-r--r--src/server/StartupTasks.cs30
-rw-r--r--src/server/StaticData/AppJsonSettings.cs11
-rw-r--r--src/server/StaticData/AppPaths.cs12
-rw-r--r--src/server/StaticData/Constants.cs7
-rw-r--r--src/server/Utilities/BasicAuthenticationHandler.cs49
-rw-r--r--src/server/Utilities/ConfigurationExtensions.cs19
-rw-r--r--src/server/Utilities/SwaggerDefaultValues.cs55
-rw-r--r--src/server/Utilities/SwaggerGenOptionsExtensions.cs37
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,
+ };
+ }
+}