// Copyright (C) 2024 Vaughn Nugent
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
using System;
using System.Net;
using System.Linq;
using System.Buffers;
using System.Text.Json;
using System.Collections;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.EntityFrameworkCore;
using VNLib.Utils;
using VNLib.Utils.IO;
using VNLib.Utils.Memory;
using VNLib.Utils.Extensions;
using VNLib.Net.Http;
using VNLib.Plugins;
using VNLib.Plugins.Essentials;
using VNLib.Plugins.Essentials.Accounts;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Data.Extensions;
using VNLib.Plugins.Extensions.Validation;
using SimpleBookmark.Model;
namespace SimpleBookmark.Endpoints
{
[ConfigurationName("bm_endpoint")]
internal sealed class BookmarkEndpoint : ProtectedWebEndpoint
{
private static readonly IValidator BmValidator = BookmarkEntry.GetValidator();
private readonly BookmarkStore Bookmarks;
private readonly BookmarkStoreConfig BmConfig;
public BookmarkEndpoint(PluginBase plugin, IConfigScope config)
{
string? path = config.GetRequiredProperty("path", p => p.GetString()!);
InitPathAndLog(path, plugin.Log);
//Init bookmark store
Bookmarks = plugin.GetOrCreateSingleton();
//Load config
BmConfig = config.GetRequiredProperty("config", p => p.Deserialize()!);
}
///
protected override async ValueTask GetAsync(HttpEntity entity)
{
if (!entity.Session.CanRead())
{
WebMessage webm = new()
{
Result = "You do not have permissions to read records",
Success = false
};
return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
}
if (entity.QueryArgs.TryGetNonEmptyValue("id", out string? singleId))
{
//Try to get single record for the current user
BookmarkEntry? single = await Bookmarks.GetSingleUserRecordAsync(singleId, entity.Session.UserID);
//Return result
return single is null ? VfReturnType.NotFound : VirtualOkJson(entity, single);
}
if (entity.QueryArgs.ContainsKey("getTags"))
{
//Try to get all tags for the current user
string[] allTags = await Bookmarks.GetAllTagsForUserAsync(entity.Session.UserID, entity.EventCancellation);
//Return result
return VirtualOkJson(entity, allTags);
}
//See if count query
if(entity.QueryArgs.TryGetNonEmptyValue("count", out string? countS))
{
//Try to get count
long count = await Bookmarks.GetUserRecordCountAsync(entity.Session.UserID, entity.EventCancellation);
WebMessage webm = new ()
{
Result = count,
Success = true
};
//Return result
return VirtualOk(entity, webm);
}
/*
* Allow exporting the current user's bookmark collection as a
* netscape file for compatability.
*
* Future support for CSV file via an accept content type header
*/
if(entity.QueryArgs.ContainsKey("export"))
{
bool html = entity.Server.Accept.Contains("text/html");
bool csv = entity.Server.Accept.Contains("text/csv");
bool json = entity.Server.Accept.Contains("application/json");
if (html | csv | json)
{
//Get the collection of bookmarks
List list = Bookmarks.ListRental.Rent();
await Bookmarks.GetUserPageAsync(list, entity.Session.UserID, 0, (int)BmConfig.PerPersonQuota);
//Alloc memory stream for output
VnMemoryStream output = new(MemoryUtil.Shared, 16 * 1024, false);
try
{
//Write the bookmarks as a netscape file and return the file
if (html)
{
ImportExportUtil.ExportToNetscapeFile(list, output);
output.Seek(0, System.IO.SeekOrigin.Begin);
return VirtualClose(entity, HttpStatusCode.OK, ContentType.Html, output);
}
else if(csv)
{
ImportExportUtil.ExportAsCsv(list, output);
output.Seek(0, System.IO.SeekOrigin.Begin);
return VirtualClose(entity, HttpStatusCode.OK, ContentType.Csv, output);
}
else if(json)
{
ImportExportUtil.ExportAsJson(list, output);
output.Seek(0, System.IO.SeekOrigin.Begin);
return VirtualClose(entity, HttpStatusCode.OK, ContentType.Json, output);
}
}
catch
{
output.Dispose();
throw;
}
finally
{
list.TrimExcess();
Bookmarks.ListRental.Return(list);
}
}
return VirtualClose(entity, HttpStatusCode.NotAcceptable);
}
//Get query parameters
_ = entity.QueryArgs.TryGetNonEmptyValue("limit", out string? limitS);
_ = uint.TryParse(limitS, out uint limit);
//Clamp limit to max limit
limit = Math.Clamp(limit, BmConfig.DefaultLimit, BmConfig.MaxLimit);
//try to parse offset
_ = entity.QueryArgs.TryGetNonEmptyValue("page", out string? offsetS);
_ = uint.TryParse(offsetS, out uint offset);
//Get any query arguments
if(entity.QueryArgs.TryGetNonEmptyValue("q", out string? query))
{
//Replace percent encoding with spaces
query = query.Replace('+', ' ');
}
string[] tags = Array.Empty();
//Get tags
if (entity.QueryArgs.TryGetNonEmptyValue("t", out string? tagsS))
{
//Split tags at spaces and remove empty entries
tags = tagsS.Split('+')
.Where(static t => !string.IsNullOrWhiteSpace(t))
.ToArray();
}
//Get bookmarks
BookmarkEntry[] bookmarks = await Bookmarks.SearchBookmarksAsync(
entity.Session.UserID,
query,
tags,
(int)limit,
(int)offset,
entity.EventCancellation
);
//Return result
return VirtualOkJson(entity, bookmarks);
}
///
protected override async ValueTask PostAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to create records"))
{
return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
}
//try to get the update from the request body
BookmarkEntry? newBookmark = await entity.GetJsonFromFileAsync();
if (webm.Assert(newBookmark != null, "No data was provided"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
//Remove any user id from the update
newBookmark.UserId = null;
if (!BmValidator.Validate(newBookmark, webm))
{
return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
/*
* Add the new entry to the database if the user is below their quota
* and the entry does not already exist by the desired url.
*/
int result = await Bookmarks.AddSingleIfNotExists(
entity.Session.UserID,
newBookmark,
entity.RequestedTimeUtc.DateTime,
BmConfig.PerPersonQuota,
entity.EventCancellation
);
if (webm.Assert(result > -1, "You have reached your bookmark quota"))
{
return VirtualOk(entity, webm);
}
if (webm.Assert(result > 0, "Bookmark with the same url alreay exists"))
{
return VirtualOk(entity, webm);
}
webm.Result = "Successfully created bookmark";
webm.Success = true;
return VirtualClose(entity, webm, HttpStatusCode.Created);
}
///
protected override async ValueTask PatchAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update records"))
{
return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
}
//try to get the update from the request body
BookmarkEntry? update = await entity.GetJsonFromFileAsync();
if (webm.Assert(update != null, "No data was provided"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
if (webm.Assert(update!.Id != null, "The bookmark object is malformatted for this request"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
//Remove any user id from the update
update.UserId = null;
if (!BmValidator.Validate(update, webm))
{
return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
}
//Try to update the record
ERRNO result = await Bookmarks.UpdateUserRecordAsync(update, entity.Session.UserID, entity.EventCancellation);
if (webm.Assert(result > 0, "Failed to update existing record"))
{
return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
webm.Result = "Successfully updated bookmark";
webm.Success = true;
return VirtualClose(entity, webm, HttpStatusCode.OK);
}
/*
* PUT method is only used for bulk uploads
*/
///
protected override async ValueTask PutAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update records"))
{
return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
}
//See if the user wants to fail on invalid records
bool failOnInvalid = entity.QueryArgs.ContainsKey("failOnInvalid");
//try to get the update from the request body
BookmarkEntry[]? batch = await entity.GetJsonFromFileAsync();
if (webm.Assert(batch != null, "No data was provided"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
//filter out any null entries
IEnumerable sanitized = batch.Where(static b => b != null);
if (failOnInvalid)
{
//Get any invalid entires and create a validation result
BookmarkError[] invalidEntires = sanitized.Select(static b =>
{
ValidationResult result = BmValidator.Validate(b);
if(result.IsValid)
{
return null;
}
return new BookmarkError()
{
Errors = result.GetErrorsAsCollection(),
Subject = b
};
})
.Where(static b => b != null)
.ToArray()!;
//At least one error
if(invalidEntires.Length > 0)
{
//Notify the user of the invalid entires
BatchUploadResult res = new()
{
Errors = invalidEntires
};
webm.Result = res;
return VirtualOk(entity, webm);
}
}
else
{
//Remove any invalid entires
sanitized = sanitized.Where(static b => BmValidator.Validate(b).IsValid);
}
try
{
//Try to update the records
ERRNO result = await Bookmarks.AddBulkAsync(sanitized, entity.Session.UserID, entity.RequestedTimeUtc, entity.EventCancellation);
webm.Result = $"Successfully added {result} of {batch.Length} bookmarks";
webm.Success = true;
return VirtualClose(entity, webm, HttpStatusCode.OK);
}
catch (DbUpdateException dbe) when(dbe.InnerException is not null)
{
//Set entire batch as an error
webm.Result = GetResultFromEntires(batch, dbe.InnerException.Message);
return VirtualOk(entity, webm);
}
}
///
protected override async ValueTask DeleteAsync(HttpEntity entity)
{
ValErrWebMessage webm = new();
if (webm.Assert(entity.Session.CanDelete(), "You do not have permissions to delete records"))
{
return VirtualClose(entity, webm, HttpStatusCode.Forbidden);
}
if(!entity.QueryArgs.TryGetNonEmptyValue("id", out string? deleteId))
{
webm.Result = "No id was provided";
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
//Try to delete the record
ERRNO result = await Bookmarks.DeleteUserRecordAsync(deleteId, entity.Session.UserID);
if (webm.Assert(result > 0, "Requested bookmark does not exist"))
{
return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
webm.Result = "Successfully deleted bookmark";
webm.Success = true;
return VirtualClose(entity, webm, HttpStatusCode.OK);
}
private static BatchUploadResult GetResultFromEntires(IEnumerable errors, string message)
{
BookmarkError[] invalidEntires = errors.Select(e => new BookmarkError
{
Errors = new object[] { new ValidationFailure(string.Empty, message) },
Subject = e
}).ToArray();
return new BatchUploadResult()
{
Errors = invalidEntires,
Message = message
};
}
sealed class BatchUploadResult
{
[JsonPropertyName("invalid")]
public BookmarkError[]? Errors { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
sealed class BookmarkError
{
[JsonPropertyName("errors")]
public ICollection? Errors { get; set; }
[JsonPropertyName("subject")]
public BookmarkEntry? Subject { get; set; }
}
}
}