First draft of moving users and admin tools to webui

This commit is contained in:
jetsparrow 2022-02-06 22:53:11 +03:00
parent f99079e82a
commit 95e8d12751
52 changed files with 1749 additions and 208 deletions

View File

@ -1,9 +1,9 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15 # Visual Studio Version 17
VisualStudioVersion = 15.0.28307.539 VisualStudioVersion = 17.0.32112.339
MinimumVisualStudioVersion = 10.0.40219.1 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -0,0 +1,28 @@
using System.Reflection;
namespace JetHerald.Authorization;
public static class FlightcheckHelpers
{
public static IEnumerable<string> GetUsedPermissions(Type rootType)
{
var res = new HashSet<string>();
var asm = Assembly.GetAssembly(rootType);
var types = asm.GetTypes();
var methods = types.SelectMany(t => t.GetMethods());
foreach (var t in types)
{
if (t.GetCustomAttribute<PermissionAttribute>() is PermissionAttribute perm)
res.Add(perm.Policy);
}
foreach (var t in methods)
{
if (t.GetCustomAttribute<PermissionAttribute>() is PermissionAttribute perm)
res.Add(perm.Policy);
}
return res.OrderBy(p => p);
}
}

View File

@ -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<PermissionRequirement>
{
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<AuthorizationOptions> opt)
=> Fallback = new DefaultAuthorizationPolicyProvider(opt);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => Fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy> GetFallbackPolicyAsync() => Fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<IAuthorizationHandler, PermissionHandler>();
services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
return services;
}
}

View File

@ -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<string> 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";
}
}
}

View File

@ -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<string> 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";
}
}

View File

@ -15,6 +15,17 @@ public class Topic
=> Name == Description ? Name : $"{Name}: {Description}"; => 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 class HeartEvent
{ {
public ulong HeartEventId { get; set; } public ulong HeartEventId { get; set; }
@ -28,9 +39,40 @@ public class HeartEvent
public class User public class User
{ {
public uint UserId { get; set; } 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 uint PlanId { get; set; }
public string Allow { get; set; }
public int? MaxTopics { get; set; } public int? MaxTopics { get; set; }
public int? TimeoutMultiplier { 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; }
} }

View File

@ -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<AdminToolsController> log,
Db db,
IOptionsSnapshot<AuthConfig> authCfg
)
{
Db = db;
Log = log;
AuthCfg = authCfg.Value;
}
[HttpGet, Route("ui/admintools/")]
public IActionResult Index() => View();
[HttpGet, Route("ui/admintools/invites")]
public async Task<IActionResult> 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<IActionResult> 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<uint, Plan> Plans { get; set; }
}

View File

@ -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<IActionResult> 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<uint, Heart> Hearts { get; set; }
}

View File

@ -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<LoginController> 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<IActionResult> 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<IActionResult> LogOut([FromQuery] string redirect = "")
{
await HttpContext.SignOutAsync();
try
{
return Redirect(new PathString(redirect));
}
catch (ArgumentException)
{
return RedirectToAction(nameof(Login));
}
}
}

View File

@ -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();
}
}

View File

@ -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<IActionResult> 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; }
}

View File

@ -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<LoginController> log,
Db db,
IOptionsSnapshot<AuthConfig> 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<IActionResult> 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");
}
}
}

View File

@ -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<IActionResult> 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<IActionResult> 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; }
}

View File

@ -5,14 +5,11 @@
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="DSharpPlus" Version="4.1.0" /> <PackageReference Include="DSharpPlus" Version="4.1.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" /> <PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.1" />
<PackageReference Include="MySql.Data" Version="8.0.28" /> <PackageReference Include="MySql.Data" Version="8.0.28" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.0.0-rc2" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.0.0-rc2" />
<PackageReference Include="Telegram.Bot.Extensions.Polling" Version="1.0.2" /> <PackageReference Include="Telegram.Bot.Extensions.Polling" Version="1.0.2" />

View File

@ -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<Task<string>> AnonymousPermissions { get; }
public AnonymousUserMassagerMiddleware(Db db)
{
AnonymousPermissions = new Lazy<Task<string>>(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);
}
}

View File

@ -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);
}
}

View File

@ -21,3 +21,11 @@ public class TimeoutConfig
public int HeartbeatCost { get; set; } public int HeartbeatCost { get; set; }
public int ReportCost { 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; }
}

