diff --git a/JetHerald.sln b/JetHerald.sln
index 98b32d0..92d8822 100644
--- a/JetHerald.sln
+++ b/JetHerald.sln
@@ -1,9 +1,9 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.28307.539
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.32112.339
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JetHerald", "JetHerald\JetHerald.csproj", "{B48207B2-F0A8-4BD8-AF92-906D128EF152}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JetHerald", "JetHerald\JetHerald.csproj", "{B48207B2-F0A8-4BD8-AF92-906D128EF152}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/JetHerald/Authorization/FlightcheckHelpers.cs b/JetHerald/Authorization/FlightcheckHelpers.cs
new file mode 100644
index 0000000..fccf099
--- /dev/null
+++ b/JetHerald/Authorization/FlightcheckHelpers.cs
@@ -0,0 +1,28 @@
+using System.Reflection;
+
+namespace JetHerald.Authorization;
+public static class FlightcheckHelpers
+{
+ public static IEnumerable GetUsedPermissions(Type rootType)
+ {
+ var res = new HashSet();
+ var asm = Assembly.GetAssembly(rootType);
+ var types = asm.GetTypes();
+ var methods = types.SelectMany(t => t.GetMethods());
+
+ foreach (var t in types)
+ {
+ if (t.GetCustomAttribute() is PermissionAttribute perm)
+ res.Add(perm.Policy);
+ }
+
+ foreach (var t in methods)
+ {
+ if (t.GetCustomAttribute() is PermissionAttribute perm)
+ res.Add(perm.Policy);
+ }
+
+ return res.OrderBy(p => p);
+ }
+}
+
diff --git a/JetHerald/Authorization/Permission.cs b/JetHerald/Authorization/Permission.cs
new file mode 100644
index 0000000..f1b17a4
--- /dev/null
+++ b/JetHerald/Authorization/Permission.cs
@@ -0,0 +1,61 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+
+namespace JetHerald.Authorization;
+
+public static class Permissions
+{
+ public const string PolicyPrefix = "permission://";
+ public const string ClaimId = "Permission";
+}
+
+public class PermissionAttribute : AuthorizeAttribute
+{
+ public PermissionAttribute(string permission)
+ => Policy = Permissions.PolicyPrefix + permission;
+}
+
+public class PermissionRequirement : IAuthorizationRequirement
+{
+ public string Permission { get; }
+ public PermissionRequirement(string permtext)
+ => Permission = permtext[Permissions.PolicyPrefix.Length..];
+}
+
+public class PermissionHandler : AuthorizationHandler
+{
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ PermissionRequirement requirement)
+ {
+ var permissions = context.User.FindFirstValue(Permissions.ClaimId);
+ if (PermissionParser.ProvePermission(permissions, requirement.Permission))
+ context.Succeed(requirement);
+ else
+ context.Fail();
+ return Task.CompletedTask;
+ }
+}
+
+public class PermissionPolicyProvider : IAuthorizationPolicyProvider
+{
+ public DefaultAuthorizationPolicyProvider Fallback { get; }
+
+ public PermissionPolicyProvider(IOptions opt)
+ => Fallback = new DefaultAuthorizationPolicyProvider(opt);
+
+ public Task GetDefaultPolicyAsync() => Fallback.GetDefaultPolicyAsync();
+
+ public Task GetFallbackPolicyAsync() => Fallback.GetFallbackPolicyAsync();
+
+ public Task GetPolicyAsync(string policyName)
+ {
+ if (policyName.StartsWith(Permissions.PolicyPrefix))
+ {
+ var policy = new AuthorizationPolicyBuilder();
+ policy.AddRequirements(new PermissionRequirement(policyName));
+ return Task.FromResult(policy.Build());
+ }
+ return Fallback.GetPolicyAsync(policyName);
+ }
+}
diff --git a/JetHerald/Authorization/PermissionParser.cs b/JetHerald/Authorization/PermissionParser.cs
new file mode 100644
index 0000000..cf949da
--- /dev/null
+++ b/JetHerald/Authorization/PermissionParser.cs
@@ -0,0 +1,28 @@
+namespace JetHerald.Authorization;
+public static class PermissionParser
+{
+ public static bool ProvePermission(string permissions, string required)
+ => permissions.Split(";").Any(p => MatchPermission(p, required));
+
+ // TODO check, test and redo
+ static bool MatchPermission(string match, string required)
+ {
+ string[] matchwords = match.Split('.');
+ string[] reqwords = required.Split('.');
+
+ if (reqwords.Length < matchwords.Length)
+ return false;
+
+ int matchindex = 0, reqindex = 0;
+
+ while (matchindex < matchwords.Length)
+ {
+ if (matchwords[matchindex] == "**") reqindex = reqwords.Length - (matchwords.Length - matchindex);
+ else if (matchwords[matchindex] != reqwords[reqindex] && matchwords[matchindex] != "*") return false;
+ matchindex++;
+ reqindex++;
+ }
+
+ return reqindex == reqwords.Length;
+ }
+}
diff --git a/JetHerald/Authorization/ServiceCollectionExtensions.cs b/JetHerald/Authorization/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..2062d8c
--- /dev/null
+++ b/JetHerald/Authorization/ServiceCollectionExtensions.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace JetHerald.Authorization;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddPermissions(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/JetHerald/Commands/CreateTopicCommand.cs b/JetHerald/Commands/CreateTopicCommand.cs
deleted file mode 100644
index 58cf14c..0000000
--- a/JetHerald/Commands/CreateTopicCommand.cs
+++ /dev/null
@@ -1,55 +0,0 @@
-using MySql.Data.MySqlClient;
-using Telegram.Bot.Types;
-using Telegram.Bot.Types.Enums;
-using JetHerald.Services;
-
-namespace JetHerald.Commands;
-public class CreateTopicCommand : IChatCommand
-{
- Db Db { get; }
-
- public CreateTopicCommand(Db db)
- {
- Db = db;
- }
-
- public async Task Execute(CommandString cmd, Update update)
- {
- if (cmd.Parameters.Length < 1)
- return null;
- var msg = update.Message;
-
- if (msg.Chat.Type != ChatType.Private)
- return null;
-
- string name = cmd.Parameters[0];
- string descr = name;
- if (cmd.Parameters.Length > 1)
- descr = string.Join(' ', cmd.Parameters.Skip(1));
-
- var user = await Db.GetUser(NamespacedId.Telegram(msg.From.Id));
-
- if (user == null) return null;
-
- try
- {
- var topic = await Db.CreateTopic(user.UserId, name, descr);
-
- if (topic == null)
- {
- return "you have reached the limit of topics";
- }
- else
- {
- return $"created {topic.Name}\n" +
- $"readToken\n{topic.ReadToken}\n" +
- $"writeToken\n{topic.WriteToken}\n";
- }
- }
- catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
- {
- return $"topic {name} already exists";
- }
- }
-}
-
diff --git a/JetHerald/Commands/DeleteTopicCommand.cs b/JetHerald/Commands/DeleteTopicCommand.cs
deleted file mode 100644
index feb5e21..0000000
--- a/JetHerald/Commands/DeleteTopicCommand.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using Telegram.Bot.Types;
-using Telegram.Bot.Types.Enums;
-using JetHerald.Services;
-
-namespace JetHerald.Commands;
-public class DeleteTopicCommand : IChatCommand
-{
- Db Db { get; }
-
- public DeleteTopicCommand(Db db)
- {
- Db = db;
- }
-
- public async Task Execute(CommandString cmd, Update update)
- {
- if (cmd.Parameters.Length < 1)
- return null;
- var msg = update.Message;
-
- if (msg.Chat.Type != ChatType.Private)
- return null;
-
- string name = cmd.Parameters[0];
-
- var user = await Db.GetUser(NamespacedId.Telegram(msg.From.Id));
-
- if (user == null) return null;
-
- var changed = await Db.DeleteTopic(name, user.UserId);
- if (changed > 0)
- return $"deleted {name} and all its subscriptions";
- else
- return $"invalid topic name";
- }
-}
-
diff --git a/JetHerald/Contracts/IDb.cs b/JetHerald/Contracts/IDb.cs
index 7bc27c7..f941569 100644
--- a/JetHerald/Contracts/IDb.cs
+++ b/JetHerald/Contracts/IDb.cs
@@ -15,6 +15,17 @@ public class Topic
=> Name == Description ? Name : $"{Name}: {Description}";
}
+public class Heart
+{
+ public uint HeartId { get; set; }
+ public uint TopicId { get; set; }
+ public string Name { get; set; }
+ public string Status { get; set; }
+ public DateTime LastBeatTs { get; set; }
+ public DateTime ExpiryTs { get; set; }
+ public DateTime CreateTs { get; set; }
+}
+
public class HeartEvent
{
public ulong HeartEventId { get; set; }
@@ -28,9 +39,40 @@ public class HeartEvent
public class User
{
public uint UserId { get; set; }
- public NamespacedId? ForeignId { get; set; }
+ public string Login { get; set; }
+ public string Name { get; set; }
+ public byte[] PasswordHash { get; set; }
+ public byte[] PasswordSalt { get; set; }
+ public int HashType { get; set; }
public uint PlanId { get; set; }
+ public string Allow { get; set; }
+
public int? MaxTopics { get; set; }
public int? TimeoutMultiplier { get; set; }
+
+ public DateTime CreateTs { get; set; }
+}
+public class UserInvite
+{
+ public uint UserInviteId { get; set; }
+ public string InviteCode { get; set; }
+ public uint PlanId { get; set; }
+ public uint RedeemedBy { get; set; }
+}
+
+public class UserSession
+{
+ public string SessionId { get; set; }
+ public byte[] SessionData { get; set; }
+ public DateTime ExpiryTs { get; set; }
+}
+
+public class Plan
+{
+ public uint PlanId { get; set; }
+ public string Name { get; set; }
+ public int MaxTopics { get; set; }
+ public double TimeoutMultiplier { get; set; }
+ public string Allow { get; set; }
}
diff --git a/JetHerald/Controllers/HeartbeatController.cs b/JetHerald/Controllers/Api/HeartbeatController.cs
similarity index 100%
rename from JetHerald/Controllers/HeartbeatController.cs
rename to JetHerald/Controllers/Api/HeartbeatController.cs
diff --git a/JetHerald/Controllers/HelloController.cs b/JetHerald/Controllers/Api/HelloController.cs
similarity index 100%
rename from JetHerald/Controllers/HelloController.cs
rename to JetHerald/Controllers/Api/HelloController.cs
diff --git a/JetHerald/Controllers/ReportController.cs b/JetHerald/Controllers/Api/ReportController.cs
similarity index 100%
rename from JetHerald/Controllers/ReportController.cs
rename to JetHerald/Controllers/Api/ReportController.cs
diff --git a/JetHerald/Controllers/Ui/AdminToolsController.cs b/JetHerald/Controllers/Ui/AdminToolsController.cs
new file mode 100644
index 0000000..a2d19b8
--- /dev/null
+++ b/JetHerald/Controllers/Ui/AdminToolsController.cs
@@ -0,0 +1,59 @@
+using JetHerald.Authorization;
+using JetHerald.Contracts;
+using JetHerald.Options;
+using JetHerald.Services;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace JetHerald.Controllers.Ui;
+[Permission("admintools")]
+public class AdminToolsController : Controller
+{
+ Db Db { get; }
+ ILogger Log { get; }
+ AuthConfig AuthCfg { get; }
+ public AdminToolsController(
+ ILogger log,
+ Db db,
+ IOptionsSnapshot authCfg
+ )
+ {
+ Db = db;
+ Log = log;
+ AuthCfg = authCfg.Value;
+ }
+
+ [HttpGet, Route("ui/admintools/")]
+ public IActionResult Index() => View();
+
+
+ [HttpGet, Route("ui/admintools/invites")]
+ public async Task ViewInvites()
+ {
+ var invites = await Db.GetInvites();
+ var plans = await Db.GetPlans();
+ return View(new ViewInvitesModel
+ {
+ Invites = invites.ToArray(),
+ Plans = plans.ToDictionary(p => p.PlanId)
+ });
+ }
+
+ public class CreateInviteRequest
+ {
+ [BindProperty(Name = "planId"), BindRequired]
+ public uint PlanId { get; set; }
+ }
+ [HttpPost, Route("ui/admintools/invites/create")]
+ public async Task CreateInvite(CreateInviteRequest req)
+ {
+ await Db.CreateUserInvite(req.PlanId, TokenHelper.GetToken(AuthCfg.InviteCodeLength));
+ return RedirectToAction(nameof(ViewInvites));
+ }
+}
+public class ViewInvitesModel
+{
+ public UserInvite[] Invites { get; set; }
+ public Dictionary Plans { get; set; }
+}
\ No newline at end of file
diff --git a/JetHerald/Controllers/Ui/DashboardController.cs b/JetHerald/Controllers/Ui/DashboardController.cs
new file mode 100644
index 0000000..60ba396
--- /dev/null
+++ b/JetHerald/Controllers/Ui/DashboardController.cs
@@ -0,0 +1,42 @@
+
+using Microsoft.AspNetCore.Mvc;
+
+using JetHerald.Services;
+using JetHerald.Utils;
+using JetHerald.Contracts;
+using JetHerald.Authorization;
+
+namespace JetHerald.Controllers.Ui;
+
+[Permission("dashboard")]
+public class DashboardController : Controller
+{
+ Db Db { get; }
+ public DashboardController(Db db)
+ {
+ Db = db;
+ }
+
+ [HttpGet, Route("ui/dashboard/")]
+ public async Task Index()
+ {
+ var login = HttpContext.User.GetUserLogin();
+ var user = await Db.GetUser(login);
+ var topics = await Db.GetTopicsForUser(user.UserId);
+ var hearts = await Db.GetHeartsForUser(user.UserId);
+ var vm = new DashboardViewModel
+ {
+ Topics = topics.ToArray(),
+ Hearts = hearts.ToLookup(h => h.TopicId)
+ };
+ return View(vm);
+ }
+}
+
+public class DashboardViewModel
+{
+ public Topic[] Topics { get; set; }
+ public ILookup Hearts { get; set; }
+
+
+}
\ No newline at end of file
diff --git a/JetHerald/Controllers/Ui/LoginController.cs b/JetHerald/Controllers/Ui/LoginController.cs
new file mode 100644
index 0000000..f0ddf8a
--- /dev/null
+++ b/JetHerald/Controllers/Ui/LoginController.cs
@@ -0,0 +1,93 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using JetHerald.Services;
+using JetHerald.Utils;
+
+namespace JetHerald.Controllers.Ui;
+public class LoginController : Controller
+{
+ Db Db { get; }
+ ILogger Log { get; }
+
+ PathString PathStringOrDefault(string s, string def = "")
+ {
+ try { return new PathString(s); }
+ catch (ArgumentException) { return new PathString(def); }
+ }
+
+ public LoginController(Db db, ILogger log)
+ {
+ Db = db;
+ Log = log;
+ }
+
+ [HttpGet, Route("ui/login/")]
+ public IActionResult Login([FromQuery] string redirect = "")
+ {
+ ViewData["RedirectTo"] = PathStringOrDefault(redirect);
+ return View();
+ }
+
+ public class LoginRequest
+ {
+ [BindProperty(Name = "username"), BindRequired]
+ public string Username { get; set; }
+
+ [BindProperty(Name = "password"), BindRequired]
+ public string Password { get; set; }
+ }
+
+ [HttpPost, Route("ui/login/")]
+ public async Task Login(
+ LoginRequest req,
+ [FromQuery] string redirect = "")
+ {
+ if (!ModelState.IsValid)
+ return View();
+
+ ViewData["RedirectTo"] = PathStringOrDefault(redirect);
+
+ var user = await Db.GetUser(req.Username);
+ if (user == null)
+ {
+ ModelState.AddModelError("", "User not found");
+ return View();
+ }
+ byte[] newHash = AuthUtils.GetHashFor(req.Password, user.PasswordSalt, user.HashType);
+ if (!Enumerable.SequenceEqual(newHash, user.PasswordHash))
+ {
+ ModelState.AddModelError("", "Incorrect password");
+ return View();
+ }
+ var userIdentity = AuthUtils.CreateIdentity(user.UserId, user.Login, user.Name, user.Allow);
+ var principal = new ClaimsPrincipal(userIdentity);
+ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
+
+ try
+ {
+ return Redirect(new PathString(redirect));
+ }
+ catch (ArgumentException)
+ {
+ return RedirectToAction("Dashboard", "Dashboard");
+ }
+ }
+
+ [Route("ui/logout/")]
+ public async Task LogOut([FromQuery] string redirect = "")
+ {
+ await HttpContext.SignOutAsync();
+ try
+ {
+ return Redirect(new PathString(redirect));
+ }
+ catch (ArgumentException)
+ {
+ return RedirectToAction(nameof(Login));
+ }
+ }
+}
diff --git a/JetHerald/Controllers/Ui/MainController.cs b/JetHerald/Controllers/Ui/MainController.cs
new file mode 100644
index 0000000..44233fb
--- /dev/null
+++ b/JetHerald/Controllers/Ui/MainController.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Mvc;
+namespace JetHerald.Controllers.Ui;
+public class MainController : Controller
+{
+ [Route("/error/")]
+ [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+ public IActionResult Error()
+ {
+ return View();
+ }
+ [Route("/403")]
+ [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+ public IActionResult Error403()
+ {
+ return View();
+ }
+ [Route("/404")]
+ public IActionResult Error404()
+ {
+ return View();
+ }
+ [Route("/400")]
+ public IActionResult Error400()
+ {
+ return View();
+ }
+}
diff --git a/JetHerald/Controllers/Ui/ProfileController.cs b/JetHerald/Controllers/Ui/ProfileController.cs
new file mode 100644
index 0000000..3a7a59c
--- /dev/null
+++ b/JetHerald/Controllers/Ui/ProfileController.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Mvc;
+
+using JetHerald.Authorization;
+using JetHerald.Services;
+using JetHerald.Utils;
+
+namespace JetHerald.Controllers.Ui;
+
+[Permission("profile")]
+public class ProfileController : Controller
+{
+ Db Db { get; }
+ public ProfileController(Db db)
+ {
+ Db = db;
+ }
+
+ [HttpGet, Route("ui/profile/")]
+ public async Task Index()
+ {
+ var login = HttpContext.User.GetUserLogin();
+ var user = await Db.GetUser(login);
+
+ var vm = new ProfileViewModel
+ {
+ OrganizationName = user.Name,
+ JoinedOn = user.CreateTs,
+ };
+ return View(vm);
+ }
+}
+
+public class ProfileViewModel
+{
+ public string OrganizationName { get; set; }
+ public DateTime JoinedOn { get; set; }
+}
\ No newline at end of file
diff --git a/JetHerald/Controllers/Ui/RegistrationController.cs b/JetHerald/Controllers/Ui/RegistrationController.cs
new file mode 100644
index 0000000..ae76384
--- /dev/null
+++ b/JetHerald/Controllers/Ui/RegistrationController.cs
@@ -0,0 +1,106 @@
+using System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using System.Security.Cryptography;
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+using JetHerald.Contracts;
+using JetHerald.Options;
+using JetHerald.Services;
+using JetHerald.Utils;
+
+namespace JetHerald.Controllers.Ui;
+public class RegistrationController : Controller
+{
+ Db Db { get; }
+ ILogger Log { get; }
+ AuthConfig Cfg { get; }
+
+ PathString PathStringOrDefault(string s, string def = "")
+ {
+ try { return new PathString(s); }
+ catch (ArgumentException) { return new PathString(def); }
+ }
+
+ public RegistrationController(
+ ILogger log,
+ Db db,
+ IOptionsSnapshot authConfig)
+ {
+ Db = db;
+ Log = log;
+ Cfg = authConfig.Value;
+ }
+
+ [HttpGet, Route("ui/register/")]
+ public IActionResult Register([FromQuery] string redirect = "")
+ {
+ ViewData["RedirectTo"] = PathStringOrDefault(redirect);
+ return View();
+ }
+
+ public class RegisterRequest
+ {
+ [BindProperty(Name = "invitecode"), BindRequired]
+ public string InviteCode { get; set; }
+
+ [BindProperty(Name = "name"), BindRequired]
+ [StringLength(maximumLength: 100, MinimumLength = 3)]
+ public string Name { get; set; }
+
+ [BindProperty(Name = "login"), BindRequired]
+ [StringLength(maximumLength: 64, MinimumLength = 6)]
+ public string Login { get; set; }
+
+ [BindProperty(Name = "password"), BindRequired]
+ [StringLength(maximumLength: 1024, MinimumLength = 6)]
+ public string Password { get; set; }
+ }
+
+ [HttpPost, Route("ui/register/")]
+ public async Task Register(RegisterRequest req, [FromQuery] string redirect = "")
+ {
+ if (!ModelState.IsValid)
+ return View();
+
+ ViewData["RedirectTo"] = PathStringOrDefault(redirect);
+
+ var oldUser = await Db.GetUser(req.Login);
+ if (oldUser != null)
+ {
+ ModelState.AddModelError("", "User already exists");
+ return View();
+ }
+ var invite = await Db.GetInviteByCode(req.InviteCode);
+ if (invite == null || invite.RedeemedBy != default)
+ {
+ ModelState.AddModelError("", "No unredeemed invite with this code found");
+ return View();
+ }
+ var user = new User()
+ {
+ PlanId = invite.PlanId,
+ Login = req.Login,
+ Name = req.Name,
+ PasswordSalt = RandomNumberGenerator.GetBytes(64)
+ };
+ user.PasswordHash = AuthUtils.GetHashFor(req.Password, user.PasswordSalt, Cfg.HashType);
+ var newUser = await Db.RegisterUserFromInvite(user, invite.UserInviteId);
+ var userIdentity = AuthUtils.CreateIdentity(newUser.UserId, newUser.Login, newUser.Name, newUser.Allow);
+ var principal = new ClaimsPrincipal(userIdentity);
+ await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
+
+ try
+ {
+ return Redirect(new PathString(redirect));
+ }
+ catch (ArgumentException)
+ {
+ return RedirectToAction("News", "Issue");
+ }
+ }
+}
diff --git a/JetHerald/Controllers/Ui/TopicController.cs b/JetHerald/Controllers/Ui/TopicController.cs
new file mode 100644
index 0000000..8152389
--- /dev/null
+++ b/JetHerald/Controllers/Ui/TopicController.cs
@@ -0,0 +1,71 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using JetHerald.Services;
+using JetHerald.Utils;
+using JetHerald.Contracts;
+using JetHerald.Authorization;
+
+namespace JetHerald.Controllers.Ui;
+
+[Permission("topic")]
+public class TopicController : Controller
+{
+ Db Db { get; }
+ public TopicController(Db db)
+ {
+ Db = db;
+ }
+
+ [HttpGet, Route("ui/topic/create")]
+ public IActionResult Create() => View();
+
+ public class CreateTopicRequest
+ {
+ [BindProperty(Name = "name"), BindRequired]
+ public string Name { get; set; }
+
+
+ [BindProperty(Name = "descr"), BindRequired]
+ public string Description { get; set; }
+ }
+
+ [HttpPost, Route("ui/topic/create")]
+ public async Task Create(
+ CreateTopicRequest req)
+ {
+ if (!ModelState.IsValid)
+ return View();
+ var userId = HttpContext.User.GetUserId();
+ var topic = await Db.CreateTopic(userId, req.Name, req.Description);
+ if (topic == null)
+ {
+ ModelState.AddModelError("", "Unknown error");
+ return View();
+ }
+
+ return RedirectToAction(nameof(ViewTopic), "Topic", new { topicName = topic.Name});
+ }
+
+ [HttpGet, Route("ui/topic/{topicName}")]
+ public async Task ViewTopic(string topicName)
+ {
+ var userId = HttpContext.User.GetUserId();
+ var topic = await Db.GetTopic(topicName);
+ if (topic == null || topic.CreatorId != userId)
+ return NotFound();
+
+ var hearts = await Db.GetHeartsForTopic(topic.TopicId);
+ var vm = new TopicViewModel
+ {
+ Topic = topic,
+ Hearts = hearts.ToArray()
+ };
+ return View(vm);
+ }
+}
+
+public class TopicViewModel
+{
+ public Topic Topic{ get; set; }
+ public Heart[] Hearts { get; set; }
+}
diff --git a/JetHerald/JetHerald.csproj b/JetHerald/JetHerald.csproj
index 157a019..cfdca24 100644
--- a/JetHerald/JetHerald.csproj
+++ b/JetHerald/JetHerald.csproj
@@ -5,14 +5,11 @@
en
-
-
-
-
+
diff --git a/JetHerald/Middlewares/AnonymousUserMassagerMiddleware.cs b/JetHerald/Middlewares/AnonymousUserMassagerMiddleware.cs
new file mode 100644
index 0000000..f3aea83
--- /dev/null
+++ b/JetHerald/Middlewares/AnonymousUserMassagerMiddleware.cs
@@ -0,0 +1,37 @@
+using Microsoft.AspNetCore.Http;
+using JetHerald.Services;
+using JetHerald.Utils;
+using System.Security.Claims;
+using JetHerald.Authorization;
+
+namespace JetHerald.Middlewares;
+public class AnonymousUserMassagerMiddleware : IMiddleware
+{
+ Lazy> AnonymousPermissions { get; }
+ public AnonymousUserMassagerMiddleware(Db db)
+ {
+ AnonymousPermissions = new Lazy>(async () =>
+ {
+ var anonymousUser = await db.GetUser("Anonymous");
+ return anonymousUser.Allow;
+ });
+ }
+
+ public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
+ {
+ if (ctx.User.FindFirst(ClaimTypes.PrimarySid) == null)
+ {
+ var perms = await AnonymousPermissions.Value;
+ var ci = new ClaimsIdentity();
+ ci.AddClaims(new Claim[] {
+ new Claim(ClaimTypes.PrimarySid, "0"),
+ new Claim(ClaimTypes.NameIdentifier, "anonymous"),
+ new Claim(ClaimTypes.Name, "Anonymous"),
+ new Claim(ClaimTypes.Anonymous, "true"),
+ new Claim(Permissions.ClaimId, perms)
+ });
+ ctx.User.AddIdentity(ci);
+ }
+ await next(ctx);
+ }
+}
diff --git a/JetHerald/Middlewares/RequestTimeTrackerMiddleware.cs b/JetHerald/Middlewares/RequestTimeTrackerMiddleware.cs
new file mode 100644
index 0000000..aea7c89
--- /dev/null
+++ b/JetHerald/Middlewares/RequestTimeTrackerMiddleware.cs
@@ -0,0 +1,18 @@
+using System.Diagnostics;
+using Microsoft.AspNetCore.Http;
+
+namespace JetHerald.Middlewares;
+public class RequestTimeFeature
+{
+ public RequestTimeFeature() => Stopwatch = Stopwatch.StartNew();
+ public Stopwatch Stopwatch { get; }
+}
+
+public class RequestTimeTrackerMiddleware : IMiddleware
+{
+ public Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ context.Features.Set(new RequestTimeFeature());
+ return next(context);
+ }
+}
diff --git a/JetHerald/Options.cs b/JetHerald/Options.cs
index 5774b9d..7c1832f 100644
--- a/JetHerald/Options.cs
+++ b/JetHerald/Options.cs
@@ -21,3 +21,11 @@ public class TimeoutConfig
public int HeartbeatCost { get; set; }
public int ReportCost { get; set; }
}
+
+
+public class AuthConfig
+{
+ public int InviteCodeLength { get; set; }
+ public int TicketIdLengthBytes { get; set; }
+ public int HashType { get; set; }
+}
\ No newline at end of file
diff --git a/JetHerald/Program.cs b/JetHerald/Program.cs
index 2b3e63b..fb1a4a3 100644
--- a/JetHerald/Program.cs
+++ b/JetHerald/Program.cs
@@ -5,25 +5,37 @@ using NLog.Web;
using JetHerald;
using JetHerald.Options;
using JetHerald.Services;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Http;
+using JetHerald.Middlewares;
+using System.Security.Cryptography;
+using JetHerald.Utils;
+using JetHerald.Authorization;
-var log =
#if DEBUG
-NLogBuilder.ConfigureNLog("nlog.debug.config").GetCurrentClassLogger();
+var debug = true;
#else
-NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
+var debug = false;
#endif
+var log = NLogBuilder.ConfigureNLog(debug ? "nlog.debug.config" : "nlog.config").GetCurrentClassLogger();
try
{
log.Info("init main");
DapperConverters.Register();
+ log.Info($"Permissions digest:\n{string.Join('\n', FlightcheckHelpers.GetUsedPermissions(typeof(Program)))}");
- var builder = WebApplication.CreateBuilder(args);
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ WebRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"),
+ ContentRootPath = Directory.GetCurrentDirectory(),
+ Args = args,
+ });
builder.WebHost.ConfigureAppConfiguration((hostingContext, config) =>
{
- config.SetBasePath(Directory.GetCurrentDirectory());
+ //config.SetBasePath(Directory.GetCurrentDirectory());
config.AddIniFile("secrets.ini",
optional: true, reloadOnChange: true);
config.AddIniFile($"secrets.{hostingContext.HostingEnvironment.EnvironmentName}.ini",
@@ -43,20 +55,72 @@ try
services.Configure(cfg.GetSection("Telegram"));
services.Configure(cfg.GetSection("Discord"));
services.Configure(cfg.GetSection("Timeout"));
+ services.Configure(cfg.GetSection("AuthConfig"));
+
services.AddSingleton();
services.AddSingleton().AddHostedService(s => s.GetService());
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
- services.AddMvc();
+
+ services.AddControllersWithViews()
+ #if DEBUG
+ .AddRazorRuntimeCompilation()
+ #endif
+ ;
+
+ services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(options =>
+ {
+ options.Cookie.Name = "jetherald.sid";
+ options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ options.Cookie.HttpOnly = true;
+ // options.SessionStore = new JetHeraldTicketStore();
+ options.LoginPath = "/login";
+ options.LogoutPath = "/logout";
+ options.ReturnUrlParameter = "redirect";
+ options.AccessDeniedPath = "/403";
+ options.ClaimsIssuer = "JetHerald";
+ });
+ services.AddPermissions();
var app = builder.Build();
+
+ // preflight checks
+ {
+ var db = app.Services.GetService();
+
+ var adminUser = await db.GetUser("admin");
+ if (adminUser == null)
+ {
+ var authCfg = app.Services.GetService>().Value;
+ var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
+ adminUser = new JetHerald.Contracts.User()
+ {
+ Login = "admin",
+ Name = "Administrator",
+ PasswordSalt = RandomNumberGenerator.GetBytes(64),
+ HashType = authCfg.HashType
+ };
+ adminUser.PasswordHash = AuthUtils.GetHashFor(password, adminUser.PasswordSalt, adminUser.HashType);
+ var newUser = await db.RegisterUser(adminUser, "admin");
+ log.Warn($"Created administrative account {adminUser.Login}:{password}. Be sure to save these credentials somewhere!");
+ }
+ }
+ app.UseMiddleware();
app.UsePathBase(cfg.GetValue("PathBase"));
+ app.UseAuthentication();
+ app.UseMiddleware();
app.UseDeveloperExceptionPage();
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
+ app.UseStatusCodePagesWithReExecute("/{0}");
app.UseRouting();
+ app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
diff --git a/JetHerald/Services/Db.cs b/JetHerald/Services/Db.cs
index 3b635cc..544df1b 100644
--- a/JetHerald/Services/Db.cs
+++ b/JetHerald/Services/Db.cs
@@ -6,27 +6,58 @@ using JetHerald.Contracts;
namespace JetHerald.Services;
public class Db
{
- public async Task DeleteTopic(string name, uint userId)
+ public async Task> GetTopicsForUser(uint userId)
{
using var c = GetConnection();
- return await c.ExecuteAsync(
- " DELETE t" +
- " FROM topic t" +
- " LEFT JOIN user u ON t.CreatorId = u.UserId" +
- " WHERE t.Name = @name AND u.UserId = @userId",
- new { name, userId });
+ return await c.QueryAsync(
+ " SELECT * FROM topic WHERE CreatorId = @userId",
+ new { userId });
+ }
+ public async Task> GetPlans()
+ {
+ using var c = GetConnection();
+ return await c.QueryAsync("SELECT * FROM plan");
+ }
+
+ public async Task> GetInvites()
+ {
+ using var c = GetConnection();
+ return await c.QueryAsync("SELECT * FROM userinvite");
+ }
+
+ public async Task> GetHeartsForUser(uint userId)
+ {
+ using var c = GetConnection();
+ return await c.QueryAsync(
+ " SELECT h.* FROM heart h JOIN topic USING (TopicId) WHERE CreatorId = @userId",
+ new { userId });
+ }
+
+ public async Task CreateUserInvite(uint planId, string inviteCode)
+ {
+ using var c = GetConnection();
+ await c.ExecuteAsync(
+ "INSERT INTO userinvite (PlanId, InviteCode) VALUES (@planId, @inviteCode)",
+ new { planId, inviteCode });
}
public async Task GetTopic(string name)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync(
- " SELECT *" +
- " FROM topic" +
- " WHERE Name = @name",
+ "SELECT * FROM topic WHERE Name = @name",
new { name });
}
+ public async Task DeleteTopic(string name, uint userId)
+ {
+ using var c = GetConnection();
+ return await c.ExecuteAsync(
+ " DELETE FROM topic WHERE Name = @name AND CreatorId = @userId",
+ new { name, userId });
+ }
+
+
public async Task GetTopicForSub(string token, NamespacedId sub)
{
using var c = GetConnection();
@@ -38,15 +69,22 @@ public class Db
new { token, sub });
}
- public async Task GetUser(NamespacedId foreignId)
+ public async Task> GetHeartsForTopic(uint topicId)
+ {
+ using var c = GetConnection();
+ return await c.QueryAsync(
+ " SELECT * FROM heart WHERE TopicId = @topicId",
+ new { topicId });
+ }
+ public async Task GetUser(string login)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync(
" SELECT u.*, p.* " +
" FROM user u " +
" LEFT JOIN plan p ON p.PlanId = u.PlanId " +
- " WHERE u.ForeignId = @foreignId",
- new { foreignId });
+ " WHERE u.Login = @login",
+ new { login });
}
public async Task CreateTopic(uint user, string name, string descr)
@@ -58,16 +96,20 @@ public class Db
await using var tx = await c.BeginTransactionAsync();
var topicsCount = await c.QuerySingleAsync(
- " SELECT COUNT(t.TopicId) " +
+ " SELECT COUNT(*) " +
" FROM user u " +
- " LEFT JOIN topic t ON t.CreatorId = u.UserId ",
+ " LEFT JOIN topic t ON t.CreatorId = u.UserId " +
+ " WHERE u.UserId = @user",
+ new { user },
transaction: tx
);
var planTopicsCount = await c.QuerySingleAsync(
" SELECT p.MaxTopics " +
" FROM user u " +
- " LEFT JOIN plan p ON p.PlanId = u.PlanId ",
+ " LEFT JOIN plan p ON p.PlanId = u.PlanId " +
+ " WHERE u.UserId = @user",
+ new { user },
transaction: tx
);
@@ -92,6 +134,37 @@ public class Db
return topic;
}
+
+ public async Task RegisterUserFromInvite(User user, uint inviteId)
+ {
+ using var c = GetConnection();
+ uint userId = await c.QuerySingleOrDefaultAsync(
+ "CALL register_user_from_invite(@inviteId, @Login, @Name, @PasswordHash, @PasswordSalt, @HashType);",
+ new { inviteId, user.Login, user.Name, user.PasswordHash, user.PasswordSalt, user.HashType });
+
+ return await GetUser(user.Login);
+ }
+
+ public async Task RegisterUser(User user, string plan)
+ {
+ using var c = GetConnection();
+ uint userId = await c.QuerySingleOrDefaultAsync(
+ "CALL register_user(@plan, @Login, @Name, @PasswordHash, @PasswordSalt, @HashType);",
+ new { plan, user.Login, user.Name, user.PasswordHash, user.PasswordSalt, user.HashType });
+
+ return await GetUser(user.Login);
+ }
+
+ public async Task GetInviteByCode(string inviteCode)
+ {
+ using var c = GetConnection();
+ return await c.QuerySingleOrDefaultAsync(
+ " SELECT * FROM userinvite " +
+ " WHERE InviteCode = @inviteCode " +
+ " AND RedeemedBy IS NULL ",
+ new { inviteCode });
+ }
+
public async Task> GetSubsForTopic(uint topicId)
{
using var c = GetConnection();
@@ -156,6 +229,46 @@ public class Db
await c.ExecuteAsync("UPDATE heartevent SET Status = 'reported' WHERE HeartEventId = @id", new { id });
}
+ #region authorization
+
+ public async Task RemoveSession(string sessionId)
+ {
+ using var c = GetConnection();
+ await c.ExecuteAsync("DELETE FROM usersession WHERE SessionId = @sessionId", new {sessionId});
+ }
+ public async Task GetSession(string sessionId)
+ {
+ using var c = GetConnection();
+ return await c.QuerySingleOrDefaultAsync(
+ "SELECT * FROM usersession WHERE SessionId = @sessionId",
+ new { sessionId });
+ }
+
+ public async Task UpdateSession(string sessionId, byte[] data, DateTime expiryTs)
+ {
+ using var c = GetConnection();
+ await c.ExecuteAsync(@"
+ UPDATE usersession SET
+ SessionData = @data,
+ ExpiryTs = @expiryTs
+ WHERE SessionId = @sessionId;",
+ new { sessionId, data, expiryTs });
+ }
+
+ public async Task CreateSession(string sessionId, byte[] data, DateTime expiryTs)
+ {
+ using var c = GetConnection();
+ await c.ExecuteAsync(@"
+ INSERT INTO usersession
+ (SessionId, SessionData, ExpiryTs)
+ VALUES
+ (@sessionId, @data, @expiryTs);",
+ new { sessionId, data, expiryTs });
+ return sessionId;
+ }
+
+ #endregion
+
public Db(IOptionsMonitor cfg)
{
Config = cfg;
diff --git a/JetHerald/Services/DiscordCommands.cs b/JetHerald/Services/DiscordCommands.cs
index 8e531ee..69b0155 100644
--- a/JetHerald/Services/DiscordCommands.cs
+++ b/JetHerald/Services/DiscordCommands.cs
@@ -1,7 +1,7 @@
-using MySql.Data.MySqlClient;
+using JetHerald.Services;
+
using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes;
-using JetHerald.Services;
namespace JetHerald.Commands;
[ModuleLifespan(ModuleLifespan.Transient)]
@@ -9,81 +9,6 @@ public class DiscordCommands : BaseCommandModule
{
public Db Db { get; set; }
- [Command("createtopic")]
- [Description("Creates a topic.")]
- [RequireDirectMessage]
- public async Task CreateTopic(
- CommandContext ctx,
- [Description("The unique name of the new topic.")]
- string name,
- [RemainingText, Description("The name displayed in service messages. Defaults to `name`")]
- string description = null)
- {
- if (description == null)
- description = name;
-
- _ = ctx.TriggerTypingAsync();
-
- var user = await Db.GetUser(NamespacedId.Discord(ctx.User.Id));
-
- if (user == null) return;
-
- try
- {
- var topic = await Db.CreateTopic(user.UserId, name, description);
-
- if (topic == null)
- {
- await ctx.RespondAsync("you have reached the limit of topics");
- }
- else
- {
- await ctx.RespondAsync($"created {topic.Name}\n" +
- $"readToken\n{topic.ReadToken}\n" +
- $"writeToken\n{topic.WriteToken}\n");
- }
- }
- catch (MySqlException myDuplicate) when (myDuplicate.Number == 1062)
- {
- await ctx.RespondAsync($"topic {name} already exists");
- }
- }
-
- [Command("deletetopic")]
- [Description("Deletes a topic.")]
- [RequireDirectMessage]
- public async Task DeleteTopic(
- CommandContext ctx,
- [Description("The name of the topic to be deleted.")]
- string name)
- {
- _ = ctx.TriggerTypingAsync();
-
- var user = await Db.GetUser(NamespacedId.Discord(ctx.User.Id));
-
- if (user == null) return;
-
- var changed = await Db.DeleteTopic(name, user.UserId);
- if (changed > 0)
- await ctx.RespondAsync($"deleted {name} and all its subscriptions");
- else
- await ctx.RespondAsync($"invalid topic name");
- }
-
- [Command("list")]
- [Description("List all subscriptions in this channel.")]
- [RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
- public async Task ListSubscriptions(CommandContext ctx)
- {
- _ = ctx.TriggerTypingAsync();
-
- var topics = await Db.GetTopicsForSub(NamespacedId.Discord(ctx.Channel.Id));
-
- await ctx.RespondAsync(topics.Any()
- ? "Topics:\n" + string.Join("\n", topics)
- : "No subscriptions active.");
- }
-
[Command("subscribe")]
[Description("Subscribes to a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]
diff --git a/JetHerald/Services/JetHeraldBot.Discord.cs b/JetHerald/Services/JetHeraldBot.Discord.cs
index a707997..06c2eca 100644
--- a/JetHerald/Services/JetHeraldBot.Discord.cs
+++ b/JetHerald/Services/JetHeraldBot.Discord.cs
@@ -8,6 +8,9 @@ public partial class JetHeraldBot
async Task StartDiscord()
{
+ if (string.IsNullOrWhiteSpace(DiscordConfig.Token))
+ return;
+
DiscordBot = new DiscordClient(new()
{
Token = DiscordConfig.Token,
diff --git a/JetHerald/Services/JetHeraldBot.Telegram.cs b/JetHerald/Services/JetHeraldBot.Telegram.cs
index 5133f4d..aefb78b 100644
--- a/JetHerald/Services/JetHeraldBot.Telegram.cs
+++ b/JetHerald/Services/JetHeraldBot.Telegram.cs
@@ -16,12 +16,13 @@ public partial class JetHeraldBot
CancellationTokenSource TelegramBotShutdownToken { get; } = new();
async Task StartTelegram()
{
+ if (string.IsNullOrWhiteSpace(TelegramConfig.ApiKey))
+ return;
+
TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey);
Me = await TelegramBot.GetMeAsync();
Commands = new ChatCommandRouter(Me.Username, Log);
- Commands.Add(new CreateTopicCommand(Db), "createtopic");
- Commands.Add(new DeleteTopicCommand(Db), "deletetopic");
Commands.Add(new SubscribeCommand(Db, TelegramBot), "subscribe", "sub");
Commands.Add(new UnsubscribeCommand(Db, TelegramBot), "unsubscribe", "unsub");
Commands.Add(new ListCommand(Db, TelegramBot), "list");
diff --git a/JetHerald/Services/TicketStore.cs b/JetHerald/Services/TicketStore.cs
new file mode 100644
index 0000000..b538c00
--- /dev/null
+++ b/JetHerald/Services/TicketStore.cs
@@ -0,0 +1,41 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using JetHerald.Options;
+
+namespace JetHerald.Services;
+public class JetHeraldTicketStore : ITicketStore
+{
+ Db Db { get; }
+ IOptionsMonitor Cfg { get; }
+ public JetHeraldTicketStore(Db db, IOptionsMonitor cfg )
+ {
+ Db = db;
+ Cfg = cfg;
+ }
+ public Task RemoveAsync(string key)
+ => Db.RemoveSession(key);
+
+ public Task RenewAsync(string key, AuthenticationTicket ticket)
+ => Db.UpdateSession(
+ key,
+ TicketSerializer.Default.Serialize(ticket),
+ ticket.Properties.ExpiresUtc.Value.DateTime);
+
+ public async Task RetrieveAsync(string key)
+ {
+ var userSession = await Db.GetSession(key);
+ return TicketSerializer.Default.Deserialize(userSession.SessionData);
+ }
+
+ public Task StoreAsync(AuthenticationTicket ticket)
+ {
+ var cfg = Cfg.CurrentValue;
+ var bytes = RandomNumberGenerator.GetBytes(cfg.TicketIdLengthBytes);
+ var key = Convert.ToBase64String(bytes);
+ return Db.CreateSession(
+ key,
+ TicketSerializer.Default.Serialize(ticket),
+ ticket.Properties.ExpiresUtc.Value.DateTime);
+ }
+}
diff --git a/JetHerald/Utils/AuthUtils.cs b/JetHerald/Utils/AuthUtils.cs
new file mode 100644
index 0000000..ed04d0b
--- /dev/null
+++ b/JetHerald/Utils/AuthUtils.cs
@@ -0,0 +1,41 @@
+using System.Security.Claims;
+
+using JetHerald.Authorization;
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+using Microsoft.AspNetCore.Http;
+
+namespace JetHerald.Utils;
+public static class AuthUtils
+{
+ public static byte[] GetHashFor(string password, byte[] salt, int hashType = 1) => hashType switch
+ {
+ 1 => KeyDerivation.Pbkdf2(password, salt, KeyDerivationPrf.HMACSHA512, 100000, 64),
+ _ => throw new ArgumentException($"Unexpected hash type {hashType}")
+ };
+
+ public static ClaimsIdentity CreateIdentity(uint userId, string login, string name, string perms)
+ {
+ var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
+ identity.AddClaims(new Claim[] {
+ new Claim(ClaimTypes.PrimarySid, userId.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, login),
+ new Claim(ClaimTypes.Name, name),
+ new Claim(Permissions.ClaimId, perms),
+ });
+ return identity;
+ }
+
+ public static uint GetUserId(this ClaimsPrincipal principal)
+ => uint.Parse(principal.FindFirstValue(ClaimTypes.PrimarySid));
+
+ public static string GetUserLogin(this ClaimsPrincipal principal)
+ => principal.FindFirstValue(ClaimTypes.NameIdentifier);
+
+ public static bool IsAnonymous(this ClaimsPrincipal principal)
+ => principal.HasClaim(x => x.Type == ClaimTypes.Anonymous);
+
+ public static bool UserCan(this HttpContext ctx, string permission)
+ => PermissionParser.ProvePermission(ctx.User.FindFirstValue(Permissions.ClaimId), permission);
+}
diff --git a/JetHerald/Utils/TokenHelper.cs b/JetHerald/Utils/TokenHelper.cs
index 39d52b2..33d7068 100644
--- a/JetHerald/Utils/TokenHelper.cs
+++ b/JetHerald/Utils/TokenHelper.cs
@@ -6,12 +6,11 @@ public static class TokenHelper
static readonly byte[] buf = new byte[24];
static readonly object SyncLock = new();
- public static string GetToken()
+ public static string GetToken(int length = 32)
{
- lock (SyncLock)
- {
- RandomNumberGenerator.Fill(buf);
- return Convert.ToBase64String(buf).Replace('+', '_').Replace('/', '_');
- }
+ var byteLength = (length + 3) / 4 * 3;
+ var bytes = RandomNumberGenerator.GetBytes(byteLength);
+ var str = Convert.ToBase64String(bytes).Substring(0, length);
+ return str.Replace('+', '_').Replace('/', '_');
}
}
diff --git a/JetHerald/Views/AdminTools/Index.cshtml b/JetHerald/Views/AdminTools/Index.cshtml
new file mode 100644
index 0000000..b17d6d0
--- /dev/null
+++ b/JetHerald/Views/AdminTools/Index.cshtml
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/JetHerald/Views/AdminTools/ViewInvites.cshtml b/JetHerald/Views/AdminTools/ViewInvites.cshtml
new file mode 100644
index 0000000..046c434
--- /dev/null
+++ b/JetHerald/Views/AdminTools/ViewInvites.cshtml
@@ -0,0 +1,27 @@
+@model ViewInvitesModel
+
+
+
+
+Invites
+
+ @foreach (var invite in Model.Invites)
+ {
+
+ @invite.InviteCode.Substring(0, 8)... (@Model.Plans[invite.PlanId].Name)
+
+ 📤
+
+ }
+
\ No newline at end of file
diff --git a/JetHerald/Views/Dashboard/Index.cshtml b/JetHerald/Views/Dashboard/Index.cshtml
new file mode 100644
index 0000000..a7e394b
--- /dev/null
+++ b/JetHerald/Views/Dashboard/Index.cshtml
@@ -0,0 +1,34 @@
+@model DashboardViewModel
+@Html.ValidationSummary(false, "", new {})
+
+ Create new topic
+
+ @foreach (var topic in @Model.Topics)
+ {
+
+ @topic.Name
+
+ ReadToken: @topic.ReadToken
+
+ WriteToken: @topic.WriteToken
+
+
+ @if (@Model.Hearts.Contains(topic.TopicId))
+ {
+
+ Heart Last beat Expires on
+ @foreach (var heart in @Model.Hearts[topic.TopicId])
+ {
+
+ @heart.Name @heart.LastBeatTs @heart.ExpiryTs
+
+
+ }
+
+
+ }
+
+
+
+ }
+
\ No newline at end of file
diff --git a/JetHerald/Views/Login/Login.cshtml b/JetHerald/Views/Login/Login.cshtml
new file mode 100644
index 0000000..1a1d522
--- /dev/null
+++ b/JetHerald/Views/Login/Login.cshtml
@@ -0,0 +1,18 @@
+@Html.ValidationSummary(false, "", new {})
+
\ No newline at end of file
diff --git a/JetHerald/Views/Main/Error.cshtml b/JetHerald/Views/Main/Error.cshtml
new file mode 100644
index 0000000..ddc2a08
--- /dev/null
+++ b/JetHerald/Views/Main/Error.cshtml
@@ -0,0 +1,2 @@
+500 Internal Server Serror
+Something happened.
\ No newline at end of file
diff --git a/JetHerald/Views/Main/Error400.cshtml b/JetHerald/Views/Main/Error400.cshtml
new file mode 100644
index 0000000..5e37115
--- /dev/null
+++ b/JetHerald/Views/Main/Error400.cshtml
@@ -0,0 +1,2 @@
+400 Bad Request
+bad
\ No newline at end of file
diff --git a/JetHerald/Views/Main/Error403.cshtml b/JetHerald/Views/Main/Error403.cshtml
new file mode 100644
index 0000000..04a3644
--- /dev/null
+++ b/JetHerald/Views/Main/Error403.cshtml
@@ -0,0 +1,2 @@
+403 Forbidden
+no
\ No newline at end of file
diff --git a/JetHerald/Views/Main/Error404.cshtml b/JetHerald/Views/Main/Error404.cshtml
new file mode 100644
index 0000000..3f4aa2b
--- /dev/null
+++ b/JetHerald/Views/Main/Error404.cshtml
@@ -0,0 +1,2 @@
+404 Not Found
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Enim sit amet venenatis urna cursus. Sed tempus urna et pharetra pharetra massa massa. Enim facilisis gravida neque convallis a cras. In eu mi bibendum neque. Arcu vitae elementum curabitur vitae nunc sed velit dignissim sodales. Interdum varius sit amet mattis. Bibendum est ultricies integer quis auctor. Odio eu feugiat pretium nibh ipsum consequat nisl vel. Sit amet venenatis urna cursus eget nunc scelerisque viverra mauris. Blandit aliquam etiam erat velit scelerisque in dictum non consectetur. Imperdiet massa tincidunt nunc pulvinar sapien et ligula. Sed adipiscing diam donec adipiscing tristique risus. Gravida dictum fusce ut placerat orci. Adipiscing enim eu turpis egestas pretium aenean pharetra. Vel quam elementum pulvinar etiam non.
\ No newline at end of file
diff --git a/JetHerald/Views/Profile/Index.cshtml b/JetHerald/Views/Profile/Index.cshtml
new file mode 100644
index 0000000..0d1d8a3
--- /dev/null
+++ b/JetHerald/Views/Profile/Index.cshtml
@@ -0,0 +1,8 @@
+@model ProfileViewModel
+@Html.ValidationSummary(false, "", new {})
+
+ @Model.OrganizationName
+
+ @Model.JoinedOn
+
+
\ No newline at end of file
diff --git a/JetHerald/Views/Registration/Register.cshtml b/JetHerald/Views/Registration/Register.cshtml
new file mode 100644
index 0000000..2395862
--- /dev/null
+++ b/JetHerald/Views/Registration/Register.cshtml
@@ -0,0 +1,40 @@
+@Html.ValidationSummary(false, "", new {})
+
+
diff --git a/JetHerald/Views/Shared/_Layout.cshtml b/JetHerald/Views/Shared/_Layout.cshtml
new file mode 100644
index 0000000..13e8d2a
--- /dev/null
+++ b/JetHerald/Views/Shared/_Layout.cshtml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ JetHerald
+
+ @RenderSection("Stylesheets", required: false)
+
+
+
+
+
+
+
+
+
+
+ @if (Context.UserCan("dashboard"))
+ {
+ Dashboard
+ }
+ @if (Context.UserCan("admintools"))
+ {
+ Admin tools
+ }
+ @if (User.IsAnonymous())
+ {
+ Login
+ Register
+ }
+ else
+ {
+ Log Out
+ }
+
+
+
+
+
+
+ @RenderBody()
+
+
+
+ Rendered in @(Context.Features.Get().Stopwatch.Elapsed.TotalMilliseconds) ms
+
+
+
+ @RenderSection("Scripts", required: false)
+
+
diff --git a/JetHerald/Views/Topic/Create.cshtml b/JetHerald/Views/Topic/Create.cshtml
new file mode 100644
index 0000000..44e9b16
--- /dev/null
+++ b/JetHerald/Views/Topic/Create.cshtml
@@ -0,0 +1,14 @@
+@Html.ValidationSummary(false, "", new {})
+
\ No newline at end of file
diff --git a/JetHerald/Views/Topic/ViewTopic.cshtml b/JetHerald/Views/Topic/ViewTopic.cshtml
new file mode 100644
index 0000000..df2def2
--- /dev/null
+++ b/JetHerald/Views/Topic/ViewTopic.cshtml
@@ -0,0 +1,30 @@
+@model TopicViewModel
+@Html.ValidationSummary(false, "", new {})
+
+ @{var topic = @Model.Topic;}
+
+ @topic.Name
+
+ ReadToken: @topic.ReadToken
+
+ WriteToken: @topic.WriteToken
+
+
+ @if (@Model.Hearts.Any())
+ {
+
+ Heart Last beat Expires on
+ @foreach (var heart in @Model.Hearts)
+ {
+
+ @heart.Name @heart.LastBeatTs @heart.ExpiryTs
+
+
+ }
+
+
+ }
+
+
+
+
\ No newline at end of file
diff --git a/JetHerald/Views/_ViewImports.cshtml b/JetHerald/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..3c6774b
--- /dev/null
+++ b/JetHerald/Views/_ViewImports.cshtml
@@ -0,0 +1,6 @@
+@using JetHerald
+@using JetHerald.Middlewares
+@using JetHerald.Controllers.Ui
+@using JetHerald.Utils
+@using System.Security.Claims
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/JetHerald/Views/_ViewStart.cshtml b/JetHerald/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..a5f1004
--- /dev/null
+++ b/JetHerald/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/JetHerald/appsettings.json b/JetHerald/appsettings.json
index 8ead099..061a45a 100644
--- a/JetHerald/appsettings.json
+++ b/JetHerald/appsettings.json
@@ -12,5 +12,10 @@
"HeartbeatCost": 60,
"ReportCost": 60
},
- "PathBase": "/"
+ "PathBase": "/",
+ "AuthConfig": {
+ "InviteCodeLength" : 128,
+ "TicketIdLengthBytes": 64,
+ "HashType": 1
+ }
}
\ No newline at end of file
diff --git a/JetHerald/wwwroot/css/global.css b/JetHerald/wwwroot/css/global.css
new file mode 100644
index 0000000..67c939a
--- /dev/null
+++ b/JetHerald/wwwroot/css/global.css
@@ -0,0 +1,465 @@
+body {
+ line-height: 1.6;
+ font-size: 16px;
+ font-family: sans-serif;
+ color: #222;
+ margin: 0;
+ background-color: #fafaff;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: #059;
+ line-height: 1.2;
+ margin: 0 10px;
+ font-family: tahoma, sans-serif;
+}
+
+.entry {
+ padding: 10px 10px;
+ font-size: 14px;
+ font-family: monospace;
+}
+
+ul.postlist {
+ list-style-type: none;
+ padding-left: 0px;
+ padding-right: 0px;
+ width: 100%;
+}
+
+ul.postlist > li:nth-child(odd) {
+ background-color: #f0f0ff;
+}
+
+ul.postlist li:target {
+ border: #059 solid 1px;
+}
+
+header {
+ background-color: #059;
+ padding: 10px 0px 10px 0px;
+ font-size: 18px;
+ width: 100%;
+ font-family: tahoma, sans-serif;
+}
+
+nav {
+ display: inline-block;
+}
+
+.flex-container {
+ display: -webkit-box;
+ /* OLD - iOS 6-, Safari 3.1-6, BB7 */
+ display: -ms-flexbox;
+ /* TWEENER - IE 10 */
+ display: -webkit-flex;
+ /* NEW - Safari 6.1+. iOS 7.1+, BB10 */
+ display: flex;
+ /* NEW, Spec - Firefox, Chrome, Opera */
+ justify-content: center;
+ align-items: center;
+}
+
+.inner-element {
+ display: inline-block;
+ width: 750px;
+}
+
+.helper {
+ display: inline-block;
+ height: 100%;
+ vertical-align: middle;
+}
+
+.helper>img {
+ vertical-align: middle;
+}
+
+span.assigneetext {
+ color: black;
+}
+
+div.page-main {
+ max-width: 750px;
+ margin: auto;
+ margin-top: 20px;
+}
+
+.issues-list {
+ list-style-type: none;
+ padding-left: 0;
+ width: 100%;
+}
+
+.issues-list a {
+ display: block;
+ color: #222;
+}
+
+.issues-list li {
+ padding: 5px;
+ padding-left: 10px;
+ margin: 5px;
+}
+
+.files-list {
+ list-style-type: none;
+ padding-left: 0;
+ width: 100%;
+}
+
+.files-list a {
+ display: block;
+ color: #222;
+}
+
+.files-list li {
+ padding: 5px;
+ padding-left: 10px;
+ margin: 5px;
+}
+
+.issue-tag {
+ font-size: 12px;
+ font-family: tahoma, sans-serif;
+ color: #059;
+ margin-bottom: -5px;
+}
+
+.issues-list li:nth-child(odd) {
+ background: #f0f0ff;
+}
+
+.issues-list li:hover {
+ background: #fff;
+}
+
+nav {
+ color: #eef;
+}
+
+nav>a {
+ color: #eef;
+ padding: 5px;
+ padding-right: 10px;
+ padding-left: 10px;
+}
+
+nav>a:hover {
+ background-color: #036;
+ color: #fff;
+}
+
+.entry {
+ font-family: tahoma, sans-serif;
+}
+
+.entry .date, .entry .from {
+ font-size: 10px;
+ color: #059;
+}
+
+.entry .from .issue {
+ color: #222;
+}
+
+.entry .username {
+ font-size: 18px;
+}
+
+.entry .activitytag {
+ font-size: 10px;
+ color: green;
+}
+
+textarea.submit-text {
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ height: 10em;
+ border-style: none;
+ background-color: #f0f0ff;
+}
+
+input.title-input {
+ width: 100%;
+ border-style: none;
+ background-color: #fafaff;
+ font-size: 1.5em;
+}
+
+.searchbox
+{
+ margin-left:auto;
+ margin-right:auto;
+ width: 80%;
+ overflow:auto;
+ height: auto;
+ display: block;
+}
+
+.search {
+ resize: vertical;
+ float: left;
+ height: auto;
+ width: calc(100% - 36px);
+ border-color: #059;
+ border-style: solid;
+ border-width: 0 0 2px 0;
+ background: none;
+ font-size: 1.5em;
+}
+
+.buttonlink {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font:inherit;
+ padding: 0;
+}
+
+form.linkform {
+ display:inline;
+}
+
+input.submit-search {
+ float: right;
+ background-size: contain !important;
+ cursor:pointer;
+ border: none;
+ width: 32px;
+ height:32px;
+ font-size: 0px;
+}
+
+input.submitpost {
+ background-color: #059;
+ color: white;
+ border: none;
+ width: 6em;
+ height: 1.5em;
+}
+
+.underpanel {
+ font-size: 16px;
+ color: black;
+ padding-left: 15px;
+}
+
+.underpanel>a, .underpanel .buttonlink {
+ color: #059;
+ padding: 5px;
+}
+
+.underpanel>a:hover, .underpanel .buttonlink:hover {
+ background-color: #036;
+ color: #fff;
+}
+
+div.comment-underpanel {
+ font-size: 10px;
+ color: black;
+ padding-left: 15px;
+}
+
+
+span.tag a,
+span.tag {
+ /* background-color: white; */
+ color: gray;
+}
+
+span.tag:hover,
+span.tag:hover a {
+ background-color: black;
+ color: white
+}
+
+span.issue-closed-marker {
+ color: green;
+}
+
+span.issue-closed-marker {
+ color: blue;
+}
+
+span.issue-wontfix-marker {
+ color: darkgreen;
+}
+
+span.issue-open-marker {
+ color: red;
+}
+
+.blue {
+ color:#059;
+}
+
+.issue-name {
+ margin-top: 5px;
+ margin-left: 0px;
+ margin-bottom: 5px;
+ color: black;
+ font-weight: normal;
+}
+
+div.taglist {
+ margin-top: 10px;
+}
+
+hr {
+ border-color: #05b;
+ border-width: 0px 0px 2px;
+ margin: 0px;
+}
+
+a {
+ text-decoration: none;
+}
+
+footer {
+ padding-top: 25px;
+ text-align: center;
+ color: #059;
+}
+
+label {
+ color: #05b;
+}
+
+.blueunderline {
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-bottom-width: 2px;
+ border-color: #05b;
+ background-color: #fafaff;
+}
+
+.h2 {
+ font-size: 1.5em;
+}
+
+.register-grid {
+ display: grid;
+ grid-template-columns: [val] 100px [fields] 300px [info] 300px [end-col];
+ grid-template-rows: [inviteid] auto [name] auto [login] auto [pass] auto [btn] auto [end-row];
+ column-gap: 15px;
+ row-gap: 25px;
+ text-align: center;
+ margin-top: 100px;
+}
+
+.changepassword-grid {
+ display: grid;
+ grid-template-columns: [val] 100px [fields] 300px [end-col];
+ grid-template-rows: [currentpassword] auto [newpassword] auto [btn] auto [end-row];
+ column-gap: 15px;
+ row-gap: 25px;
+ text-align: center;
+ margin-top: 100px;
+}
+
+.valcol {
+ grid-column: val;
+ justify-self: end;
+}
+
+.fieldcol {
+ grid-column: fields;
+ justify-self: stretch;
+ align-self: start;
+}
+
+.infocol {
+ grid-column: info;
+ justify-self: start;
+ align-self: start;
+ text-align: left;
+}
+
+.inviteidrow {
+ grid-row: inviteid;
+}
+
+.namerow {
+ grid-row: name;
+}
+
+.loginrow {
+ grid-row: login;
+}
+
+.passrow {
+ grid-row: pass;
+}
+
+.btnrow {
+ grid-row: btn;
+}
+
+.currentpassrow {
+ grid-row: currentpassword;
+}
+
+.newpassrow {
+ grid-row: newpassword;
+}
+
+.icon {
+ width: 1.5em;
+ margin-right: 5px;
+}
+
+.beforeedit {
+ background-color:#ff000080;
+}
+
+.afteredit {
+ background-color:#00ff0080;
+}
+
+.filler {
+ background-color: #80808080;
+}
+
+.postborder {
+ padding: 5px;
+ border-radius: 5px;
+ border-width: 2px;
+ border-style: solid;
+ border-color: lightgray;
+}
+
+.showable.hide {
+ display: none;
+}
+
+.underline {
+ text-decoration-line: underline;
+}
+
+.fleft {
+ float: left;
+}
+.fright {
+ float: right;
+}
+
+.smallmargin {
+ margin-top: 1px;
+ margin-bottom: 1px;
+}
+
+.strike {
+ text-decoration: line-through;
+}
+
+a.show-button {
+ display: inline;
+}
\ No newline at end of file
diff --git a/JetHerald/wwwroot/css/jquery.tag-editor.css b/JetHerald/wwwroot/css/jquery.tag-editor.css
new file mode 100644
index 0000000..1bd9e0b
--- /dev/null
+++ b/JetHerald/wwwroot/css/jquery.tag-editor.css
@@ -0,0 +1,49 @@
+/*from tagEditor by pixabay. modified*/
+
+/* surrounding tag container */
+.tag-editor {
+ /* list-style-type: none; padding: 0 5px 0 0; margin: 0; overflow: hidden; border: 1px solid #eee; cursor: text;
+ font: normal 14px sans-serif; color: #555; background: #fff; line-height: 20px; */
+ list-style-type: none; padding: 0 5px 0 0; margin: 0; overflow: hidden; cursor: text;
+ font: normal 14px sans-serif; color: #555; line-height: 20px;
+}
+
+/* core styles usually need no change */
+.tag-editor li { display: block; float: left; overflow: hidden; margin: 3px 0; }
+.tag-editor div { float: left; padding: 0 4px; }
+.tag-editor .placeholder { padding: 0 8px; color: #bbb; }
+.tag-editor .tag-editor-spacer { padding: 0; width: 8px; overflow: hidden; color: transparent; background: none; }
+.tag-editor input {
+ vertical-align: inherit; border: 0; outline: none; padding: 0; margin: 0; cursor: text;
+ font-family: inherit; font-weight: inherit; font-size: inherit; font-style: inherit;
+ box-shadow: none; background: none; color: #444;
+}
+/* hide original input field or textarea visually to allow tab navigation */
+.tag-editor-hidden-src { position: absolute !important; left: -99999px; }
+/* hide IE10 "clear field" X */
+.tag-editor ::-ms-clear { display: none; }
+
+/* tag style */
+.tag-editor .tag-editor-tag {
+ padding-left: 5px; color: #46799b; background: #e0eaf1; white-space: nowrap;
+ overflow: hidden; cursor: pointer; border-radius: 2px 0 0 2px;
+}
+
+/* delete icon */
+.tag-editor .tag-editor-delete { background: #e0eaf1; cursor: pointer; border-radius: 0 2px 2px 0; padding-left: 3px; padding-right: 4px; }
+.tag-editor .tag-editor-delete i { line-height: 18px; display: inline-block; }
+.tag-editor .tag-editor-delete i:before { font-size: 16px; color: #8ba7ba; content: "×"; font-style: normal; }
+.tag-editor .tag-editor-delete:hover i:before { color: #d65454; }
+.tag-editor .tag-editor-tag.active+.tag-editor-delete, .tag-editor .tag-editor-tag.active+.tag-editor-delete i { visibility: hidden; cursor: text; }
+
+.tag-editor .tag-editor-tag.active { background: none !important; }
+
+/* jQuery UI autocomplete - code.jquery.com/ui/1.10.2/themes/smoothness/jquery-ui.css */
+.ui-autocomplete { position: absolute; top: 0; left: 0; cursor: default; font-size: 14px; }
+.ui-front { z-index: 9999; }
+.ui-menu { list-style: none; padding: 1px; margin: 0; display: block; outline: none; }
+.ui-menu .ui-menu-item a { text-decoration: none; display: block; padding: 2px .4em; line-height: 1.4; min-height: 0; /* support: IE7 */ }
+.ui-widget-content { border: 1px solid #bbb; background: #fff; color: #555; }
+.ui-widget-content a { color: #46799b; }
+.ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { background: #e0eaf1; }
+.ui-helper-hidden-accessible { display: none; }
diff --git a/JetHerald/wwwroot/img/PB.svg b/JetHerald/wwwroot/img/PB.svg
new file mode 100644
index 0000000..8121861
--- /dev/null
+++ b/JetHerald/wwwroot/img/PB.svg
@@ -0,0 +1 @@
+PB
\ No newline at end of file
diff --git a/JetHerald/wwwroot/img/magnifying_glass.png b/JetHerald/wwwroot/img/magnifying_glass.png
new file mode 100644
index 0000000..c979b58
Binary files /dev/null and b/JetHerald/wwwroot/img/magnifying_glass.png differ
diff --git a/JetHerald/wwwroot/js/expandable.js b/JetHerald/wwwroot/js/expandable.js
new file mode 100644
index 0000000..38b108c
--- /dev/null
+++ b/JetHerald/wwwroot/js/expandable.js
@@ -0,0 +1,8 @@
+$(function () {
+ $(".showable-container > .showable")
+ .css("display", "none")
+ .removeClass("hide");
+ $(".showable-container > .show-button").click(function () {
+ $(this).parent().children(".showable").toggle();
+ });
+});
\ No newline at end of file
diff --git a/JetHerald/wwwroot/js/jquery.tag-editor.min.js b/JetHerald/wwwroot/js/jquery.tag-editor.min.js
new file mode 100644
index 0000000..9429344
--- /dev/null
+++ b/JetHerald/wwwroot/js/jquery.tag-editor.min.js
@@ -0,0 +1,3 @@
+// jQuery tagEditor v1.0.21
+// https://github.com/Pixabay/jQuery-tagEditor
+!function(t){t.fn.tagEditorInput=function(){var e=" ",i=t(this),a=parseInt(i.css("fontSize")),r=t(" ").css({position:"absolute",top:-9999,left:-9999,width:"auto",fontSize:i.css("fontSize"),fontFamily:i.css("fontFamily"),fontWeight:i.css("fontWeight"),letterSpacing:i.css("letterSpacing"),whiteSpace:"nowrap"}),l=function(){if(e!==(e=i.val())){r.text(e);var t=r.width()+a;20>t&&(t=20),t!=i.width()&&i.width(t)}};return r.insertAfter(i),i.bind("keyup keydown focus",l)},t.fn.tagEditor=function(e,a,r){function l(t){return t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}var n,o=t.extend({},t.fn.tagEditor.defaults,e),c=this;if(o.dregex=new RegExp("["+o.delimiter.replace("-","-")+"]","g"),"string"==typeof e){var s=[];return c.each(function(){var i=t(this),l=i.data("options"),n=i.next(".tag-editor");if("getTags"==e)s.push({field:i[0],editor:n,tags:n.data("tags")});else if("addTag"==e){if(l.maxTags&&n.data("tags").length>=l.maxTags)return!1;t(' '+l.delimiter[0]+'
').appendTo(n).find(".tag-editor-tag").html(' ').addClass("active").find("input").val(a).blur(),r?t(".placeholder",n).remove():n.click()}else"removeTag"==e?(t(".tag-editor-tag",n).filter(function(){return t(this).text()==a}).closest("li").find(".tag-editor-delete").click(),r||n.click()):"destroy"==e&&i.removeClass("tag-editor-hidden-src").removeData("options").off("focus.tag-editor").next(".tag-editor").remove()}),"getTags"==e?s:this}return window.getSelection&&t(document).off("keydown.tag-editor").on("keydown.tag-editor",function(e){if(8==e.which||46==e.which||e.ctrlKey&&88==e.which){try{var a=getSelection(),r="INPUT"!=document.activeElement.tagName?t(a.getRangeAt(0).startContainer.parentNode).closest(".tag-editor"):0}catch(e){r=0}if(a.rangeCount>0&&r&&r.length){var l=[],n=a.toString().split(r.prev().data("options").dregex);for(i=0;i'+o.placeholder+"
")}function i(i){var a=c.toString();c=t(".tag-editor-tag:not(.deleted)",s).map(function(e,i){var a=t.trim(t(this).hasClass("active")?t(this).find("input").val():t(i).text());return a?a:void 0}).get(),s.data("tags",c),r.val(c.join(o.delimiter[0])),i||a!=c.toString()&&o.onChange(r,s,c),e()}function a(e){for(var a,n=e.closest("li"),d=e.val().replace(/ +/," ").split(o.dregex),g=e.data("old_tag"),f=c.slice(0),h=!1,u=0;u '+o.delimiter[0]+'
'+l(v)+'
'),o.maxTags&&f.length>=o.maxTags)){h=!0;break}e.attr("maxlength",o.maxLength).removeData("old_tag").val(""),h?e.blur():e.focus(),i()}var r=t(this),c=[],s=t("').insertAfter(r);r.addClass("tag-editor-hidden-src").data("options",o).on("focus.tag-editor",function(){s.click()}),s.append(' ');var d=' '+o.delimiter[0]+'
';s.click(function(e,i){var a,r,l=99999;if(!window.getSelection||""==getSelection())return o.maxTags&&s.data("tags").length>=o.maxTags?(s.find("input").blur(),!1):(n=!0,t("input:focus",s).blur(),n?(n=!0,t(".placeholder",s).remove(),i&&i.length?r="before":t(".tag-editor-tag",s).each(function(){var n=t(this),o=n.offset(),c=o.left,s=o.top;e.pageY>=s&&e.pageY<=s+n.height()&&(e.pageXa&&(l=a,i=n))}),"before"==r?t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click():"after"==r?t(d).insertAfter(i.closest("li")).find(".tag-editor-tag").click():t(d).appendTo(s).find(".tag-editor-tag").click(),!1):!1)}),s.on("click",".tag-editor-delete",function(){if(t(this).prev().hasClass("active"))return t(this).closest("li").find("input").caret(-1),!1;var a=t(this).closest("li"),l=a.find(".tag-editor-tag");return o.beforeTagDelete(r,s,c,l.text())===!1?!1:(l.addClass("deleted").animate({width:0},o.animateDelete,function(){a.remove(),e()}),i(),!1)}),o.clickDelete&&s.on("mousedown",".tag-editor-tag",function(a){if(a.ctrlKey||a.which>1){var l=t(this).closest("li"),n=l.find(".tag-editor-tag");return o.beforeTagDelete(r,s,c,n.text())===!1?!1:(n.addClass("deleted").animate({width:0},o.animateDelete,function(){l.remove(),e()}),i(),!1)}}),s.on("click",".tag-editor-tag",function(e){if(o.clickDelete&&(e.ctrlKey||e.which>1))return!1;if(!t(this).hasClass("active")){var i=t(this).text(),a=Math.abs((t(this).offset().left-e.pageX)/t(this).width()),r=parseInt(i.length*a),n=t(this).html(' ').addClass("active").find("input");if(n.data("old_tag",i).tagEditorInput().focus().caret(r),o.autocomplete){var c=t.extend({},o.autocomplete),d="select"in c?o.autocomplete.select:"";c.select=function(e,i){d&&d(e,i),setTimeout(function(){s.trigger("click",[t(".active",s).find("input").closest("li").next("li").find(".tag-editor-tag")])},20)},n.autocomplete(c)}}return!1}),s.on("blur","input",function(d){d.stopPropagation();var g=t(this),f=g.data("old_tag"),h=t.trim(g.val().replace(/ +/," ").replace(o.dregex,o.delimiter[0]));if(h){if(h.indexOf(o.delimiter[0])>=0)return void a(g);if(h!=f)if(o.forceLowercase&&(h=h.toLowerCase()),cb_val=o.beforeTagSave(r,s,c,f,h),h=cb_val||h,cb_val===!1){if(f)return g.val(f).focus(),n=!1,void i();try{g.closest("li").remove()}catch(d){}f&&i()}else o.removeDuplicates&&t(".tag-editor-tag:not(.active)",s).each(function(){t(this).text()==h&&t(this).closest("li").remove()})}else{if(f&&o.beforeTagDelete(r,s,c,f)===!1)return g.val(f).focus(),n=!1,void i();try{g.closest("li").remove()}catch(d){}f&&i()}g.parent().html(l(h)).removeClass("active"),h!=f&&i(),e()});var g;s.on("paste","input",function(){t(this).removeAttr("maxlength"),g=t(this),setTimeout(function(){a(g)},30)});var f;s.on("keypress","input",function(e){o.delimiter.indexOf(String.fromCharCode(e.which))>=0&&(f=t(this),setTimeout(function(){a(f)},20))}),s.on("keydown","input",function(e){var i=t(this);if((37==e.which||!o.autocomplete&&38==e.which)&&!i.caret()||8==e.which&&!i.val()){var a=i.closest("li").prev("li").find(".tag-editor-tag");return a.length?a.click().find("input").caret(-1):!i.val()||o.maxTags&&s.data("tags").length>=o.maxTags||t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click(),!1}if((39==e.which||!o.autocomplete&&40==e.which)&&i.caret()==i.val().length){var l=i.closest("li").next("li").find(".tag-editor-tag");return l.length?l.click().find("input").caret(0):i.val()&&s.click(),!1}if(9==e.which){if(e.shiftKey){var a=i.closest("li").prev("li").find(".tag-editor-tag");if(a.length)a.click().find("input").caret(0);else{if(!i.val()||o.maxTags&&s.data("tags").length>=o.maxTags)return r.attr("disabled","disabled"),void setTimeout(function(){r.removeAttr("disabled")},30);t(d).insertBefore(i.closest("li")).find(".tag-editor-tag").click()}return!1}var l=i.closest("li").next("li").find(".tag-editor-tag");if(l.length)l.click().find("input").caret(0);else{if(!i.val())return;s.click()}return!1}if(!(46!=e.which||t.trim(i.val())&&i.caret()!=i.val().length)){var l=i.closest("li").next("li").find(".tag-editor-tag");return l.length?l.click().find("input").caret(0):i.val()&&s.click(),!1}if(13==e.which)return s.trigger("click",[i.closest("li").next("li").find(".tag-editor-tag")]),o.maxTags&&s.data("tags").length>=o.maxTags&&s.find("input").blur(),!1;if(36!=e.which||i.caret()){if(35==e.which&&i.caret()==i.val().length)s.find(".tag-editor-tag").last().click();else if(27==e.which)return i.val(i.data("old_tag")?i.data("old_tag"):"").blur(),!1}else s.find(".tag-editor-tag").first().click()});for(var h=o.initialTags.length?o.initialTags:r.val().split(o.dregex),u=0;u=o.maxTags);u++){var v=t.trim(h[u].replace(/ +/," "));v&&(o.forceLowercase&&(v=v.toLowerCase()),c.push(v),s.append(' '+o.delimiter[0]+'
'+l(v)+'
'))}i(!0),o.sortable&&t.fn.sortable&&s.sortable({distance:5,cancel:".tag-editor-spacer, input",helper:"clone",update:function(){i()}})})},t.fn.tagEditor.defaults={initialTags:[],maxTags:0,maxLength:50,delimiter:",;",placeholder:"",forceLowercase:!0,removeDuplicates:!0,clickDelete:!1,animateDelete:175,sortable:!0,autocomplete:null,onChange:function(){},beforeTagSave:function(){},beforeTagDelete:function(){}}}(jQuery);
\ No newline at end of file