summaryrefslogtreecommitdiffstats
path: root/src/server/Api
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/Api')
-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
31 files changed, 838 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();
+ }
+ }
+}