View File

@ -5,25 +5,37 @@ using NLog.Web;
using JetHerald; using JetHerald;
using JetHerald.Options; using JetHerald.Options;
using JetHerald.Services; 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 #if DEBUG
NLogBuilder.ConfigureNLog("nlog.debug.config").GetCurrentClassLogger(); var debug = true;
#else #else
NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger(); var debug = false;
#endif #endif
var log = NLogBuilder.ConfigureNLog(debug ? "nlog.debug.config" : "nlog.config").GetCurrentClassLogger();
try try
{ {
log.Info("init main"); log.Info("init main");
DapperConverters.Register(); 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) => builder.WebHost.ConfigureAppConfiguration((hostingContext, config) =>
{ {
config.SetBasePath(Directory.GetCurrentDirectory()); //config.SetBasePath(Directory.GetCurrentDirectory());
config.AddIniFile("secrets.ini", config.AddIniFile("secrets.ini",
optional: true, reloadOnChange: true); optional: true, reloadOnChange: true);
config.AddIniFile($"secrets.{hostingContext.HostingEnvironment.EnvironmentName}.ini", config.AddIniFile($"secrets.{hostingContext.HostingEnvironment.EnvironmentName}.ini",
@ -43,20 +55,72 @@ try
services.Configure<JetHerald.Options.TelegramConfig>(cfg.GetSection("Telegram")); services.Configure<JetHerald.Options.TelegramConfig>(cfg.GetSection("Telegram"));
services.Configure<DiscordConfig>(cfg.GetSection("Discord")); services.Configure<DiscordConfig>(cfg.GetSection("Discord"));
services.Configure<TimeoutConfig>(cfg.GetSection("Timeout")); services.Configure<TimeoutConfig>(cfg.GetSection("Timeout"));
services.Configure<AuthConfig>(cfg.GetSection("AuthConfig"));
services.AddSingleton<Db>(); services.AddSingleton<Db>();
services.AddSingleton<JetHeraldBot>().AddHostedService(s => s.GetService<JetHeraldBot>()); services.AddSingleton<JetHeraldBot>().AddHostedService(s => s.GetService<JetHeraldBot>());
services.AddSingleton<LeakyBucket>(); services.AddSingleton<LeakyBucket>();
services.AddSingleton<RequestTimeTrackerMiddleware>();
services.AddSingleton<AnonymousUserMassagerMiddleware>();
services.AddHostedService<HeartMonitor>(); services.AddHostedService<HeartMonitor>();
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(); var app = builder.Build();
// preflight checks
{
var db = app.Services.GetService<Db>();
var adminUser = await db.GetUser("admin");
if (adminUser == null)
{
var authCfg = app.Services.GetService<IOptions<AuthConfig>>().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<RequestTimeTrackerMiddleware>();
app.UsePathBase(cfg.GetValue<string>("PathBase")); app.UsePathBase(cfg.GetValue<string>("PathBase"));
app.UseAuthentication();
app.UseMiddleware<AnonymousUserMassagerMiddleware>();
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app.UseHsts(); app.UseHsts();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseStatusCodePagesWithReExecute("/{0}");
app.UseRouting(); app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
endpoints.MapControllers(); endpoints.MapControllers();

View File

@ -6,27 +6,58 @@ using JetHerald.Contracts;
namespace JetHerald.Services; namespace JetHerald.Services;
public class Db public class Db
{ {
public async Task<int> DeleteTopic(string name, uint userId) public async Task<IEnumerable<Topic>> GetTopicsForUser(uint userId)
{ {
using var c = GetConnection(); using var c = GetConnection();
return await c.ExecuteAsync( return await c.QueryAsync<Topic>(
" DELETE t" + " SELECT * FROM topic WHERE CreatorId = @userId",
" FROM topic t" + new { userId });
" LEFT JOIN user u ON t.CreatorId = u.UserId" + }
" WHERE t.Name = @name AND u.UserId = @userId", public async Task<IEnumerable<Plan>> GetPlans()
new { name, userId }); {
using var c = GetConnection();
return await c.QueryAsync<Plan>("SELECT * FROM plan");
}
public async Task<IEnumerable<UserInvite>> GetInvites()
{
using var c = GetConnection();
return await c.QueryAsync<UserInvite>("SELECT * FROM userinvite");
}
public async Task<IEnumerable<Heart>> GetHeartsForUser(uint userId)
{
using var c = GetConnection();
return await c.QueryAsync<Heart>(
" 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<Topic> GetTopic(string name) public async Task<Topic> GetTopic(string name)
{ {
using var c = GetConnection(); using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<Topic>( return await c.QuerySingleOrDefaultAsync<Topic>(
" SELECT *" + "SELECT * FROM topic WHERE Name = @name",
" FROM topic" +
" WHERE Name = @name",
new { name }); new { name });
} }
public async Task<int> 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<Topic> GetTopicForSub(string token, NamespacedId sub) public async Task<Topic> GetTopicForSub(string token, NamespacedId sub)
{ {
using var c = GetConnection(); using var c = GetConnection();
@ -38,15 +69,22 @@ public class Db
new { token, sub }); new { token, sub });
} }
public async Task<User> GetUser(NamespacedId foreignId) public async Task<IEnumerable<Heart>> GetHeartsForTopic(uint topicId)
{
using var c = GetConnection();
return await c.QueryAsync<Heart>(
" SELECT * FROM heart WHERE TopicId = @topicId",
new { topicId });
}
public async Task<User> GetUser(string login)
{ {
using var c = GetConnection(); using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<User>( return await c.QuerySingleOrDefaultAsync<User>(
" SELECT u.*, p.* " + " SELECT u.*, p.* " +
" FROM user u " + " FROM user u " +
" LEFT JOIN plan p ON p.PlanId = u.PlanId " + " LEFT JOIN plan p ON p.PlanId = u.PlanId " +
" WHERE u.ForeignId = @foreignId", " WHERE u.Login = @login",
new { foreignId }); new { login });
} }
public async Task<Topic> CreateTopic(uint user, string name, string descr) public async Task<Topic> CreateTopic(uint user, string name, string descr)
@ -58,16 +96,20 @@ public class Db
await using var tx = await c.BeginTransactionAsync(); await using var tx = await c.BeginTransactionAsync();
var topicsCount = await c.QuerySingleAsync<int>( var topicsCount = await c.QuerySingleAsync<int>(
" SELECT COUNT(t.TopicId) " + " SELECT COUNT(*) " +
" FROM user u " + " 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 transaction: tx
); );
var planTopicsCount = await c.QuerySingleAsync<int>( var planTopicsCount = await c.QuerySingleAsync<int>(
" SELECT p.MaxTopics " + " SELECT p.MaxTopics " +
" FROM user u " + " 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 transaction: tx
); );
@ -92,6 +134,37 @@ public class Db
return topic; return topic;
} }
public async Task<User> RegisterUserFromInvite(User user, uint inviteId)
{
using var c = GetConnection();
uint userId = await c.QuerySingleOrDefaultAsync<uint>(
"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<User> RegisterUser(User user, string plan)
{
using var c = GetConnection();
uint userId = await c.QuerySingleOrDefaultAsync<uint>(
"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<UserInvite> GetInviteByCode(string inviteCode)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<UserInvite>(
" SELECT * FROM userinvite " +
" WHERE InviteCode = @inviteCode " +
" AND RedeemedBy IS NULL ",
new { inviteCode });
}
public async Task<IEnumerable<NamespacedId>> GetSubsForTopic(uint topicId) public async Task<IEnumerable<NamespacedId>> GetSubsForTopic(uint topicId)
{ {
using var c = GetConnection(); using var c = GetConnection();
@ -156,6 +229,46 @@ public class Db
await c.ExecuteAsync("UPDATE heartevent SET Status = 'reported' WHERE HeartEventId = @id", new { id }); 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<UserSession> GetSession(string sessionId)
{
using var c = GetConnection();
return await c.QuerySingleOrDefaultAsync<UserSession>(
"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<string> 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<ConnectionStrings> cfg) public Db(IOptionsMonitor<ConnectionStrings> cfg)
{ {
Config = cfg; Config = cfg;

View File

@ -1,7 +1,7 @@
using MySql.Data.MySqlClient; using JetHerald.Services;
using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext;
using DSharpPlus.CommandsNext.Attributes; using DSharpPlus.CommandsNext.Attributes;
using JetHerald.Services;
namespace JetHerald.Commands; namespace JetHerald.Commands;
[ModuleLifespan(ModuleLifespan.Transient)] [ModuleLifespan(ModuleLifespan.Transient)]
@ -9,81 +9,6 @@ public class DiscordCommands : BaseCommandModule
{ {
public Db Db { get; set; } 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")] [Command("subscribe")]
[Description("Subscribes to a topic.")] [Description("Subscribes to a topic.")]
[RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)] [RequireUserPermissions(DSharpPlus.Permissions.ManageGuild)]

View File

@ -8,6 +8,9 @@ public partial class JetHeraldBot
async Task StartDiscord() async Task StartDiscord()
{ {
if (string.IsNullOrWhiteSpace(DiscordConfig.Token))
return;
DiscordBot = new DiscordClient(new() DiscordBot = new DiscordClient(new()
{ {
Token = DiscordConfig.Token, Token = DiscordConfig.Token,

View File

@ -16,12 +16,13 @@ public partial class JetHeraldBot
CancellationTokenSource TelegramBotShutdownToken { get; } = new(); CancellationTokenSource TelegramBotShutdownToken { get; } = new();
async Task StartTelegram() async Task StartTelegram()
{ {
if (string.IsNullOrWhiteSpace(TelegramConfig.ApiKey))
return;
TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey); TelegramBot = new TelegramBotClient(TelegramConfig.ApiKey);
Me = await TelegramBot.GetMeAsync(); Me = await TelegramBot.GetMeAsync();
Commands = new ChatCommandRouter(Me.Username, Log); 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 SubscribeCommand(Db, TelegramBot), "subscribe", "sub");
Commands.Add(new UnsubscribeCommand(Db, TelegramBot), "unsubscribe", "unsub"); Commands.Add(new UnsubscribeCommand(Db, TelegramBot), "unsubscribe", "unsub");
Commands.Add(new ListCommand(Db, TelegramBot), "list"); Commands.Add(new ListCommand(Db, TelegramBot), "list");

View File

@ -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<AuthConfig> Cfg { get; }
public JetHeraldTicketStore(Db db, IOptionsMonitor<AuthConfig> 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<AuthenticationTicket> RetrieveAsync(string key)
{
var userSession = await Db.GetSession(key);
return TicketSerializer.Default.Deserialize(userSession.SessionData);
}
public Task<string> 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);
}
}

View File

@ -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);
}

View File

@ -6,12 +6,11 @@ public static class TokenHelper
static readonly byte[] buf = new byte[24]; static readonly byte[] buf = new byte[24];
static readonly object SyncLock = new(); static readonly object SyncLock = new();
public static string GetToken() public static string GetToken(int length = 32)
{ {
lock (SyncLock) var byteLength = (length + 3) / 4 * 3;
{ var bytes = RandomNumberGenerator.GetBytes(byteLength);
RandomNumberGenerator.Fill(buf); var str = Convert.ToBase64String(bytes).Substring(0, length);
return Convert.ToBase64String(buf).Replace('+', '_').Replace('/', '_'); return str.Replace('+', '_').Replace('/', '_');
}
} }
} }

View File

@ -0,0 +1,6 @@
<ul class="issues-list">
<li><a asp-action="CreateProject">Create new project code</a></li>
<li><a asp-action="ViewUsers">View all registered users</a></li>
<li><a asp-action="ViewInvites">View unredeemed invites</a></li>
<li><a asp-action="ViewRoles">View all roles</a></li>
</ul>

View File

@ -0,0 +1,27 @@
@model ViewInvitesModel
<form asp-controller="AdminTools" asp-action="CreateInvite" method="POST" enctype="application/x-www-form-urlencoded">
<label for="planselector">Plan: </label>
<select name="planId" required id="planselector">
@foreach (var plan in Model.Plans.Values)
{
<option value="@plan.PlanId">@plan.Name</option>
}
</select>
<input type="submit" value="Create invite" class="h2 submitpost" style="margin-top:10px; width:initial">
</form>
<br>
<hr>
<h3>Invites</h3>
<ul class="issues-list">
@foreach (var invite in Model.Invites)
{
<li>
<span style="font-family:monospace">@invite.InviteCode.Substring(0, 8)... (@Model.Plans[invite.PlanId].Name)</span>
<form asp-controller="Admin" asp-action="DeleteInvite" asp-route-inviteId="@invite.UserInviteId" method="POST" style="display:inline">
<input type="submit" value="❌" style="display:inline; color:red;" class="buttonlink">
</form>
<a asp-controller="Registration" asp-action="Register" asp-route-invite="@invite.InviteCode" class="copier" style="display:inline; color:blue">📤</a>
</li>
}
</ul>

View File

@ -0,0 +1,34 @@
@model DashboardViewModel
@Html.ValidationSummary(false, "", new {})
<a asp-controller="Topic" asp-action="Create"> Create new topic</a>
<p>
@foreach (var topic in @Model.Topics)
{
<p>
<span>@topic.Name</span>
<br>
<span>ReadToken: @topic.ReadToken</span>
<br>
<span>WriteToken: @topic.WriteToken</span>
<br>
@if (@Model.Hearts.Contains(topic.TopicId))
{
<table>
<tr><th>Heart</th> <th>Last beat</th> <th>Expires on</th></tr>
@foreach (var heart in @Model.Hearts[topic.TopicId])
{
<tr>
<td>@heart.Name </td> <td>@heart.LastBeatTs</td> <td>@heart.ExpiryTs</td>
</tr>
}
</table>
<br>
}
</p>
}
</p>

View File

@ -0,0 +1,18 @@
@Html.ValidationSummary(false, "", new {})
<form
asp-controller="Login" asp-action="Login"
asp-route-redirect="@ViewData["RedirectTo"]"
method="POST" type="application/x-www-form-urlencoded"
style="text-align:center; margin-top: 100px">
<input type="text" class="h2 blueunderline" name="username" placeholder="Username" required>
<br>
<br>
<input type="password" class="h2 blueunderline" name="password" placeholder="Password" required>
<br>
<br>
<input type="submit" class="h2 submitpost" value="Login">
<br>
<br>
<span style="color:#05b">Don't have an account? <a asp-controller="Registration" asp-action="Register">Register</a></span>
</form>

View File

@ -0,0 +1,2 @@
<h2 style="text-align:center">500 Internal Server Serror</h2>
Something happened.

View File

@ -0,0 +1,2 @@
<h2 style="text-align:center">400 Bad Request</h2>
bad

View File

@ -0,0 +1,2 @@
<h2 style="text-align:center">403 Forbidden</h2>
no

View File

@ -0,0 +1,2 @@
<h2 style="text-align:center">404 Not Found</h2>
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.

View File

@ -0,0 +1,8 @@
@model ProfileViewModel
@Html.ValidationSummary(false, "", new {})
<p>
<span>@Model.OrganizationName</span>
<br>
<span>@Model.JoinedOn</span>
</p>

View File

@ -0,0 +1,40 @@
@Html.ValidationSummary(false, "", new {})
<form method="POST" asp-action="Register" asp-controller="Registration"
class="register-grid" type="application/x-www-form-urlencoded"
asp-route-redirect="@ViewData["RedirectTo"]" >
<img id="inviteresult" class="icon valcol inviteidrow">
<input type="text" name="invitecode" placeholder="Invite code" id="invitetb"
class="h2 blueunderline fieldcol inviteidrow" autocomplete="off"
value="@(Context.Request.Query.ContainsKey("invite") ? Context.Request.Query["invite"].Last() : "")">
<div class="infocol inviteidrow">
Invite code supplied by admin.
</div>
<br>
<img id="nameresult" class="icon valcol namerow">
<input type="text" class="h2 blueunderline fieldcol namerow" id="nametb" name="name" placeholder="Organization name" minlength="6" maxlength="100">
<div class="infocol namerow">
Used for display.
<br>
You can change it later.
</div>
<br>
<img id="loginresult" class="icon valcol loginrow">
<input type="text" class="h2 blueunderline fieldcol loginrow" id="logintb" name="login" placeholder="Login" minlength="3" maxlength="64">
<div class="infocol loginrow">
Used for signing in and in the
<br>
address of your organization page.
</div>
<br>
<img id="passresult" class="icon valcol passrow">
<input type="password" class="h2 blueunderline fieldcol passrow" id="passtb" name="password" placeholder="Passphrase" minlength="6" maxlength="1024">
<div class="infocol passrow">
6-1024 characters.
<br>
You know how to make a strong password, right?
</div>
<br>
<input type="submit" class="h2 submitpost fieldcol buttonrow" value="Register" style="margin-top:10px; justify-self: center;">
</form>

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>JetHerald</title>
<link rel="stylesheet" href="~/css/global.css" />
@RenderSection("Stylesheets", required: false)
</head>
<body>
<header class="flex-container">
<div class="inner-element">
<div class="helper">
<nav>
<div class="helper">
<img src="~/img/PB.svg" alt="PBug" class="logo" width="32" height="32">
</div>
@if (Context.UserCan("dashboard"))
{
<a asp-action="Index" asp-controller="Dashboard">Dashboard</a>
}
@if (Context.UserCan("admintools"))
{
<a asp-action="Index" asp-controller="AdminTools">Admin tools</a>
}
@if (User.IsAnonymous())
{
<a asp-action="Login" asp-controller="Login">Login</a>
<a asp-action="Register" asp-controller="Registration">Register</a>
}
else
{
<a asp-action="LogOut" asp-controller="Login">Log Out</a>
}
</nav>
</div>
</div>
</header>
<div class="page-main">
@RenderBody()
</div>
<footer>
Rendered in @(Context.Features.Get<RequestTimeFeature>().Stopwatch.Elapsed.TotalMilliseconds) ms
</footer>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@ -0,0 +1,14 @@
@Html.ValidationSummary(false, "", new {})
<form
asp-controller="Topic" asp-action="Create"
method="POST" type="application/x-www-form-urlencoded"
style="text-align:center; margin-top: 100px">
<input type="text" class="h2 blueunderline" name="name" placeholder="Topic identifier" required>
<br>
<br>
<input type="text" class="h2 blueunderline" name="descr" placeholder="Description" required>
<br>
<br>
<input type="submit" class="h2 submitpost" value="Create">
</form>

View File

@ -0,0 +1,30 @@
@model TopicViewModel
@Html.ValidationSummary(false, "", new {})
<p>
@{var topic = @Model.Topic;}
<p>
<span>@topic.Name</span>
<br>
<span>ReadToken: @topic.ReadToken</span>
<br>
<span>WriteToken: @topic.WriteToken</span>
<br>
@if (@Model.Hearts.Any())
{
<table>
<tr><th>Heart</th> <th>Last beat</th> <th>Expires on</th></tr>
@foreach (var heart in @Model.Hearts)
{
<tr>
<td>@heart.Name </td> <td>@heart.LastBeatTs</td> <td>@heart.ExpiryTs</td>
</tr>
}
</table>
<br>
}
</p>
</p>

View File

@ -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

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -12,5 +12,10 @@
"HeartbeatCost": 60, "HeartbeatCost": 60,
"ReportCost": 60 "ReportCost": 60
}, },
"PathBase": "/" "PathBase": "/",
"AuthConfig": {
"InviteCodeLength" : 128,
"TicketIdLengthBytes": 64,
"HashType": 1
}
} }

View File

@ -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;
}

View File

@ -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; }

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49.88 50.48"><defs><style>.cls-1{fill:none;}.cls-2{fill:#fff;}.cls-3{fill:#ed1c24;}</style></defs><title>PB</title><path class="cls-1" d="M82.92,79.22h-15V87.3a3.78,3.78,0,0,0,.45,2.3,3.35,3.35,0,0,0,2.41.62H82.41c1.18,0,3.2-.06,4.32-1.4a6.64,6.64,0,0,0,1-4,7.76,7.76,0,0,0-.84-4C86.45,80.23,85.33,79.22,82.92,79.22Z" transform="translate(-45.5 -45.64)"/><path class="cls-2" d="M45.5,86.27V51.73c0-1.51,0-3.13,1.39-4.53a6.32,6.32,0,0,1,4.59-1.57H70.8c2.15,0,5.57,0,8.07,2.44,2.79,2.67,3.08,7.25,3.08,10.68s-.35,8.53-3.54,11.15a10.17,10.17,0,0,1-6.21,2h-19v14.4ZM67.73,65.78c1.68,0,3.66,0,5-1.51s1.33-4,1.33-5.57c0-1.8-.17-4.24-1.45-5.57s-3.48-1.39-4.76-1.39H55.48a2.25,2.25,0,0,0-1.8.52,2.49,2.49,0,0,0-.52,1.91V65.78Z" transform="translate(-45.5 -45.64)"/><path class="cls-3" d="M93.52,78.32a6.6,6.6,0,0,0-4.32-2.08V76a5,5,0,0,0,3.7-2c1.74-2.07,1.85-4.26,1.85-6.4,0-2.58-.28-6.17-2.47-8.42s-5-2.36-7.13-2.36H65.86a5.22,5.22,0,0,0-4.15,1.63,6.53,6.53,0,0,0-1.23,4v3.29h7.41V64.9a2.7,2.7,0,0,1,.5-1.74c.45-.5,1.07-.45,1.68-.45H82.19a4.88,4.88,0,0,1,3.65,1c1.23,1.18,1.29,3.14,1.29,4.21,0,2.69-.56,3.65-.84,4-1,1.4-2.58,1.4-4,1.4H67.88V71.88H60.47V90.55a5.94,5.94,0,0,0,1.35,4c1.57,1.8,3.37,1.57,5.33,1.57h18.8c2.41,0,4.94,0,7.18-2.52s2.25-5.67,2.25-8.25C95.38,83.14,95.38,80.34,93.52,78.32ZM86.73,88.81c-1.12,1.35-3.14,1.4-4.32,1.4H70.74a3.35,3.35,0,0,1-2.41-.62,3.78,3.78,0,0,1-.45-2.3V79.22h15c2.41,0,3.53,1,4,1.63a7.76,7.76,0,0,1,.84,4A6.64,6.64,0,0,1,86.73,88.81Z" transform="translate(-45.5 -45.64)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

View File

@ -0,0 +1,8 @@
$(function () {
$(".showable-container > .showable")
.css("display", "none")
.removeClass("hide");
$(".showable-container > .show-button").click(function () {
$(this).parent().children(".showable").toggle();
});
});

View File

@ -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("<span/>").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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}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('<li><div class="tag-editor-spacer">&nbsp;'+l.delimiter[0]+'</div><div class="tag-editor-tag"></div><div class="tag-editor-delete"><i></i></div></li>').appendTo(n).find(".tag-editor-tag").html('<input type="text" maxlength="'+l.maxLength+'">').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<n.length;i++){var o=t.trim(n[i]);o&&l.push(o)}return t(".tag-editor-tag",r).each(function(){~t.inArray(t(this).text(),l)&&t(this).closest("li").find(".tag-editor-delete").click()}),!1}}}),c.each(function(){function e(){!o.placeholder||c.length||t(".deleted, .placeholder, input",s).length||s.append('<li class="placeholder"><div>'+o.placeholder+"</div></li>")}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<d.length;u++)if(v=t.trim(d[u]).slice(0,o.maxLength),o.forceLowercase&&(v=v.toLowerCase()),a=o.beforeTagSave(r,s,f,g,v),v=a||v,a!==!1&&v&&(o.removeDuplicates&&~t.inArray(v,f)&&t(".tag-editor-tag",s).each(function(){t(this).text()==v&&t(this).closest("li").remove()}),f.push(v),n.before('<li><div class="tag-editor-spacer">&nbsp;'+o.delimiter[0]+'</div><div class="tag-editor-tag">'+l(v)+'</div><div class="tag-editor-delete"><i></i></div></li>'),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("<ul "+(o.clickDelete?'oncontextmenu="return false;" ':"")+'class="tag-editor"></ul>').insertAfter(r);r.addClass("tag-editor-hidden-src").data("options",o).on("focus.tag-editor",function(){s.click()}),s.append('<li style="width:1px">&nbsp;</li>');var d='<li><div class="tag-editor-spacer">&nbsp;'+o.delimiter[0]+'</div><div class="tag-editor-tag"></div><div class="tag-editor-delete"><i></i></div></li>';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.pageX<c?(r="before",a=c-e.pageX):(r="after",a=e.pageX-c-n.width()),l>a&&(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('<input type="text" maxlength="'+o.maxLength+'" value="'+l(i)+'">').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<h.length&&!(o.maxTags&&u>=o.maxTags);u++){var v=t.trim(h[u].replace(/ +/," "));v&&(o.forceLowercase&&(v=v.toLowerCase()),c.push(v),s.append('<li><div class="tag-editor-spacer">&nbsp;'+o.delimiter[0]+'</div><div class="tag-editor-tag">'+l(v)+'</div><div class="tag-editor-delete"><i></i></div></li>'))}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);