添加项目文件。

This commit is contained in:
Zel
2025-01-22 23:31:03 +08:00
parent 1b8ba6771f
commit 2ae76476fb
894 changed files with 774558 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.5",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@@ -0,0 +1,11 @@
@page
@model AccessDeniedModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Access denied.
</h3>
<p class="text-danger">You do not have access to this resource.</p>

View File

@@ -0,0 +1,28 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class AccessDeniedModel : PageModelBase
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
public AccessDeniedModel(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,11 @@
@page
@model ConfirmEmailModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Confirm your email address.
</h3>
<p><partial name="_StatusMessage" model="Model.StatusMessage" /></p>

View File

@@ -0,0 +1,48 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.Text;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class ConfirmEmailModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
public ConfirmEmailModel(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: base(signInManager)
{
_userManager = userManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync(string userId, string code)
{
if (userId == null || code == null)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Index");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return NotFound($"Unable to load user with ID '{userId}'.");
}
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var result = await _userManager.ConfirmEmailAsync(user, code);
StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
return Page();
}
}
}

View File

@@ -0,0 +1,11 @@
@page
@model ConfirmEmailChangeModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Confirm email change
</h3>
<p><partial name="_StatusMessage" model="Model.StatusMessage" /></p>

View File

@@ -0,0 +1,67 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.Text;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class ConfirmEmailChangeModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
public ConfirmEmailChangeModel(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync(string userId, string email, string code)
{
if (userId == null || email == null || code == null)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Index");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return NotFound($"Unable to load user with ID '{userId}'.");
}
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var result = await _userManager.ChangeEmailAsync(user, email, code);
if (!result.Succeeded)
{
StatusMessage = "Error changing email.";
return Page();
}
// In our UI email and user name are one and the same, so when we update the email
// we need to update the user name.
var setUserNameResult = await _userManager.SetUserNameAsync(user, email);
if (!setUserNameResult.Succeeded)
{
StatusMessage = "Error changing user name.";
return Page();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Thank you for confirming your email change.";
return Page();
}
}
}

View File

@@ -0,0 +1,37 @@
@page
@using System.Text.Encodings.Web
@model ExternalLoginModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Register
</h3>
<p>Associate your @Model.ProviderDisplayName account.</p>
<hr />
<p id="external-login-description" class="text-info">
You've successfully authenticated with <strong>@Model.ProviderDisplayName</strong>.
Please enter an email address for this site below and click the Register button to finish
logging in.
</p>
<div class="row">
<div class="col-md-4">
<form asp-page-handler="Confirmation" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl)" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email."/>
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,239 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class ExternalLoginModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly IUserStore<IdentityUser> _userStore;
private readonly IUserEmailStore<IdentityUser> _emailStore;
private readonly IWikiEmailSender _emailSender;
private readonly ILogger<ExternalLoginModel> _logger;
public ExternalLoginModel(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserStore<IdentityUser> userStore,
ILogger<ExternalLoginModel> logger,
IWikiEmailSender emailSender)
: base(signInManager)
{
_signInManager = signInManager;
_userManager = userManager;
_userStore = userStore;
_emailStore = GetEmailStore();
_logger = logger;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ProviderDisplayName { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string ErrorMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public IActionResult OnGet() => RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Login");
public IActionResult OnPost(string provider, string returnUrl = null)
{
// Request a redirect to the external login provider.
var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return new ChallengeResult(provider, properties);
}
public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
if (remoteError != null)
{
ErrorMessage = $"Error from external provider: {remoteError}";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Login", new { ReturnUrl = returnUrl });
}
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
ErrorMessage = "Error loading external login information.";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Login", new { ReturnUrl = returnUrl });
}
// Sign in the user with this external login provider if the user already has a login.
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (result.Succeeded)
{
_logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
return LocalRedirect(returnUrl);
}
if (result.IsLockedOut)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Lockout");
}
else
{
// If the user does not have an account, then ask the user to create an account.
ReturnUrl = returnUrl;
ProviderDisplayName = info.ProviderDisplayName;
if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
{
Input = new InputModel
{
Email = info.Principal.FindFirstValue(ClaimTypes.Email)
};
}
return Page();
}
}
public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
// Get the information about the user from the external login provider
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
ErrorMessage = "Error loading external login information during confirmation.";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Login", new { ReturnUrl = returnUrl });
}
if (ModelState.IsValid)
{
var user = CreateUser();
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user);
if (result.Succeeded == false)
{
result = await _userManager.AddLoginAsync(user, info);
if (result.Succeeded)
{
_logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = encodedCode },
protocol: Request.Scheme);
var emailTemplate = new StringBuilder(ConfigurationRepository.Get<string>("Membership", "Template: Account Verification Email"));
var basicConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Basic");
var siteName = basicConfig.Value<string>("Name");
var address = basicConfig.Value<string>("Address");
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(userId));
var emailSubject = "Confirm your email";
emailTemplate.Replace("##SUBJECT##", emailSubject);
emailTemplate.Replace("##ACCOUNTCOUNTRY##", profile.Country);
emailTemplate.Replace("##ACCOUNTTIMEZONE##", profile.TimeZone);
emailTemplate.Replace("##ACCOUNTLANGUAGE##", profile.Language);
emailTemplate.Replace("##ACCOUNTEMAIL##", profile.EmailAddress);
emailTemplate.Replace("##ACCOUNTNAME##", profile.AccountName);
emailTemplate.Replace("##PERSONNAME##", $"{profile.FirstName} {profile.LastName}");
emailTemplate.Replace("##CODE##", code);
emailTemplate.Replace("##USERID##", userId);
emailTemplate.Replace("##SITENAME##", siteName);
emailTemplate.Replace("##SITEADDRESS##", address);
emailTemplate.Replace("##CALLBACKURL##", HtmlEncoder.Default.Encode(callbackUrl));
await _emailSender.SendEmailAsync(Input.Email, emailSubject, emailTemplate.ToString());
// If account confirmation is required, we need to show the link if we don't have a real email sender
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/RegisterConfirmation", new { Email = Input.Email });
}
await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
return LocalRedirect(returnUrl);
}
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
ProviderDisplayName = info.ProviderDisplayName;
ReturnUrl = returnUrl;
return Page();
}
private IdentityUser CreateUser()
{
try
{
return Activator.CreateInstance<IdentityUser>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(IdentityUser)}'. " +
$"Ensure that '{nameof(IdentityUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
$"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml");
}
}
private IUserEmailStore<IdentityUser> GetEmailStore()
{
if (!_userManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<IdentityUser>)_userStore;
}
}
}

View File

@@ -0,0 +1,97 @@
@page
@using System.Text.Encodings.Web
@model ExternalLoginSupplementalModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Signup. Almost there...
</h3>
<p>
You have successfully signed in with the external provider but we still need a bit of information.<br /><br />
</p>
<form asp-page-handler="Confirmation" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl ?? "")" method="post">
<div class="container">
<div class="form-group row mb-3">
<label for="Input.AccountName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.AccountName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.AccountName, new { @class = "form-control", placeholder = "required" })
<small class="form-text text-muted">This account name will be visible publicly.</small>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.AccountName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.FirstName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.FirstName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.FirstName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.FirstName)</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="Input.LastName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.LastName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.LastName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.LastName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.Country" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Country)</strong></label>
<div class="col-sm-10">
<select name="Input.Country" id="Input.Country" class="form-control">
<option value="" style="color:#ccc !important;">Select a country</option>
@foreach (var item in Model.Input.Countries)
{
<option value="@item.Value" selected=@(Model.Input.Country == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Country)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.Language" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Language)</strong></label>
<div class="col-sm-10">
<select name="Input.Language" id="Input.Language" class="form-control">
<option value="" style="color:#ccc !important;">Select a language</option>
@foreach (var item in Model.Input.Languages)
{
<option value="@item.Value" selected=@(Model.Input.Language == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Language)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.TimeZone" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.TimeZone)</strong></label>
<div class="col-sm-10">
<select name="Input.TimeZone" id="Input.TimeZone" class="form-control">
<option value="" style="color:#ccc !important;">Select a time-zone</option>
@foreach (var item in Model.Input.TimeZones)
{
<option value="@item.Value" selected=@(Model.Input.TimeZone == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.TimeZone)</div>
</div>
</div>
<div class="form-group row mb-1">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-primary rounded-0">Complete Signup!</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,165 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using TightWiki.Library;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class ExternalLoginSupplementalModel : PageModelBase
{
[BindProperty]
public string? ReturnUrl { get; set; }
private UserManager<IdentityUser> _userManager;
public ExternalLoginSupplementalModel(SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager, IUserStore<IdentityUser> userStore)
: base(signInManager)
{
_userManager = userManager;
}
[BindProperty]
public InputModel Input { get; set; } = new InputModel();
public class InputModel
{
public List<TimeZoneItem> TimeZones { get; set; } = new();
public List<CountryItem> Countries { get; set; } = new();
public List<LanguageItem> Languages { get; set; } = new();
[Display(Name = "Account Name")]
[Required(ErrorMessage = "Account Name is required")]
public string AccountName { get; set; } = string.Empty;
[Display(Name = "First Name")]
public string? FirstName { get; set; }
[Display(Name = "Last Name")]
public string? LastName { get; set; } = string.Empty;
[Display(Name = "Time-Zone")]
[Required(ErrorMessage = "TimeZone is required")]
public string TimeZone { get; set; } = string.Empty;
[Display(Name = "Country")]
[Required(ErrorMessage = "Country is required")]
public string Country { get; set; } = string.Empty;
[Display(Name = "Language")]
[Required(ErrorMessage = "Language is required")]
public string Language { get; set; } = string.Empty;
}
public IActionResult OnGet()
{
ReturnUrl ??= Url.Content("~/");
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
PopulateDefaults();
return Page();
}
private void PopulateDefaults()
{
Input.TimeZones = TimeZoneItem.GetAll();
Input.Countries = CountryItem.GetAll();
Input.Languages = LanguageItem.GetAll();
var membershipConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Membership");
if (string.IsNullOrEmpty(Input.TimeZone))
Input.TimeZone = membershipConfig.Value<string>("Default TimeZone").EnsureNotNull();
if (string.IsNullOrEmpty(Input.Country))
Input.Country = membershipConfig.Value<string>("Default Country").EnsureNotNull();
if (string.IsNullOrEmpty(Input.Language))
Input.Language = membershipConfig.Value<string>("Default Language").EnsureNotNull();
}
public async Task<IActionResult> OnPostAsync()
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
PopulateDefaults();
if (!ModelState.IsValid)
{
return Page();
}
if (string.IsNullOrWhiteSpace(Input.AccountName))
{
ModelState.AddModelError("Input.AccountName", "Account Name is required.");
return Page();
}
else if (UsersRepository.DoesProfileAccountExist(Input.AccountName))
{
ModelState.AddModelError("Input.AccountName", "Account Name is already in use.");
return Page();
}
var info = await SignInManager.GetExternalLoginInfoAsync();
if (info == null)
{
return NotifyOfError("An error occurred retrieving user information from the external provider.");
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email).EnsureNotNull();
if (string.IsNullOrEmpty(email))
{
return NotifyOfError("The email address was not supplied by the external provider.");
}
var user = new IdentityUser { UserName = email, Email = email };
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
{
return NotifyOfError("An error occurred while creating the user.");
}
result = await _userManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
return NotifyOfError("An error occurred while adding the login.");
}
UsersRepository.CreateProfile(Guid.Parse(user.Id), Input.AccountName);
var membershipConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Membership");
var claimsToAdd = new List<Claim>
{
new (ClaimTypes.Role, membershipConfig.Value<string>("Default Signup Role").EnsureNotNull()),
new ("timezone", Input.TimeZone),
new (ClaimTypes.Country, Input.Country),
new ("language", Input.Language),
new ("firstname", Input.FirstName ?? ""),
new ("lastname", Input.LastName ?? ""),
};
SecurityRepository.UpsertUserClaims(_userManager, user, claimsToAdd);
await SignInManager.SignInAsync(user, isPersistent: false);
if (string.IsNullOrEmpty(ReturnUrl))
{
return LocalRedirect($"{GlobalConfiguration.BasePath}/");
}
return LocalRedirect(ReturnUrl);
}
}
}

View File

@@ -0,0 +1,30 @@
@page
@model ForgotPasswordModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Forgot your password?
</h3>
<p>Enter your email.</p>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset Password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,99 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class ForgotPasswordModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly IWikiEmailSender _emailSender;
public ForgotPasswordModel(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, IWikiEmailSender emailSender)
: base(signInManager)
{
_userManager = userManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null || !(await _userManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ForgotPasswordConfirmation");
}
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ResetPassword",
pageHandler: null,
values: new { area = "Identity", encodedCode },
protocol: Request.Scheme);
var emailTemplate = new StringBuilder(ConfigurationRepository.Get<string>("Membership", "Template: Reset Password Email"));
var basicConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Basic");
var siteName = basicConfig.Value<string>("Name");
var address = basicConfig.Value<string>("Address");
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(user.Id));
var emailSubject = "Reset password";
emailTemplate.Replace("##SUBJECT##", emailSubject);
emailTemplate.Replace("##ACCOUNTCOUNTRY##", profile.Country);
emailTemplate.Replace("##ACCOUNTTIMEZONE##", profile.TimeZone);
emailTemplate.Replace("##ACCOUNTLANGUAGE##", profile.Language);
emailTemplate.Replace("##ACCOUNTEMAIL##", profile.EmailAddress);
emailTemplate.Replace("##ACCOUNTNAME##", profile.AccountName);
emailTemplate.Replace("##PERSONNAME##", $"{profile.FirstName} {profile.LastName}");
emailTemplate.Replace("##CODE##", code);
emailTemplate.Replace("##USERID##", user.Id);
emailTemplate.Replace("##SITENAME##", siteName);
emailTemplate.Replace("##SITEADDRESS##", address);
emailTemplate.Replace("##CALLBACKURL##", HtmlEncoder.Default.Encode(callbackUrl));
await _emailSender.SendEmailAsync(Input.Email, emailSubject, emailTemplate.ToString());
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ForgotPasswordConfirmation");
}
return Page();
}
}
}

View File

@@ -0,0 +1,13 @@
@page
@model ForgotPasswordConfirmation
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Forgot password confirmation
</h3>
<p>
Please check your email to reset your password.
</p>

View File

@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
public class ForgotPasswordConfirmation : PageModelBase
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
public ForgotPasswordConfirmation(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,11 @@
@page
@model LockoutModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Locked out
</h3>
<p>This account has been locked out, please try again later.</p>

View File

@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
public class LockoutModel : PageModelBase
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
public LockoutModel(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,88 @@
@page
@using System.Text.Encodings.Web
@using TightWiki.Models
@model LoginModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<div class="row">
<div class="col-sm-12 col-md-8 col-lg-4">
<section>
<form id="account" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label">Email</label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
<label asp-for="Input.Password" class="form-label">Password</label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="checkbox mb-3">
<label asp-for="Input.RememberMe" class="form-label">
<input class="form-check-input" asp-for="Input.RememberMe" />
@Html.DisplayNameFor(m => m.Input.RememberMe)
</label>
</div>
<div>
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
<br />
</div>
<div>
<p>
<a id="forgot-password" asp-page="./ForgotPassword">Forgot your password?</a>
<br />
@if (TightWiki.Models.GlobalConfiguration.AllowSignup == true)
{
<a asp-page="./Register" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl)">Register as a new user</a>
<br />
}
<a id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend email confirmation</a>
<br />
</p>
</div>
</form>
</section>
<section>
@{
if ((Model.ExternalLogins?.Count ?? 0) > 0)
{
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl)" method="post" class="form-horizontal">
<div>
<p>
@foreach (var provider in Model.ExternalLogins!)
{
if (provider.Name == "Google")
{
<button type="submit" class="btn w-100 mb-3 d-flex align-items-center" name="provider" value="@provider.Name" title="Log in using your Google account">
<img src="@GlobalConfiguration.BasePath/images/external/google-signin.svg" alt="Google" class="me-2" />
</button>
}
else if (provider.Name == "Microsoft")
{
<button type="submit" class="btn w-100 mb-3 d-flex align-items-center" name="provider" value="@provider.Name" title="Log in using your Microsoft account">
<img src="@GlobalConfiguration.BasePath/images/external/microsoft-signin.svg" alt="Microsoft" class="me-2" />
</button>
}
else
{
<button type="submit" class="btn btn-primary w-100 mb-3" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">
@provider.DisplayName
</button>
}
}
</p>
</div>
</form>
}
}
</section>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,134 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class LoginModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<LoginModel> _logger;
public LoginModel(SignInManager<IdentityUser> signInManager, ILogger<LoginModel> logger)
: base(signInManager)
{
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> ExternalLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string ErrorMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
public async Task OnGetAsync(string returnUrl = null)
{
if (!string.IsNullOrEmpty(ErrorMessage))
{
ModelState.AddModelError(string.Empty, ErrorMessage);
}
returnUrl ??= Url.Content("~/");
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
ReturnUrl = returnUrl;
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return Redirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
// If we got this far, something failed, redisplay form
return Page();
}
}
}

View File

@@ -0,0 +1,44 @@
@page
@using System.Text.Encodings.Web
@model LoginWith2faModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Two-factor authentication
</h3>
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl)">
<input asp-for="RememberMe" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.TwoFactorCode" class="form-control" autocomplete="off" />
<label asp-for="Input.TwoFactorCode" class="form-label"></label>
<span asp-validation-for="Input.TwoFactorCode" class="text-danger"></span>
</div>
<div class="checkbox mb-3">
<label asp-for="Input.RememberMachine" class="form-label">
<input asp-for="Input.RememberMachine" />
@Html.DisplayNameFor(m => m.Input.RememberMachine)
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</form>
</div>
</div>
<p>
Don't have access to your authenticator device? You can
<a id="recovery-code-login" asp-page="./LoginWithRecoveryCode" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl)">log in with a recovery code</a>.
</p>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,127 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class LoginWith2faModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<LoginWith2faModel> _logger;
public LoginWith2faModel(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
ILogger<LoginWith2faModel> logger)
: base(signInManager)
{
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool RememberMe { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string TwoFactorCode { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
public async Task<IActionResult> OnGetAsync(bool rememberMe, string returnUrl = null)
{
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
ReturnUrl = returnUrl;
RememberMe = rememberMe;
return Page();
}
public async Task<IActionResult> OnPostAsync(bool rememberMe, string returnUrl = null)
{
if (!ModelState.IsValid)
{
return Page();
}
returnUrl = returnUrl ?? Url.Content("~/");
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
var authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);
var userId = await _userManager.GetUserIdAsync(user);
if (result.Succeeded)
{
_logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id);
return LocalRedirect(returnUrl);
}
else if (result.IsLockedOut)
{
_logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id);
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Lockout");
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return Page();
}
}
}
}

View File

@@ -0,0 +1,33 @@
@page
@model LoginWithRecoveryCodeModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Recovery code verification
</h3>
<p>
You have requested to log in with a recovery code. This login will not be remembered until you provide
an authenticator app code at log in or disable 2FA and log in again.
</p>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
<label asp-for="Input.RecoveryCode" class="form-label"></label>
<span asp-validation-for="Input.RecoveryCode" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,110 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class LoginWithRecoveryCodeModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<LoginWithRecoveryCodeModel> _logger;
public LoginWithRecoveryCodeModel(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
ILogger<LoginWithRecoveryCodeModel> logger)
: base(signInManager)
{
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
[Required]
[DataType(DataType.Text)]
[Display(Name = "Recovery Code")]
public string RecoveryCode { get; set; }
}
public async Task<IActionResult> OnGetAsync(string returnUrl = null)
{
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
ReturnUrl = returnUrl;
return Page();
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
var userId = await _userManager.GetUserIdAsync(user);
if (result.Succeeded)
{
_logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id);
return LocalRedirect(returnUrl ?? Url.Content("~/"));
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/Lockout");
}
else
{
_logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return Page();
}
}
}
}

View File

@@ -0,0 +1,29 @@
@page
@model LogoutModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Logout
</h3>
<p>
Logging out will make sure that others cant use this session to make changes to the wiki. You'll need to log back in before making changes.<br /><br />
</p>
<hr />
<header>
@{
if (User.Identity?.IsAuthenticated ?? false)
{
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="btn btn-primary rounded-0">Logout</button>
</form>
}
else
{
<p>You have successfully logged out of the application.</p>
}
}
</header>

View File

@@ -0,0 +1,107 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class LogoutModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<LogoutModel> _logger;
private readonly IAuthenticationSchemeProvider _schemeProvider;
private readonly UserManager<IdentityUser> _userManager;
public LogoutModel(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager, ILogger<LogoutModel> logger, IAuthenticationSchemeProvider schemeProvider)
: base(signInManager)
{
_schemeProvider = schemeProvider;
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
public async Task<IActionResult> OnPost(string returnUrl = null)
{
await _signInManager.SignOutAsync();
/*
var allSchemes = await _schemeProvider.GetAllSchemesAsync();
foreach (var scheme in allSchemes)
{
try
{
await HttpContext.SignOutAsync(scheme.Name);
}
catch
{
}
}
*/
/*
// Explicitly delete the cookie with the correct path.
Response.Cookies.Delete(".AspNetCore.Identity.Application", new CookieOptions
{
Path = "/TightWiki", // Must match the cookie's path.
HttpOnly = true,
Secure = true // Use this if the cookie is secure.
});
*/
/*
if (HttpContext.Request.Cookies.Count > 0)
{
var siteCookies = HttpContext.Request.Cookies
.Where(c => c.Key.Contains(".AspNetCore.")
|| c.Key.Contains("Microsoft.Authentication"));
foreach (var cookie in siteCookies)
{
Response.Cookies.Delete(cookie.Key);
}
}
await HttpContext.SignOutAsync(
user.AuthenticationScheme);
HttpContext.Session.Clear();
await _signInManager.SignOutAsync();
await HttpContext.SignOutAsync();
var allSchemes = await _schemeProvider.GetAllSchemesAsync();
foreach (var scheme in allSchemes)
{
try
{
await HttpContext.SignOutAsync(scheme.Name);
}
catch
{
}
}
foreach (var cookie in Request.Cookies.Keys)
{
Response.Cookies.Delete(cookie);
}
*/
_logger.LogInformation("User logged out.");
if (returnUrl != null)
{
return LocalRedirect(returnUrl);
}
else
{
// This needs to be a redirect so that the browser performs a new
// request and the identity for the user gets updated.
return RedirectToPage();
}
}
}
}

View File

@@ -0,0 +1,37 @@
@page
@model ChangePasswordModel
@{
ViewData["Title"] = "Change password";
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>@ViewData["Title"]</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<form id="change-password-form" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
<label asp-for="Input.OldPassword" class="form-label"></label>
<span asp-validation-for="Input.OldPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
<label asp-for="Input.NewPassword" class="form-label"></label>
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,138 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TightWiki.Library;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class ChangePasswordModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<ChangePasswordModel> _logger;
public ChangePasswordModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
ILogger<ChangePasswordModel> logger)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[DataType(DataType.Password)]
[Display(Name = "Current password")]
public string OldPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var hasPassword = await _userManager.HasPasswordAsync(user);
if (!hasPassword)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/SetPassword");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(user.Id));
if (user == null)
{
return NotFound($"Unable to load profile with ID '{_userManager.GetUserId(User)}'.");
}
var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
if (!changePasswordResult.Succeeded)
{
foreach (var error in changePasswordResult.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
if (profile.AccountName.Equals(Constants.DEFAULTACCOUNT, StringComparison.CurrentCultureIgnoreCase))
{
UsersRepository.SetAdminPasswordIsChanged();
}
await _signInManager.RefreshSignInAsync(user);
_logger.LogInformation("User changed their password successfully.");
StatusMessage = "Your password has been changed.";
return RedirectToPage();
}
}
}

View File

@@ -0,0 +1,26 @@
@page
@model Disable2faModel
@{
ViewData["Title"] = "Disable two-factor authentication (2FA)";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>This action only disables 2FA.</strong>
</p>
<p>
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form method="post">
<button class="btn btn-danger" type="submit">Disable 2FA</button>
</form>
</div>

View File

@@ -0,0 +1,67 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class Disable2faModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<Disable2faModel> _logger;
public Disable2faModel(SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
ILogger<Disable2faModel> logger)
: base(signInManager)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!await _userManager.GetTwoFactorEnabledAsync(user))
{
throw new InvalidOperationException($"Cannot disable 2FA for user as it's not currently enabled.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
if (!disable2faResult.Succeeded)
{
throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
}
_logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/TwoFactorAuthentication");
}
}
}

View File

@@ -0,0 +1,45 @@
@page
@model EmailModel
@{
ViewData["Title"] = "Manage Email";
ViewData["ActivePage"] = ManageNavPages.Email;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>@ViewData["Title"]</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<form id="email-form" method="post">
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
@if (Model.IsEmailConfirmed)
{
<div class="form-floating mb-3 input-group">
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
<div class="input-group-append">
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
</div>
<label asp-for="Email" class="form-label"></label>
</div>
}
else
{
<div class="form-floating mb-3">
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
<label asp-for="Email" class="form-label"></label>
<button id="email-verification" type="submit" asp-page-handler="SendVerificationEmail" class="btn btn-link">Send verification email</button>
</div>
}
<div class="form-floating mb-3">
<input asp-for="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email." />
<label asp-for="Input.NewEmail" class="form-label"></label>
<span asp-validation-for="Input.NewEmail" class="text-danger"></span>
</div>
<button id="change-email-button" type="submit" asp-page-handler="ChangeEmail" class="w-100 btn btn-lg btn-primary">Change email</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,189 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Library.Interfaces;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class EmailModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly IWikiEmailSender _emailSender;
public EmailModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
IWikiEmailSender emailSender)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool IsEmailConfirmed { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
[Display(Name = "New email")]
public string NewEmail { get; set; }
}
private async Task LoadAsync(IdentityUser user)
{
var email = await _userManager.GetEmailAsync(user);
Email = email;
Input = new InputModel
{
NewEmail = email,
};
IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user);
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await LoadAsync(user);
return Page();
}
public async Task<IActionResult> OnPostChangeEmailAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadAsync(user);
return Page();
}
var email = await _userManager.GetEmailAsync(user);
if (Input.NewEmail != email)
{
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmailChange",
pageHandler: null,
values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = encodedCode },
protocol: Request.Scheme);
var emailTemplate = new StringBuilder(ConfigurationRepository.Get<string>("Membership", "Template: Account Verification Email"));
var basicConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Basic");
var siteName = basicConfig.Value<string>("Name");
var address = basicConfig.Value<string>("Address");
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(userId));
var emailSubject = "Confirm your email";
emailTemplate.Replace("##SUBJECT##", emailSubject);
emailTemplate.Replace("##ACCOUNTCOUNTRY##", profile.Country);
emailTemplate.Replace("##ACCOUNTTIMEZONE##", profile.TimeZone);
emailTemplate.Replace("##ACCOUNTLANGUAGE##", profile.Language);
emailTemplate.Replace("##ACCOUNTEMAIL##", profile.EmailAddress);
emailTemplate.Replace("##ACCOUNTNAME##", profile.AccountName);
emailTemplate.Replace("##PERSONNAME##", $"{profile.FirstName} {profile.LastName}");
emailTemplate.Replace("##CODE##", code);
emailTemplate.Replace("##USERID##", userId);
emailTemplate.Replace("##SITENAME##", siteName);
emailTemplate.Replace("##SITEADDRESS##", address);
emailTemplate.Replace("##CALLBACKURL##", HtmlEncoder.Default.Encode(callbackUrl));
await _emailSender.SendEmailAsync(Input.NewEmail, emailSubject, emailTemplate.ToString());
StatusMessage = "Confirmation link to change email sent. Please check your email.";
return RedirectToPage();
}
StatusMessage = "Your email is unchanged.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostSendVerificationEmailAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadAsync(user);
return Page();
}
var userId = await _userManager.GetUserIdAsync(user);
var email = await _userManager.GetEmailAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
email,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
StatusMessage = "Verification email sent. Please check your email.";
return RedirectToPage();
}
}
}

View File

@@ -0,0 +1,54 @@
@page
@model EnableAuthenticatorModel
@{
ViewData["Title"] = "Configure authenticator app";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div>
<p>To use an authenticator app go through the following steps:</p>
<ol class="list">
<li>
<p>
Download a two-factor authenticator app like Microsoft Authenticator for
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
Google Authenticator for
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&amp;hl=en">Android</a> and
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
</p>
</li>
<li>
<p>Scan the QR Code or enter this key <kbd>@Model.SharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
<div id="qrCode"></div>
<div id="qrCodeData" data-url="@Model.AuthenticatorUri"></div>
</li>
<li>
<p>
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
with a unique code. Enter the code in the confirmation box below.
</p>
<div class="row">
<div class="col-md-6">
<form id="send-code" method="post">
<div class="form-floating mb-3">
<input asp-for="Input.Code" class="form-control" autocomplete="off" placeholder="Please enter the code."/>
<label asp-for="Input.Code" class="control-label form-label">Verification Code</label>
<span asp-validation-for="Input.Code" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
</form>
</div>
</div>
</li>
</ol>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,185 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class EnableAuthenticatorModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<EnableAuthenticatorModel> _logger;
private readonly UrlEncoder _urlEncoder;
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
public EnableAuthenticatorModel(SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
ILogger<EnableAuthenticatorModel> logger,
UrlEncoder urlEncoder)
: base(signInManager)
{
_userManager = userManager;
_logger = logger;
_urlEncoder = urlEncoder;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string SharedKey { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string AuthenticatorUri { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Verification Code")]
public string Code { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
// Strip spaces and hyphens
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
if (!is2faTokenValid)
{
ModelState.AddModelError("Input.Code", "Verification code is invalid.");
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
var userId = await _userManager.GetUserIdAsync(user);
_logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
StatusMessage = "Your authenticator app has been verified.";
if (await _userManager.CountRecoveryCodesAsync(user) == 0)
{
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
RecoveryCodes = recoveryCodes.ToArray();
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ShowRecoveryCodes");
}
else
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/TwoFactorAuthentication");
}
}
private async Task LoadSharedKeyAndQrCodeUriAsync(IdentityUser user)
{
// Load the authenticator key & QR code URI to display on the form
var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
}
SharedKey = FormatKey(unformattedKey);
var email = await _userManager.GetEmailAsync(user);
AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
}
private string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
private string GenerateQrCodeUri(string email, string unformattedKey)
{
return string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
_urlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
_urlEncoder.Encode(email),
unformattedKey);
}
}
}

View File

@@ -0,0 +1,52 @@
@page
@model ExternalLoginsModel
@{
ViewData["Title"] = "Manage your external logins";
ViewData["ActivePage"] = ManageNavPages.ExternalLogins;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
@if (Model.CurrentLogins?.Count > 0)
{
<h3>Registered Logins</h3>
<table class="table">
<tbody>
@foreach (var login in Model.CurrentLogins)
{
<tr>
<td id="@($"login-provider-{login.LoginProvider}")">@login.ProviderDisplayName</td>
<td>
@if (Model.ShowRemoveButton)
{
<form id="@($"remove-login-{login.LoginProvider}")" asp-page-handler="RemoveLogin" method="post">
<div>
<input asp-for="@login.LoginProvider" name="LoginProvider" type="hidden" />
<input asp-for="@login.ProviderKey" name="ProviderKey" type="hidden" />
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
</div>
</form>
}
else
{
@: &nbsp;
}
</td>
</tr>
}
</tbody>
</table>
}
@if (Model.OtherLogins?.Count > 0)
{
<form id="link-login-form" asp-page-handler="LinkLogin" method="post" class="form-horizontal">
<div id="socialLoginList">
<p>
@foreach (var provider in Model.OtherLogins)
{
<button id="@($"link-login-button-{provider.Name}")" type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
}
</p>
</div>
</form>
}

View File

@@ -0,0 +1,136 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class ExternalLoginsModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly IUserStore<IdentityUser> _userStore;
public ExternalLoginsModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
IUserStore<IdentityUser> userStore)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_userStore = userStore;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<UserLoginInfo> CurrentLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> OtherLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool ShowRemoveButton { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
CurrentLogins = await _userManager.GetLoginsAsync(user);
OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync())
.Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider))
.ToList();
string passwordHash = null;
if (_userStore is IUserPasswordStore<IdentityUser> userPasswordStore)
{
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
}
ShowRemoveButton = passwordHash != null || CurrentLogins.Count > 1;
return Page();
}
public async Task<IActionResult> OnPostRemoveLoginAsync(string loginProvider, string providerKey)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey);
if (!result.Succeeded)
{
StatusMessage = "The external login was not removed.";
return RedirectToPage();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "The external login was removed.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostLinkLoginAsync(string provider)
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
// Request a redirect to the external login provider to link a login for the current user
var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback");
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));
return new ChallengeResult(provider, properties);
}
public async Task<IActionResult> OnGetLinkLoginCallbackAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var userId = await _userManager.GetUserIdAsync(user);
var info = await _signInManager.GetExternalLoginInfoAsync(userId);
if (info == null)
{
throw new InvalidOperationException($"Unexpected error occurred loading external login info.");
}
var result = await _userManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
StatusMessage = "The external login was not added. External logins can only be associated with one account.";
return RedirectToPage();
}
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
StatusMessage = "The external login was added.";
return RedirectToPage();
}
}
}

View File

@@ -0,0 +1,28 @@
@page
@model GenerateRecoveryCodesModel
@{
ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
<p>
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form method="post">
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
</form>
</div>

View File

@@ -0,0 +1,79 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class GenerateRecoveryCodesModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly ILogger<GenerateRecoveryCodesModel> _logger;
public GenerateRecoveryCodesModel(SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
ILogger<GenerateRecoveryCodesModel> logger)
: base(signInManager)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException($"Cannot generate recovery codes for user because they do not have 2FA enabled.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
var userId = await _userManager.GetUserIdAsync(user);
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException($"Cannot generate recovery codes for user as they do not have 2FA enabled.");
}
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
RecoveryCodes = recoveryCodes.ToArray();
_logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
StatusMessage = "You have generated new recovery codes.";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ShowRecoveryCodes");
}
}
}

View File

@@ -0,0 +1,4 @@
@page
@model IndexModel
@{
}

View File

@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class IndexModel : PageModel
{
public IActionResult OnGetAsync()
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/Manage/Email");
}
}
}

View File

@@ -0,0 +1,74 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Mvc.Rendering;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static class ManageNavPages
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string Email => "Email";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ChangePassword => "ChangePassword";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ExternalLogins => "ExternalLogins";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string TwoFactorAuthentication => "TwoFactorAuthentication";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string PageNavClass(ViewContext viewContext, string page)
{
var activePage = viewContext.ViewData["ActivePage"] as string
?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
}
}
}

View File

@@ -0,0 +1,25 @@
@page
@model ResetAuthenticatorModel
@{
ViewData["Title"] = "Reset authenticator key";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
</p>
<p>
This process disables 2FA until you verify your authenticator app.
If you do not complete your authenticator app configuration you may lose access to your account.
</p>
</div>
<div>
<form id="reset-authenticator-form" method="post">
<button id="reset-authenticator-button" class="btn btn-danger" type="submit">Reset authenticator key</button>
</form>
</div>

View File

@@ -0,0 +1,66 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class ResetAuthenticatorModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<ResetAuthenticatorModel> _logger;
public ResetAuthenticatorModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
ILogger<ResetAuthenticatorModel> logger)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user);
var userId = await _userManager.GetUserIdAsync(user);
_logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id);
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/EnableAuthenticator");
}
}
}

View File

@@ -0,0 +1,36 @@
@page
@model SetPasswordModel
@{
ViewData["Title"] = "Set password";
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>Set your password</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<p class="text-info">
You do not have a local username/password for this site. Add a local
account so you can log in without an external login.
</p>
<div class="row">
<div class="col-md-6">
<form id="set-password-form" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password."/>
<label asp-for="Input.NewPassword" class="form-label"></label>
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,114 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class SetPasswordModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
public SetPasswordModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var hasPassword = await _userManager.HasPasswordAsync(user);
if (hasPassword)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ChangePassword");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword);
if (!addPasswordResult.Succeeded)
{
foreach (var error in addPasswordResult.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Your password has been set.";
return RedirectToPage();
}
}
}

View File

@@ -0,0 +1,26 @@
@page
@model ShowRecoveryCodesModel
@{
ViewData["Title"] = "Recovery codes";
ViewData["ActivePage"] = "TwoFactorAuthentication";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
</div>
<div class="row">
<div class="col-md-12">
@for (var row = 0; row < Model.RecoveryCodes.Length; row += 2)
{
<code class="recovery-code">@Model.RecoveryCodes[row]</code><text>&nbsp;</text><code class="recovery-code">@Model.RecoveryCodes[row + 1]</code><br />
}
</div>
</div>

View File

@@ -0,0 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class ShowRecoveryCodesModel : PageModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IActionResult OnGet()
{
if (RecoveryCodes == null || RecoveryCodes.Length == 0)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/TwoFactorAuthentication");
}
return Page();
}
}
}

View File

@@ -0,0 +1,72 @@
@page
@using Microsoft.AspNetCore.Http.Features
@model TwoFactorAuthenticationModel
@{
ViewData["Title"] = "Two-factor authentication (2FA)";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
@{
var consentFeature = HttpContext.Features.Get<ITrackingConsentFeature>();
@if (consentFeature?.CanTrack ?? true)
{
@if (Model.Is2faEnabled)
{
if (Model.RecoveryCodesLeft == 0)
{
<div class="alert alert-danger">
<strong>You have no recovery codes left.</strong>
<p>You must <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
</div>
}
else if (Model.RecoveryCodesLeft == 1)
{
<div class="alert alert-danger">
<strong>You have 1 recovery code left.</strong>
<p>You can <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
else if (Model.RecoveryCodesLeft <= 3)
{
<div class="alert alert-warning">
<strong>You have @Model.RecoveryCodesLeft recovery codes left.</strong>
<p>You should <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
if (Model.IsMachineRemembered)
{
<form method="post" style="display: inline-block">
<button type="submit" class="btn btn-primary">Forget this browser</button>
</form>
}
<a asp-page="./Disable2fa" class="btn btn-primary">Disable 2FA</a>
<a asp-page="./GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
}
<h4>Authenticator app</h4>
@if (!Model.HasAuthenticator)
{
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
}
else
{
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
<a id="reset-authenticator" asp-page="./ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
}
}
else
{
<div class="alert alert-danger">
<strong>Privacy and cookie policy have not been accepted.</strong>
<p>You must accept the policy before you can enable two factor authentication.</p>
</div>
}
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,86 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace TightWiki.Areas.Identity.Pages.Account.Manage
{
public class TwoFactorAuthenticationModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
private readonly ILogger<TwoFactorAuthenticationModel> _logger;
public TwoFactorAuthenticationModel(
UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, ILogger<TwoFactorAuthenticationModel> logger)
: base(signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool HasAuthenticator { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public int RecoveryCodesLeft { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public bool Is2faEnabled { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool IsMachineRemembered { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user);
RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await _signInManager.ForgetTwoFactorClientAsync();
StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.";
return RedirectToPage();
}
}
}

View File

@@ -0,0 +1,14 @@
@inject SignInManager<IdentityUser> SignInManager
@{
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<ul class="nav nav-pills flex-column">
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
@if (hasExternalLogins)
{
<li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
}
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
</ul>

View File

@@ -0,0 +1,10 @@
@model string
@if (!String.IsNullOrEmpty(Model))
{
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@Model
</div>
}

View File

@@ -0,0 +1 @@
@using TightWiki.Areas.Identity.Pages.Account.Manage

View File

@@ -0,0 +1,161 @@
@page
@using TightWiki.Models
@using System.Text.Encodings.Web
@model RegisterModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<div class="row">
<div class="col-md-8">
<form id="registerForm" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl ?? "")" method="post">
<h4>Create a new account.</h4>
<hr />
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-group row mb-3">
<label for="Input.Email" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Email)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.Email, new { @class = "form-control", autocomplete = "email", placeholder = "name@example.com" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Email)</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="Input.Password" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Password)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => Model.Input.Password, new { @class = "form-control", autocomplete = "password", placeholder = "required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Password)</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="Input.ConfirmPassword" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.ConfirmPassword)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => Model.Input.ConfirmPassword, new { @class = "form-control", autocomplete = "confirmpassword", placeholder = "required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.ConfirmPassword)</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="Input.AccountName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.AccountName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.AccountName, new { @class = "form-control", placeholder = "required" })
<small class="form-text text-muted">This account name will be visible publicly.</small>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.AccountName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.FirstName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.FirstName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.FirstName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.FirstName)</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="Input.Input.LastName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.LastName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => Model.Input.LastName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.LastName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.Input.Country" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Country)</strong></label>
<div class="col-sm-10">
<select name="Input.Country" id="Input.Country" class="form-control">
<option value="" style="color:#ccc !important;">Select a country</option>
@foreach (var item in Model.Input.Countries)
{
<option value="@item.Value" selected=@(Model.Input.Country == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Country)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.Input.Language" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.Language)</strong></label>
<div class="col-sm-10">
<select name="Input.Language" id="Input.Language" class="form-control">
<option value="" style="color:#ccc !important;">Select a language</option>
@foreach (var item in Model.Input.Languages)
{
<option value="@item.Value" selected=@(Model.Input.Language == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.Language)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Input.Input.TimeZone" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => Model.Input.TimeZone)</strong></label>
<div class="col-sm-10">
<select name="Input.TimeZone" id="Input.TimeZone" class="form-control">
<option value="" style="color:#ccc !important;">Select a time-zone</option>
@foreach (var item in Model.Input.TimeZones)
{
<option value="@item.Value" selected=@(Model.Input.TimeZone == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => Model.Input.TimeZone)</div>
</div>
</div>
<div>
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Signup</button>
<br /><br />
</div>
</form>
</div>
@if ((Model.ExternalLogins?.Count ?? 0) > 0)
{
<div class="col-md-4">
<section>
<h4>Use another service to register.</h4>
<hr />
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@UrlEncoder.Default.Encode(Model.ReturnUrl ?? "")" method="post" class="form-horizontal">
<div>
<p>
@foreach (var provider in Model.ExternalLogins!)
{
if (provider.Name == "Google")
{
<button type="submit" class="btn w-100 mb-3 d-flex align-items-center" name="provider" value="@provider.Name" title="Log in using your Google account">
<img src="@GlobalConfiguration.BasePath/images/external/google-signin.svg" alt="Google" class="me-2" />
</button>
}
else if (provider.Name == "Microsoft")
{
<button type="submit" class="btn w-100 mb-3 d-flex align-items-center" name="provider" value="@provider.Name" title="Log in using your Microsoft account">
<img src="@GlobalConfiguration.BasePath/images/external/microsoft-signin.svg" alt="Microsoft" class="me-2" />
</button>
}
else
{
<button type="submit" class="btn btn-primary w-100 mb-3" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">
@provider.DisplayName
</button>
}
}
</p>
</div>
</form>
</section>
</div>
}
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,250 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using NTDLS.Helpers;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Library;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class RegisterModel : PageModelBase
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly IUserStore<IdentityUser> _userStore;
private readonly IUserEmailStore<IdentityUser> _emailStore;
private readonly ILogger<RegisterModel> _logger;
private readonly IWikiEmailSender _emailSender;
public RegisterModel(
UserManager<IdentityUser> userManager,
IUserStore<IdentityUser> userStore,
SignInManager<IdentityUser> signInManager,
ILogger<RegisterModel> logger,
IWikiEmailSender emailSender)
: base(signInManager)
{
_userManager = userManager;
_userStore = userStore;
_emailStore = GetEmailStore();
_signInManager = signInManager;
_logger = logger;
_emailSender = emailSender;
}
[BindProperty]
public InputModel Input { get; set; } = new();
[BindProperty]
public string? ReturnUrl { get; set; }
public IList<AuthenticationScheme>? ExternalLogins { get; set; }
public class InputModel
{
public List<TimeZoneItem> TimeZones { get; set; } = new();
public List<CountryItem> Countries { get; set; } = new();
public List<LanguageItem> Languages { get; set; } = new();
[Display(Name = "Account Name")]
[Required(ErrorMessage = "Account Name is required")]
public string AccountName { get; set; } = string.Empty;
[Display(Name = "First Name")]
public string? FirstName { get; set; }
[Display(Name = "Last Name")]
public string? LastName { get; set; } = string.Empty;
[Display(Name = "Time-Zone")]
[Required(ErrorMessage = "TimeZone is required")]
public string TimeZone { get; set; } = string.Empty;
[Display(Name = "Country")]
[Required(ErrorMessage = "Country is required")]
public string Country { get; set; } = string.Empty;
[Display(Name = "Language")]
[Required(ErrorMessage = "Language is required")]
public string Language { get; set; } = string.Empty;
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; } = string.Empty;
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; } = string.Empty;
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
private void PopulateDefaults()
{
Input.TimeZones = TimeZoneItem.GetAll();
Input.Countries = CountryItem.GetAll();
Input.Languages = LanguageItem.GetAll();
var membershipConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Membership");
if (string.IsNullOrEmpty(Input.TimeZone))
Input.TimeZone = membershipConfig.Value<string>("Default TimeZone").EnsureNotNull();
if (string.IsNullOrEmpty(Input.Country))
Input.Country = membershipConfig.Value<string>("Default Country").EnsureNotNull();
if (string.IsNullOrEmpty(Input.Language))
Input.Language = membershipConfig.Value<string>("Default Language").EnsureNotNull();
}
public async Task<IActionResult> OnGetAsync(string? returnUrl = null)
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
PopulateDefaults();
ReturnUrl = returnUrl;
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
return Page();
}
public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
PopulateDefaults();
if (!ModelState.IsValid)
{
return Page();
}
if (string.IsNullOrWhiteSpace(Input.AccountName))
{
ModelState.AddModelError("Input.AccountName", "Account Name is required.");
return Page();
}
else if (UsersRepository.DoesProfileAccountExist(Input.AccountName))
{
ModelState.AddModelError("Input.AccountName", "Account Name is already in use.");
return Page();
}
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
var user = new IdentityUser()
{
UserName = Input.Email,
Email = Input.Email
};
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
_logger.LogInformation("User created a new account with password.");
var userId = await _userManager.GetUserIdAsync(user);
var membershipConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Membership");
UsersRepository.CreateProfile(Guid.Parse(userId), Input.AccountName);
var claimsToAdd = new List<Claim>
{
new (ClaimTypes.Role, membershipConfig.Value<string>("Default Signup Role").EnsureNotNull()),
new ("timezone", Input.TimeZone),
new (ClaimTypes.Country, Input.Country),
new ("language", Input.Language),
new ("firstname", Input.FirstName ?? ""),
new ("lastname", Input.LastName ?? ""),
};
SecurityRepository.UpsertUserClaims(_userManager, user, claimsToAdd);
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = encodedCode, returnUrl = returnUrl },
protocol: Request.Scheme);
var emailTemplate = new StringBuilder(ConfigurationRepository.Get<string>("Membership", "Template: Account Verification Email"));
var basicConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Basic");
var siteName = basicConfig.Value<string>("Name");
var address = basicConfig.Value<string>("Address");
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(userId));
var emailSubject = "Confirm your email";
emailTemplate.Replace("##SUBJECT##", emailSubject);
emailTemplate.Replace("##ACCOUNTCOUNTRY##", profile.Country);
emailTemplate.Replace("##ACCOUNTTIMEZONE##", profile.TimeZone);
emailTemplate.Replace("##ACCOUNTLANGUAGE##", profile.Language);
emailTemplate.Replace("##ACCOUNTEMAIL##", profile.EmailAddress);
emailTemplate.Replace("##ACCOUNTNAME##", profile.AccountName);
emailTemplate.Replace("##PERSONNAME##", $"{profile.FirstName} {profile.LastName}");
emailTemplate.Replace("##CODE##", code);
emailTemplate.Replace("##USERID##", userId);
emailTemplate.Replace("##SITENAME##", siteName);
emailTemplate.Replace("##SITEADDRESS##", address);
emailTemplate.Replace("##CALLBACKURL##", HtmlEncoder.Default.Encode(callbackUrl ?? string.Empty));
await _emailSender.SendEmailAsync(Input.Email, emailSubject, emailTemplate.ToString());
return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
}
else
{
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
// If we got this far, something failed, redisplay form
return Page();
}
private IUserEmailStore<IdentityUser> GetEmailStore()
{
if (!_userManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<IdentityUser>)_userStore;
}
}
}

View File

@@ -0,0 +1,15 @@
@page
@model RegisterConfirmationModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Register confirmation
</h3>
<p>
Please check your email to confirm your account.
</p>

View File

@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class RegisterConfirmationModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly IWikiEmailSender _emailSender;
public RegisterConfirmationModel(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager, IWikiEmailSender emailSender)
: base(signInManager)
{
_userManager = userManager;
_emailSender = emailSender;
}
public IActionResult OnGetAsync(string email, string returnUrl = null)
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
return Page();
}
}
}

View File

@@ -0,0 +1,12 @@
@page
@model RegistrationIsNotAllowedModel
@{
ViewData["Title"] = "";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Registration is not allowed
</h3>
<p class="text-danger">Registration is not allowed.</p>

View File

@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class RegistrationIsNotAllowedModel : PageModelBase
{
public void OnGet()
{
}
public RegistrationIsNotAllowedModel(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,30 @@
@page
@model ResendEmailConfirmationModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Resend email confirmation
</h3>
<p>Enter your email.</p>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,115 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class ResendEmailConfirmationModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
private readonly IWikiEmailSender _emailSender;
public ResendEmailConfirmationModel(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager, IWikiEmailSender emailSender)
: base(signInManager)
{
_userManager = userManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public IActionResult OnGet()
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null)
{
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
return Page();
}
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { userId = userId, code = encodedCode },
protocol: Request.Scheme);
var emailTemplate = new StringBuilder(ConfigurationRepository.Get<string>("Membership", "Template: Account Verification Email"));
var basicConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Basic");
var siteName = basicConfig.Value<string>("Name");
var address = basicConfig.Value<string>("Address");
var profile = UsersRepository.GetAccountProfileByUserId(Guid.Parse(userId));
var emailSubject = "Confirm your email";
emailTemplate.Replace("##SUBJECT##", emailSubject);
emailTemplate.Replace("##ACCOUNTCOUNTRY##", profile.Country);
emailTemplate.Replace("##ACCOUNTTIMEZONE##", profile.TimeZone);
emailTemplate.Replace("##ACCOUNTLANGUAGE##", profile.Language);
emailTemplate.Replace("##ACCOUNTEMAIL##", profile.EmailAddress);
emailTemplate.Replace("##ACCOUNTNAME##", profile.AccountName);
emailTemplate.Replace("##PERSONNAME##", $"{profile.FirstName} {profile.LastName}");
emailTemplate.Replace("##CODE##", code);
emailTemplate.Replace("##USERID##", userId);
emailTemplate.Replace("##SITENAME##", siteName);
emailTemplate.Replace("##SITEADDRESS##", address);
emailTemplate.Replace("##CALLBACKURL##", HtmlEncoder.Default.Encode(callbackUrl));
await _emailSender.SendEmailAsync(Input.Email, emailSubject, emailTemplate.ToString());
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
return Page();
}
}
}

View File

@@ -0,0 +1,41 @@
@page
@model ResetPasswordModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Reset password
</h3>
<p>Reset your password.</p>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<input asp-for="Input.Code" type="hidden" />
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
<label asp-for="Input.Password" class="form-label"></label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,115 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using System.ComponentModel.DataAnnotations;
using System.Text;
using TightWiki.Models;
namespace TightWiki.Areas.Identity.Pages.Account
{
public class ResetPasswordModel : PageModelBase
{
private readonly UserManager<IdentityUser> _userManager;
public ResetPasswordModel(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: base(signInManager)
{
_userManager = userManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
public string Password { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
public string Code { get; set; }
}
public IActionResult OnGet(string code = null)
{
if (code == null)
{
return BadRequest("A code must be supplied for password reset.");
}
else
{
Input = new InputModel
{
Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code))
};
return Page();
}
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null)
{
// Don't reveal that the user does not exist
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ResetPasswordConfirmation");
}
var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password);
if (result.Succeeded)
{
return RedirectToPage($"{GlobalConfiguration.BasePath}/Identity/ResetPasswordConfirmation");
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
}
}

View File

@@ -0,0 +1,13 @@
@page
@model ResetPasswordConfirmationModel
@{
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Reset password confirmation
</h3>
<p>
Your password has been reset. Please <a asp-page="./Login">click here to log in</a>.
</p>

View File

@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
public class ResetPasswordConfirmationModel : PageModelBase
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
public ResetPasswordConfirmationModel(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,10 @@
@model string
@if (!String.IsNullOrEmpty(Model))
{
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@Model
</div>
}

View File

@@ -0,0 +1 @@
@using TightWiki.Areas.Identity.Pages.Account

View File

@@ -0,0 +1,24 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
</p>

View File

@@ -0,0 +1,47 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace TightWiki.Areas.Identity.Pages
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public class ErrorModel : PageModelBase
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string RequestId { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
public ErrorModel(SignInManager<IdentityUser> signInManager)
: base(signInManager)
{
}
}
}

View File

@@ -0,0 +1,18 @@
<environment include="Development">
<script src="~/Identity/lib/jquery-validation/dist/jquery.validate.js"></script>
<script src="~/Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
</environment>
<environment exclude="Development">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.17.0/jquery.validate.min.js"
asp-fallback-src="~/Identity/lib/jquery-validation/dist/jquery.validate.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator"
crossorigin="anonymous"
integrity="sha384-rZfj/ogBloos6wzLGpPkkOr/gpkBNLZ6b6yLy4o+ok+t/SAKlL5mvXLr0OXNi1Hp">
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"
asp-fallback-src="~/Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
crossorigin="anonymous"
integrity="sha384-R3vNCHsZ+A2Lo3d5A6XNP7fdQkeswQWTIPfiYwSpEP3YV079R+93YzTeZRah7f/F">
</script>
</environment>

View File

@@ -0,0 +1,4 @@
@using Microsoft.AspNetCore.Identity
@using TightWiki.Areas.Identity
@using TightWiki.Areas.Identity.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,4 @@
@{
Layout = "/Views/Shared/_Layout.cshtml";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}

View File

@@ -0,0 +1,115 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using System.Security.Claims;
using TightWiki.Models;
using TightWiki.Models.ViewModels;
using TightWiki.Repository;
namespace TightWiki.Controllers
{
[Area("Identity")]
[Route("Identity/Account")]
public class AccountController : WikiControllerBase
{
private readonly IUserStore<IdentityUser> _userStore;
private readonly IUserEmailStore<IdentityUser> _emailStore;
public AccountController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager, IUserStore<IdentityUser> userStore)
: base(signInManager, userManager)
{
_userStore = userStore;
_emailStore = (IUserEmailStore<IdentityUser>)_userStore;
}
[HttpGet("ExternalLogin")]
[ValidateAntiForgeryToken]
public IActionResult ExternalLoginHttpGet(string provider, string? returnUrl = null)
{
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new { area = "Identity", ReturnUrl = returnUrl });
var properties = SignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Challenge(properties, provider);
}
[HttpPost("ExternalLogin")]
[ValidateAntiForgeryToken]
public IActionResult ExternalLoginHttpPost(string provider, string? returnUrl = null)
{
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new { area = "Identity", ReturnUrl = returnUrl });
var properties = SignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Challenge(properties, provider);
}
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null, string? remoteError = null)
{
//We use this model to display any errors that occur.
var model = new ExternalLoginCallbackViewModel();
returnUrl ??= Url.Content("~/");
if (remoteError != null)
{
return NotifyOfError($"Error from external provider: {remoteError}");
}
var info = await SignInManager.GetExternalLoginInfoAsync();
if (info == null)
{
return NotifyOfError($"Failed to get information from external provider");
}
var user = await UserManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (user != null)
{
// User exists, sign them in:
await SignInManager.SignInAsync(user, isPersistent: false);
if (UsersRepository.TryGetBasicProfileByUserId(Guid.Parse(user.Id), out _) == false)
{
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
//User exits but does not have a profile.
//This means that the user has authenticated externally, but has yet to complete the signup process.
return RedirectToPage($"{GlobalConfiguration.BasePath}/Account/ExternalLoginSupplemental", new { ReturnUrl = returnUrl });
}
return LocalRedirect(returnUrl);
}
else
{
// If the user does not exist, check by email
var email = info.Principal.FindFirstValue(ClaimTypes.Email).EnsureNotNull();
if (string.IsNullOrEmpty(email))
{
return NotifyOfError($"The email address was not supplied by the external provider.");
}
user = await UserManager.FindByEmailAsync(email);
if (user != null)
{
// User with this email exists but not linked with this external login, link them:
var result = await UserManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
return NotifyOfError(string.Join("<br />\r\n", result.Errors.Select(o => o.Description)));
}
await SignInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
else
{
// If user with this email does not exist, then we need to create the user and profile.
if (GlobalConfiguration.AllowSignup != true)
{
return Redirect($"{GlobalConfiguration.BasePath}/Identity/Account/RegistrationIsNotAllowed");
}
return RedirectToPage($"{GlobalConfiguration.BasePath}/Account/ExternalLoginSupplemental", new { ReturnUrl = returnUrl });
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,481 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using SixLabors.ImageSharp;
using System.Web;
using TightWiki.Caching;
using TightWiki.Library;
using TightWiki.Models;
using TightWiki.Models.DataModels;
using TightWiki.Models.ViewModels.File;
using TightWiki.Repository;
using static TightWiki.Library.Images;
namespace TightWiki.Controllers
{
[Route("File")]
public class FileController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: WikiControllerBase(signInManager, userManager)
{
/// <summary>
/// Gets an image attached to a page.
/// </summary>
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="fileRevision">The revision of the the file (NOT THE PAGE REVISION).</param>
/// <returns></returns>
[HttpGet("Image/{givenPageNavigation}/{givenFileNavigation}/{fileRevision:int?}")]
public ActionResult Image(string givenPageNavigation, string givenFileNavigation, int? fileRevision = null)
{
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
int givenScale = GetQueryValue("Scale", 100);
var cacheKey = WikiCacheKeyFunction.Build(WikiCache.Category.Page, [givenPageNavigation, givenFileNavigation, fileRevision, givenScale]);
if (WikiCache.TryGet<ImageCacheItem>(cacheKey, out var cached))
{
return File(cached.Bytes, cached.ContentType);
}
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationFileRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, fileRevision);
if (file != null)
{
if (file.ContentType == "image/x-icon")
{
//We do not handle the resizing of icon file. Maybe later....
return File(file.Data, file.ContentType);
}
var img = SixLabors.ImageSharp.Image.Load(new MemoryStream(file.Data));
if (givenScale > 500)
{
givenScale = 500;
}
if (givenScale != 100)
{
int width = (int)(img.Width * (givenScale / 100.0));
int height = (int)(img.Height * (givenScale / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
int difference = 16 - height;
height += difference;
width += difference;
}
if (width < 16)
{
int difference = 16 - width;
height += difference;
width += difference;
}
if (file.ContentType.Equals("image/gif", StringComparison.InvariantCultureIgnoreCase))
{
var resized = ResizeGifImage(file.Data, width, height);
return File(resized, "image/gif");
}
else
{
using var image = ResizeImage(img, width, height);
using var ms = new MemoryStream();
file.ContentType = BestEffortConvertImage(image, ms, file.ContentType);
var cacheItem = new ImageCacheItem(ms.ToArray(), file.ContentType);
WikiCache.Put(cacheKey, cacheItem);
return File(cacheItem.Bytes, cacheItem.ContentType);
}
}
else
{
return File(file.Data, file.ContentType);
}
}
else
{
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
/// <summary>
/// Gets an image from the database, converts it to a PNG with optional scaling and returns it to the client.
/// </summary>
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="fileRevision">The revision of the the FILE (NOT THE PAGE REVISION)</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("Png/{givenPageNavigation}/{givenFileNavigation}/{fileRevision:int?}")]
public ActionResult Png(string givenPageNavigation, string givenFileNavigation, int? fileRevision = null)
{
SessionState.RequireViewPermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
int givenScale = GetQueryValue("Scale", 100);
var cacheKey = WikiCacheKeyFunction.Build(WikiCache.Category.Page, [givenPageNavigation, givenFileNavigation, fileRevision, givenScale]);
if (WikiCache.TryGet<ImageCacheItem>(cacheKey, out var cached))
{
return File(cached.Bytes, cached.ContentType);
}
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationFileRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, fileRevision);
if (file != null)
{
var img = SixLabors.ImageSharp.Image.Load(new MemoryStream(Utility.Decompress(file.Data)));
if (givenScale > 500)
{
givenScale = 500;
}
if (givenScale != 100)
{
int width = (int)(img.Width * (givenScale / 100.0));
int height = (int)(img.Height * (givenScale / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
int difference = 16 - height;
height += difference;
width += difference;
}
if (width < 16)
{
int difference = 16 - width;
height += difference;
width += difference;
}
using var image = Images.ResizeImage(img, width, height);
using var ms = new MemoryStream();
image.SaveAsPng(ms);
var cacheItem = new ImageCacheItem(ms.ToArray(), "image/png");
WikiCache.Put(cacheKey, cacheItem);
return File(cacheItem.Bytes, cacheItem.ContentType);
}
else
{
using var ms = new MemoryStream();
img.SaveAsPng(ms);
var cacheItem = new ImageCacheItem(ms.ToArray(), "image/png");
WikiCache.Put(cacheKey, cacheItem);
return File(cacheItem.Bytes, cacheItem.ContentType);
}
}
else
{
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
/// <summary>
/// Gets a file from the database and returns it to the client. This works even
/// when the file or file revision is not even attached to the page anymore.
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="fileRevision">The revision of the the FILE (NOT THE PAGE REVISION),</param>
/// </summary>
[AllowAnonymous]
[HttpGet("Binary/{givenPageNavigation}/{givenFileNavigation}/{fileRevision:int?}")]
public ActionResult Binary(string givenPageNavigation, string givenFileNavigation, int? fileRevision = null)
{
SessionState.RequireViewPermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationFileRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, fileRevision);
if (file != null)
{
return File(file.Data.ToArray(), file.ContentType);
}
else
{
HttpContext.Response.StatusCode = 404;
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
/// <summary>
/// Populate the upload page. Shows the attachments.
/// </summary>
[Authorize]
[HttpGet("Revisions/{givenPageNavigation}/{givenFileNavigation}")]
public ActionResult Revisions(string givenPageNavigation, string givenFileNavigation)
{
SessionState.RequireViewPermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
var model = new PageFileRevisionsViewModel()
{
PageNavigation = pageNavigation.Canonical,
FileNavigation = fileNavigation.Canonical,
Revisions = PageFileRepository.GetPageFileAttachmentRevisionsByPageAndFileNavigationPaged
(pageNavigation.Canonical, fileNavigation.Canonical, GetQueryValue("page", 1))
};
model.PaginationPageCount = (model.Revisions.FirstOrDefault()?.PaginationPageCount ?? 0);
return View(model);
}
/// <summary>
/// Populate the upload page. Shows the attachments.
/// </summary>
[Authorize]
[HttpGet("PageAttachments/{givenPageNavigation}")]
public ActionResult PageAttachments(string givenPageNavigation)
{
SessionState.RequireCreatePermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation);
if (page != null)
{
var pageFiles = PageFileRepository.GetPageFilesInfoByPageId(page.Id);
return View(new FileAttachmentViewModel
{
PageNavigation = page.Navigation,
PageRevision = page.Revision,
Files = pageFiles
});
}
return View(new FileAttachmentViewModel
{
Files = new()
});
}
/// <summary>
/// Uploads a file by drag drop.
/// </summary>
[Authorize]
[HttpPost("UploadDragDrop/{givenPageNavigation}")]
public IActionResult UploadDragDrop(string givenPageNavigation, List<IFormFile> postedFiles)
{
SessionState.RequireCreatePermission();
try
{
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var page = PageRepository.GetPageInfoByNavigation(pageNavigation.Canonical).EnsureNotNull();
foreach (IFormFile file in postedFiles)
{
if (file != null)
{
var fileSize = file.Length;
if (fileSize > 0)
{
if (fileSize > GlobalConfiguration.MaxAttachmentFileSize)
{
return Json(new { message = $"Could not attach file: [{file.FileName}], too large." });
}
var fileName = HttpUtility.UrlDecode(file.FileName);
PageFileRepository.UpsertPageFile(new PageFileAttachment()
{
Data = Utility.ConvertHttpFileToBytes(file),
CreatedDate = DateTime.UtcNow,
PageId = page.Id,
Name = fileName,
FileNavigation = Navigation.Clean(fileName),
Size = fileSize,
ContentType = Utility.GetMimeType(fileName)
}, (SessionState.Profile?.UserId).EnsureNotNullOrEmpty());
}
}
}
return Json(new { success = true, message = $"{postedFiles.Count:n0} file{(postedFiles.Count == 0 || postedFiles.Count > 1 ? "s" : string.Empty)}." });
}
catch (Exception ex)
{
ExceptionRepository.InsertException(ex, "Failed to upload file.");
return StatusCode(500, new { success = false, message = $"An error occurred: {ex.Message}" });
}
}
/// <summary>
/// Uploads a file by manually selecting it for upload.
/// </summary>
/// <param name="postData"></param>
/// <returns></returns>
[Authorize]
[HttpPost("ManualUpload/{givenPageNavigation}")]
public IActionResult ManualUpload(string givenPageNavigation, IFormFile fileData)
{
SessionState.RequireCreatePermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var page = PageRepository.GetPageInfoByNavigation(pageNavigation.Canonical).EnsureNotNull();
if (fileData != null)
{
var fileSize = fileData.Length;
if (fileSize > 0)
{
if (fileSize > GlobalConfiguration.MaxAttachmentFileSize)
{
return Content("Could not save the attached file, too large");
}
var fileName = HttpUtility.UrlDecode(fileData.FileName);
PageFileRepository.UpsertPageFile(new PageFileAttachment()
{
Data = Utility.ConvertHttpFileToBytes(fileData),
CreatedDate = DateTime.UtcNow,
PageId = page.Id,
Name = fileName,
FileNavigation = Navigation.Clean(fileName),
Size = fileSize,
ContentType = Utility.GetMimeType(fileName)
}, (SessionState.Profile?.UserId).EnsureNotNullOrEmpty());
return Content("Success");
}
}
return Content("Failure");
}
/// <summary>
/// Allows a user to delete a page attachment from a page.
/// </summary>
/// <param name="navigation"></param>
/// <returns></returns>
[HttpPost("Detach/{givenPageNavigation}/{givenFileNavigation}/{pageRevision}")]
public ActionResult Detach(string givenPageNavigation, string givenFileNavigation, int pageRevision)
{
SessionState.RequireDeletePermission();
PageFileRepository.DetachPageRevisionAttachment(
new NamespaceNavigation(givenPageNavigation).Canonical,
new NamespaceNavigation(givenFileNavigation).Canonical, pageRevision);
return Content("Success");
}
/// <summary>
/// Gets a file from the database, converts it to a PNG with optional scaling and returns it to the client.
/// </summary>
/// <param name="navigation"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("Emoji/{givenPageNavigation}")]
public ActionResult Emoji(string givenPageNavigation)
{
SessionState.RequireViewPermission();
var pageNavigation = Navigation.Clean(givenPageNavigation);
if (string.IsNullOrEmpty(pageNavigation) == false)
{
string scale = GetQueryValue("Scale", "100");
string shortcut = $"%%{pageNavigation.ToLower()}%%";
var emoji = GlobalConfiguration.Emojis.Where(o => o.Shortcut == shortcut).FirstOrDefault();
if (emoji != null)
{
//Do we have this scale cached already?
var scaledImageCacheKey = WikiCacheKey.Build(WikiCache.Category.Emoji, [shortcut, scale]);
if (WikiCache.TryGet<ImageCacheItem>(scaledImageCacheKey, out var cachedEmoji))
{
return File(cachedEmoji.Bytes, cachedEmoji.ContentType);
}
var imageCacheKey = WikiCacheKey.Build(WikiCache.Category.Emoji, [shortcut]);
emoji.ImageData = WikiCache.Get<byte[]>(imageCacheKey);
if (emoji.ImageData == null)
{
//We don't get the bytes by default, that would be a lot of RAM for all the thousands of images.
emoji.ImageData = EmojiRepository.GetEmojiByName(emoji.Name)?.ImageData;
if (emoji.ImageData == null)
{
return NotFound($"Emoji {pageNavigation} was not found");
}
WikiCache.Put(imageCacheKey, emoji.ImageData);
}
if (emoji.ImageData != null)
{
var decompressedImageBytes = Utility.Decompress(emoji.ImageData);
var img = SixLabors.ImageSharp.Image.Load(new MemoryStream(decompressedImageBytes));
int customScalePercent = int.Parse(scale);
if (customScalePercent > 500)
{
customScalePercent = 500;
}
var (Width, Height) = Utility.ScaleToMaxOf(img.Width, img.Height, GlobalConfiguration.DefaultEmojiHeight);
//Adjust to any specified scaling.
Height = (int)(Height * (customScalePercent / 100.0));
Width = (int)(Width * (customScalePercent / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (Height < 16)
{
Height += 16 - Height;
Width += 16 - Height;
}
if (Width < 16)
{
Height += 16 - Width;
Width += 16 - Width;
}
if (emoji.MimeType?.ToLower() == "image/gif")
{
var resized = ResizeGifImage(decompressedImageBytes, Width, Height);
var itemCache = new ImageCacheItem(resized, "image/gif");
WikiCache.Put(scaledImageCacheKey, itemCache);
return File(itemCache.Bytes, itemCache.ContentType);
}
else
{
using var image = Images.ResizeImage(img, Width, Height);
using var ms = new MemoryStream();
image.SaveAsPng(ms);
var itemCache = new ImageCacheItem(ms.ToArray(), "image/png");
WikiCache.Put(scaledImageCacheKey, itemCache);
return File(itemCache.Bytes, itemCache.ContentType);
}
}
}
}
return NotFound($"Emoji {pageNavigation} was not found");
}
}
}

View File

@@ -0,0 +1,839 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using SixLabors.ImageSharp;
using System.Text;
using TightWiki.Caching;
using TightWiki.Engine;
using TightWiki.Engine.Implementation.Utility;
using TightWiki.Engine.Library.Interfaces;
using TightWiki.Library;
using TightWiki.Models;
using TightWiki.Models.DataModels;
using TightWiki.Models.ViewModels.Page;
using TightWiki.Repository;
using static TightWiki.Library.Constants;
using static TightWiki.Library.Images;
namespace TightWiki.Controllers
{
[Route("")]
public class PageController(ITightEngine tightEngine, SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: WikiControllerBase(signInManager, userManager)
{
[AllowAnonymous]
[Route("/robots.txt")]
public ContentResult RobotsTxt()
{
var sb = new StringBuilder();
sb.AppendLine("User-agent: *")
.AppendLine("Allow: /");
return Content(sb.ToString(), "text/plain", Encoding.UTF8);
}
#region Display.
/// <summary>
/// Default controller for root requests. e.g. http://127.0.0.1/
/// </summary>
/// <returns></returns>
[HttpGet]
public IActionResult Display()
=> Display("home", null);
[HttpGet("{givenCanonical}/{pageRevision:int?}")]
public IActionResult Display(string givenCanonical, int? pageRevision)
{
SessionState.RequireViewPermission();
var model = new PageDisplayViewModel();
var navigation = new NamespaceNavigation(givenCanonical);
var page = PageRepository.GetPageRevisionByNavigation(navigation.Canonical, pageRevision);
if (page != null)
{
var instructions = PageRepository.GetPageProcessingInstructionsByPageId(page.Id);
model.Revision = page.Revision;
model.MostCurrentRevision = page.MostCurrentRevision;
model.Name = page.Name;
model.Namespace = page.Namespace;
model.Navigation = page.Navigation;
model.HideFooterComments = instructions.Contains(WikiInstruction.HideFooterComments);
model.HideFooterLastModified = instructions.Contains(WikiInstruction.HideFooterLastModified);
model.ModifiedByUserName = page.ModifiedByUserName;
model.ModifiedDate = SessionState.LocalizeDateTime(page.ModifiedDate);
SessionState.SetPageId(page.Id, pageRevision);
if (GlobalConfiguration.PageCacheSeconds > 0)
{
string queryKey = string.Empty;
foreach (var query in Request.Query)
{
queryKey += $"{query.Key}:{query.Value}";
}
var cacheKey = WikiCacheKeyFunction.Build(WikiCache.Category.Page, [page.Navigation, page.Revision, queryKey]);
if (WikiCache.TryGet<PageCache>(cacheKey, out var cached))
{
model.Body = cached.Body;
SessionState.PageTitle = cached.PageTitle;
WikiCache.Put(cacheKey, cached); //Update the cache expiration.
}
else
{
var state = tightEngine.Transform(SessionState, page, pageRevision);
SessionState.PageTitle = state.PageTitle;
model.Body = state.HtmlResult;
if (state.ProcessingInstructions.Contains(WikiInstruction.NoCache) == false)
{
var toBeCached = new PageCache(state.HtmlResult)
{
PageTitle = state.PageTitle
};
WikiCache.Put(cacheKey, toBeCached); //This is cleared with the call to Cache.ClearCategory($"Page:{page.Navigation}");
}
}
}
else
{
var state = tightEngine.Transform(SessionState, page, pageRevision);
model.Body = state.HtmlResult;
}
if (GlobalConfiguration.EnablePageComments && GlobalConfiguration.ShowCommentsOnPageFooter && model.HideFooterComments == false)
{
var comments = PageRepository.GetPageCommentsPaged(navigation.Canonical, 1);
foreach (var comment in comments)
{
model.Comments.Add(new PageComment
{
PaginationPageCount = comment.PaginationPageCount,
UserNavigation = comment.UserNavigation,
Id = comment.Id,
UserName = comment.UserName,
UserId = comment.UserId,
Body = WikifierLite.Process(comment.Body),
CreatedDate = SessionState.LocalizeDateTime(comment.CreatedDate)
});
}
}
}
else if (pageRevision != null)
{
var notExistPageName = ConfigurationRepository.Get<string>("Customization", "Revision Does Not Exists Page");
string notExistPageNavigation = NamespaceNavigation.CleanAndValidate(notExistPageName);
var notExistsPage = PageRepository.GetPageRevisionByNavigation(notExistPageNavigation).EnsureNotNull();
SessionState.SetPageId(null, pageRevision);
var state = tightEngine.Transform(SessionState, notExistsPage);
SessionState.Page.Name = notExistsPage.Name;
model.Body = state.HtmlResult;
model.HideFooterComments = true;
if (SessionState.IsAuthenticated && SessionState.CanCreate)
{
SessionState.ShouldCreatePage = false;
}
}
else
{
var notExistPageName = ConfigurationRepository.Get<string>("Customization", "Page Not Exists Page");
string notExistPageNavigation = NamespaceNavigation.CleanAndValidate(notExistPageName);
var notExistsPage = PageRepository.GetPageRevisionByNavigation(notExistPageNavigation).EnsureNotNull();
SessionState.SetPageId(null, null);
var state = tightEngine.Transform(SessionState, notExistsPage);
SessionState.Page.Name = notExistsPage.Name;
model.Body = state.HtmlResult;
model.HideFooterComments = true;
if (SessionState.IsAuthenticated && SessionState.CanCreate)
{
SessionState.ShouldCreatePage = true;
}
}
return View(model);
}
#endregion
#region Search.
[AllowAnonymous]
[HttpGet("Page/Search")]
public ActionResult Search()
{
string searchString = GetQueryValue("SearchString") ?? string.Empty;
if (string.IsNullOrEmpty(searchString) == false)
{
var model = new PageSearchViewModel()
{
Pages = PageRepository.PageSearchPaged(Utility.SplitToTokens(searchString), GetQueryValue("page", 1)),
SearchString = searchString
};
model.PaginationPageCount = (model.Pages.FirstOrDefault()?.PaginationPageCount ?? 0);
return View(model);
}
return View(new PageSearchViewModel()
{
Pages = new(),
SearchString = searchString
});
}
[AllowAnonymous]
[HttpPost("Page/Search")]
public ActionResult Search(PageSearchViewModel model)
{
string searchString = GetQueryValue("SearchString") ?? string.Empty;
if (string.IsNullOrEmpty(searchString) == false)
{
model = new PageSearchViewModel()
{
Pages = PageRepository.PageSearchPaged(Utility.SplitToTokens(searchString), GetQueryValue("page", 1)),
SearchString = searchString
};
model.PaginationPageCount = (model.Pages.FirstOrDefault()?.PaginationPageCount ?? 0);
return View(model);
}
return View(new PageSearchViewModel()
{
Pages = new(),
SearchString = searchString
});
}
#endregion
#region Comments.
[AllowAnonymous]
[HttpGet("{givenCanonical}/Comments")]
public ActionResult Comments(string givenCanonical)
{
SessionState.RequireViewPermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var pageInfo = PageRepository.GetPageInfoByNavigation(pageNavigation);
if (pageInfo == null)
{
return NotFound();
}
var deleteAction = GetQueryValue("Delete");
if (string.IsNullOrEmpty(deleteAction) == false && SessionState.IsAuthenticated)
{
if (SessionState.CanModerate)
{
//Moderators and administrators can delete comments that they do not own.
PageRepository.DeletePageCommentById(pageInfo.Id, int.Parse(deleteAction));
}
else
{
PageRepository.DeletePageCommentByUserAndId(pageInfo.Id, SessionState.Profile.EnsureNotNull().UserId, int.Parse(deleteAction));
}
}
var model = new PageCommentsViewModel();
var comments = PageRepository.GetPageCommentsPaged(pageNavigation, GetQueryValue("page", 1));
foreach (var comment in comments)
{
model.Comments.Add(new PageComment
{
PaginationPageCount = comment.PaginationPageCount,
UserNavigation = comment.UserNavigation,
Id = comment.Id,
UserName = comment.UserName,
UserId = comment.UserId,
Body = WikifierLite.Process(comment.Body),
CreatedDate = SessionState.LocalizeDateTime(comment.CreatedDate)
});
}
model.PaginationPageCount = (model.Comments.FirstOrDefault()?.PaginationPageCount ?? 0);
SessionState.SetPageId(pageInfo.Id);
return View(model);
}
/// <summary>
/// Insert new page comment.
/// </summary>
/// <param name="model"></param>
/// <param name="givenCanonical"></param>
/// <param name="page"></param>
/// <returns></returns>
[Authorize]
[HttpPost("{givenCanonical}/Comments")]
public ActionResult Comments(PageCommentsViewModel model, string givenCanonical)
{
SessionState.RequireEditPermission();
if (!ModelState.IsValid)
{
return View(model);
}
string? errorMessage = null;
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var pageInfo = PageRepository.GetPageInfoByNavigation(pageNavigation);
if (pageInfo == null)
{
return NotFound();
}
PageRepository.InsertPageComment(pageInfo.Id, SessionState.Profile.EnsureNotNull().UserId, model.Comment);
model = new PageCommentsViewModel()
{
ErrorMessage = errorMessage.DefaultWhenNull(string.Empty)
};
var comments = PageRepository.GetPageCommentsPaged(pageNavigation, GetQueryValue("page", 1));
foreach (var comment in comments)
{
model.Comments.Add(new PageComment
{
PaginationPageCount = comment.PaginationPageCount,
UserNavigation = comment.UserNavigation,
Id = comment.Id,
UserName = comment.UserName,
UserId = comment.UserId,
Body = WikifierLite.Process(comment.Body),
CreatedDate = SessionState.LocalizeDateTime(comment.CreatedDate)
});
}
model.PaginationPageCount = (model.Comments.FirstOrDefault()?.PaginationPageCount ?? 0);
SessionState.SetPageId(pageInfo.Id);
return View(model);
}
#endregion
#region Refresh.
[Authorize]
[HttpGet("{givenCanonical}/Refresh")]
public ActionResult Refresh(string givenCanonical)
{
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation, null, false);
if (page != null)
{
Engine.Implementation.Helpers.RefreshPageMetadata(tightEngine, page, SessionState);
}
return Redirect($"{GlobalConfiguration.BasePath}/{pageNavigation}");
}
#endregion
#region Revisions.
[Authorize]
[HttpGet("{givenCanonical}/Revisions")]
public ActionResult Revisions(string givenCanonical)
{
SessionState.RequireViewPermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var pageNumber = GetQueryValue("page", 1);
var orderBy = GetQueryValue("OrderBy");
var orderByDirection = GetQueryValue("OrderByDirection");
var model = new RevisionsViewModel()
{
Revisions = PageRepository.GetPageRevisionsInfoByNavigationPaged(pageNavigation, pageNumber, orderBy, orderByDirection)
};
model.PaginationPageCount = (model.Revisions.FirstOrDefault()?.PaginationPageCount ?? 0);
model.Revisions.ForEach(o =>
{
o.CreatedDate = SessionState.LocalizeDateTime(o.CreatedDate);
o.ModifiedDate = SessionState.LocalizeDateTime(o.ModifiedDate);
});
foreach (var p in model.Revisions)
{
var thisRev = PageRepository.GetPageRevisionByNavigation(p.Navigation, p.Revision);
var prevRev = PageRepository.GetPageRevisionByNavigation(p.Navigation, p.Revision - 1);
p.ChangeSummary = Differentiator.GetComparisonSummary(thisRev?.Body ?? "", prevRev?.Body ?? "");
}
if (model.Revisions != null && model.Revisions.Count > 0)
{
SessionState.SetPageId(model.Revisions.First().PageId);
}
return View(model);
}
#endregion
#region Delete.
[Authorize]
[HttpPost("{givenCanonical}/Delete")]
public ActionResult Delete(string givenCanonical, PageDeleteViewModel model)
{
SessionState.RequireDeletePermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation);
var instructions = PageRepository.GetPageProcessingInstructionsByPageId(page.EnsureNotNull().Id);
if (instructions.Contains(WikiInstruction.Protect))
{
return NotifyOfError("The page is protected and cannot be deleted. A moderator or an administrator must remove the protection before deletion.");
}
bool confirmAction = bool.Parse(GetFormValue("IsActionConfirmed").EnsureNotNull());
if (confirmAction == true && page != null)
{
PageRepository.MovePageToDeletedById(page.Id, (SessionState.Profile?.UserId).EnsureNotNullOrEmpty());
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.Page, [page.Navigation]));
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.Page, [page.Id]));
return NotifyOfSuccess("The page has been deleted.", $"/Home");
}
return Redirect($"{GlobalConfiguration.BasePath}/{pageNavigation}");
}
[Authorize]
[HttpGet("{givenCanonical}/Delete")]
public ActionResult Delete(string givenCanonical)
{
SessionState.RequireDeletePermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation).EnsureNotNull();
var model = new PageDeleteViewModel()
{
CountOfAttachments = PageRepository.GetCountOfPageAttachmentsById(page.Id),
PageName = page.Name,
MostCurrentRevision = page.Revision,
PageRevision = page.Revision
};
SessionState.SetPageId(page.Id);
var instructions = PageRepository.GetPageProcessingInstructionsByPageId(page.Id);
if (instructions.Contains(WikiInstruction.Protect))
{
return NotifyOfError("The page is protected and cannot be deleted. A moderator or an administrator must remove the protection before deletion.");
}
return View(model);
}
#endregion
#region Revert.
[Authorize]
[HttpPost("{givenCanonical}/Revert/{pageRevision:int}")]
public ActionResult Revert(string givenCanonical, int pageRevision, PageRevertViewModel model)
{
SessionState.RequireModeratePermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
bool confirmAction = bool.Parse(GetFormValue("IsActionConfirmed").EnsureNotNullOrEmpty());
if (confirmAction == true)
{
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation, pageRevision).EnsureNotNull();
Engine.Implementation.Helpers.UpsertPage(tightEngine, page, SessionState);
return NotifyOfSuccess("The page has been reverted.", $"/{pageNavigation}");
}
return Redirect($"{GlobalConfiguration.BasePath}/{pageNavigation}");
}
[Authorize]
[HttpGet("{givenCanonical}/Revert/{pageRevision:int}")]
public ActionResult Revert(string givenCanonical, int pageRevision)
{
SessionState.RequireModeratePermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var mostCurrentPage = PageRepository.GetPageRevisionByNavigation(pageNavigation).EnsureNotNull();
mostCurrentPage.CreatedDate = SessionState.LocalizeDateTime(mostCurrentPage.CreatedDate);
mostCurrentPage.ModifiedDate = SessionState.LocalizeDateTime(mostCurrentPage.ModifiedDate);
var revisionPage = PageRepository.GetPageRevisionByNavigation(pageNavigation, pageRevision).EnsureNotNull();
revisionPage.CreatedDate = SessionState.LocalizeDateTime(revisionPage.CreatedDate);
revisionPage.ModifiedDate = SessionState.LocalizeDateTime(revisionPage.ModifiedDate);
var model = new PageRevertViewModel()
{
PageName = revisionPage.Name,
HighestRevision = mostCurrentPage.Revision,
HigherRevisionCount = revisionPage.HigherRevisionCount,
};
if (revisionPage != null)
{
SessionState.SetPageId(revisionPage.Id, pageRevision);
}
return View(model);
}
#endregion
#region Edit.
[Authorize]
[HttpGet("{givenCanonical}/Edit")]
[HttpGet("Page/Create")]
public ActionResult Edit(string givenCanonical)
{
SessionState.RequireEditPermission();
var pageNavigation = NamespaceNavigation.CleanAndValidate(givenCanonical);
var page = PageRepository.GetPageRevisionByNavigation(pageNavigation);
if (page != null)
{
var instructions = PageRepository.GetPageProcessingInstructionsByPageId(page.EnsureNotNull().Id);
if (SessionState.CanModerate == false && instructions.Contains(WikiInstruction.Protect))
{
return NotifyOfError("The page is protected and cannot be modified except by a moderator or an administrator unless the protection is removed.");
}
SessionState.SetPageId(page.Id);
return View(new PageEditViewModel()
{
Id = page.Id,
Body = page.Body,
Name = page.Name,
Navigation = NamespaceNavigation.CleanAndValidate(page.Navigation),
Description = page.Description
});
}
else
{
var pageName = GetQueryValue("Name").DefaultWhenNullOrEmpty(pageNavigation);
string templateName = ConfigurationRepository.Get<string>("Customization", "New Page Template").EnsureNotNull();
string templateNavigation = NamespaceNavigation.CleanAndValidate(templateName);
var templatePage = PageRepository.GetPageRevisionByNavigation(templateNavigation);
templatePage ??= new Page();
return View(new PageEditViewModel()
{
Body = templatePage.Body,
Name = pageName?.Replace('_', ' ') ?? string.Empty,
Navigation = NamespaceNavigation.CleanAndValidate(pageNavigation)
});
}
}
[Authorize]
[HttpPost("{givenCanonical}/Edit")]
[HttpPost("Page/Create")]
public ActionResult Edit(PageEditViewModel model)
{
SessionState.RequireEditPermission();
if (!ModelState.IsValid)
{
return View(model);
}
if (model.Id == 0) //Saving a new page.
{
var page = new Page()
{
CreatedDate = DateTime.UtcNow,
CreatedByUserId = SessionState.Profile.EnsureNotNull().UserId,
ModifiedDate = DateTime.UtcNow,
ModifiedByUserId = SessionState.Profile.UserId,
Body = model.Body ?? "",
Name = model.Name,
Navigation = NamespaceNavigation.CleanAndValidate(model.Name),
Description = model.Description ?? ""
};
if (PageRepository.GetPageInfoByNavigation(page.Navigation) != null)
{
ModelState.AddModelError("Name", "The page name you entered already exists.");
return View(model);
}
page.Id = Engine.Implementation.Helpers.UpsertPage(tightEngine, page, SessionState);
SessionState.SetPageId(page.Id);
return NotifyOfSuccess("The page has been created.", $"/{page.Navigation}/Edit");
}
else
{
var page = PageRepository.GetPageRevisionById(model.Id).EnsureNotNull();
var instructions = PageRepository.GetPageProcessingInstructionsByPageId(page.Id);
if (SessionState.CanModerate == false && instructions.Contains(WikiInstruction.Protect))
{
return NotifyOfError("The page is protected and cannot be modified except by a moderator or an administrator unless the protection is removed.");
}
string originalNavigation = string.Empty;
model.Navigation = NamespaceNavigation.CleanAndValidate(model.Name);
if (!page.Navigation.Equals(model.Navigation, StringComparison.InvariantCultureIgnoreCase))
{
if (PageRepository.GetPageInfoByNavigation(model.Navigation) != null)
{
ModelState.AddModelError("Name", "The page name you entered already exists.");
return View(model);
}
originalNavigation = page.Navigation; //So we can clear cache and this also indicates that we need to redirect to the new name.
}
page.ModifiedDate = DateTime.UtcNow;
page.ModifiedByUserId = SessionState.Profile.EnsureNotNull().UserId;
page.Body = model.Body ?? "";
page.Name = model.Name;
page.Navigation = NamespaceNavigation.CleanAndValidate(model.Name);
page.Description = model.Description ?? "";
Engine.Implementation.Helpers.UpsertPage(tightEngine, page, SessionState);
SessionState.SetPageId(page.Id);
model.SuccessMessage = "The page was saved.";
if (string.IsNullOrWhiteSpace(originalNavigation) == false)
{
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.Page, [originalNavigation]));
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.Page, [page.Id]));
return Redirect($"{GlobalConfiguration.BasePath}/{page.Navigation}/Edit");
}
return View(model);
}
}
#endregion
#region File.
/// <summary>
/// Gets an image attached to a page.
/// </summary>
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="pageRevision">The revision of the the PAGE that the file is attached to (NOT THE FILE REVISION)</param>
/// <returns></returns>
[HttpGet("Page/Image/{givenPageNavigation}/{givenFileNavigation}/{pageRevision:int?}")]
public ActionResult Image(string givenPageNavigation, string givenFileNavigation, int? pageRevision = null)
{
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
string givenScale = GetQueryValue("Scale", "100");
var cacheKey = WikiCacheKeyFunction.Build(WikiCache.Category.Page, [givenPageNavigation, givenFileNavigation, pageRevision, givenScale]);
if (WikiCache.TryGet<ImageCacheItem>(cacheKey, out var cached))
{
return File(cached.Bytes, cached.ContentType);
}
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationPageRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, pageRevision);
if (file != null)
{
if (file.ContentType == "image/x-icon")
{
//We do not handle the resizing of icon file. Maybe later....
return File(file.Data, file.ContentType);
}
var img = SixLabors.ImageSharp.Image.Load(new MemoryStream(file.Data));
int parsedScale = int.Parse(givenScale);
if (parsedScale > 500)
{
parsedScale = 500;
}
if (parsedScale != 100)
{
int width = (int)(img.Width * (parsedScale / 100.0));
int height = (int)(img.Height * (parsedScale / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
height += 16 - height;
width += 16 - height;
}
if (width < 16)
{
height += 16 - width;
width += 16 - width;
}
if (file.ContentType.Equals("image/gif", StringComparison.InvariantCultureIgnoreCase))
{
var resized = ResizeGifImage(file.Data, width, height);
return File(resized, "image/gif");
}
else
{
using var image = ResizeImage(img, width, height);
using var ms = new MemoryStream();
file.ContentType = BestEffortConvertImage(image, ms, file.ContentType);
var cacheItem = new ImageCacheItem(ms.ToArray(), file.ContentType);
WikiCache.Put(cacheKey, cacheItem);
return File(cacheItem.Bytes, cacheItem.ContentType);
}
}
else
{
return File(file.Data, file.ContentType);
}
}
else
{
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
/// <summary>
/// Gets an image from the database, converts it to a PNG with optional scaling and returns it to the client.
/// </summary>
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="pageRevision">The revision of the the PAGE that the file is attached to (NOT THE FILE REVISION)</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("Page/Png/{givenPageNavigation}/{givenFileNavigation}/{pageRevision:int?}")]
public ActionResult Png(string givenPageNavigation, string givenFileNavigation, int? pageRevision = null)
{
SessionState.RequireViewPermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
string givenScale = GetQueryValue("Scale", "100");
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationPageRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, pageRevision);
if (file != null)
{
var img = SixLabors.ImageSharp.Image.Load(new MemoryStream(Utility.Decompress(file.Data)));
int parsedScale = int.Parse(givenScale);
if (parsedScale > 500)
{
parsedScale = 500;
}
if (parsedScale != 100)
{
int width = (int)(img.Width * (parsedScale / 100.0));
int height = (int)(img.Height * (parsedScale / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
height += 16 - height;
width += 16 - height;
}
if (width < 16)
{
height += 16 - width;
width += 16 - width;
}
using var image = Images.ResizeImage(img, width, height);
using var ms = new MemoryStream();
image.SaveAsPng(ms);
return File(ms.ToArray(), "image/png");
}
else
{
using var ms = new MemoryStream();
img.SaveAsPng(ms);
return File(ms.ToArray(), "image/png");
}
}
else
{
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
/// <summary>
/// Gets a file from the database and returns it to the client.
/// <param name="givenPageNavigation">The navigation link of the page.</param>
/// <param name="givenFileNavigation">The navigation link of the file.</param>
/// <param name="pageRevision">The revision of the the PAGE that the file is attached to (NOT THE FILE REVISION)</param>
/// </summary>
[AllowAnonymous]
[HttpGet("Page/Binary/{givenPageNavigation}/{givenFileNavigation}/{pageRevision:int?}")]
public ActionResult Binary(string givenPageNavigation, string givenFileNavigation, int? pageRevision = null)
{
SessionState.RequireViewPermission();
var pageNavigation = new NamespaceNavigation(givenPageNavigation);
var fileNavigation = new NamespaceNavigation(givenFileNavigation);
var file = PageFileRepository.GetPageFileAttachmentByPageNavigationPageRevisionAndFileNavigation(pageNavigation.Canonical, fileNavigation.Canonical, pageRevision);
if (file != null)
{
return File(file.Data.ToArray(), file.ContentType);
}
else
{
HttpContext.Response.StatusCode = 404;
return NotFound($"[{fileNavigation}] was not found on the page [{pageNavigation}].");
}
}
#endregion
}
}

View File

@@ -0,0 +1,444 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using SixLabors.ImageSharp;
using System.Security.Claims;
using TightWiki.Caching;
using TightWiki.Engine;
using TightWiki.Engine.Implementation.Utility;
using TightWiki.Library;
using TightWiki.Models;
using TightWiki.Models.DataModels;
using TightWiki.Models.ViewModels.Profile;
using TightWiki.Repository;
using static TightWiki.Library.Images;
namespace TightWiki.Controllers
{
[Route("[controller]")]
public class ProfileController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager, IWebHostEnvironment environment)
: WikiControllerBase(signInManager, userManager)
{
private readonly IWebHostEnvironment _environment = environment;
#region User Profile.
/// <summary>
/// //Gets a users avatar.
/// </summary>
/// <param name="navigation"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet]
[HttpGet("{userAccountName}/Avatar")]
public ActionResult Avatar(string userAccountName)
{
SessionState.RequireViewPermission();
SessionState.Page.Name = $"Avatar";
string givenScale = Request.Query["Scale"].ToString().ToString().DefaultWhenNullOrEmpty("100");
string givenMax = Request.Query["max"].ToString().DefaultWhenNullOrEmpty("512");
string? givenExact = Request.Query["exact"];
ProfileAvatar? avatar;
if (GlobalConfiguration.EnablePublicProfiles)
{
avatar = UsersRepository.GetProfileAvatarByNavigation(NamespaceNavigation.CleanAndValidate(userAccountName));
}
else
{
avatar = new ProfileAvatar();
}
if (avatar.Bytes == null || avatar.Bytes.Length == 0)
{
//Load the default avatar.
var filePath = Path.Combine(_environment.WebRootPath, "Avatar.png");
var image = Image.Load(filePath);
using var ms = new MemoryStream();
image.SaveAsPng(ms);
avatar.ContentType = "image/png";
avatar.Bytes = ms.ToArray();
}
if (avatar.Bytes != null && avatar.Bytes.Length > 0)
{
if (avatar.ContentType == "image/x-icon")
{
//We do not handle the resizing of icon file. Maybe later....
return File(avatar.Bytes, avatar.ContentType);
}
var img = Image.Load(new MemoryStream(avatar.Bytes));
int width = img.Width;
int height = img.Height;
int parsedScale = int.Parse(givenScale);
int parsedMax = int.Parse(givenMax);
if (string.IsNullOrEmpty(givenExact) == false)
{
int parsedExact = int.Parse(givenExact);
if (parsedExact > 1024)
{
parsedExact = 1024;
}
else if (parsedExact < 16)
{
parsedExact = 16;
}
int diff = img.Width - parsedExact;
width = (int)(img.Width - diff);
height = (int)(img.Height - diff);
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
int difference = 16 - height;
height += difference;
width += difference;
}
if (width < 16)
{
int difference = 16 - width;
height += difference;
width += difference;
}
}
else if (parsedMax != 0 && (img.Width > parsedMax || img.Height > parsedMax))
{
int diff = img.Width - parsedMax;
width = (int)(img.Width - diff);
height = (int)(img.Height - diff);
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
int difference = 16 - height;
height += difference;
width += difference;
}
if (width < 16)
{
int difference = 16 - width;
height += difference;
width += difference;
}
}
else if (parsedScale != 100)
{
width = (int)(img.Width * (parsedScale / 100.0));
height = (int)(img.Height * (parsedScale / 100.0));
//Adjusting by a ratio (and especially after applying additional scaling) may have caused one
// dimension to become very small (or even negative). So here we will check the height and width
// to ensure they are both at least n pixels and adjust both dimensions.
if (height < 16)
{
int difference = 16 - height;
height += difference;
width += difference;
}
if (width < 16)
{
int difference = 16 - width;
height += difference;
width += difference;
}
}
else
{
return File(avatar.Bytes, avatar.ContentType);
}
if (avatar.ContentType.Equals("image/gif", StringComparison.InvariantCultureIgnoreCase))
{
var resized = ResizeGifImage(avatar.Bytes, width, height);
return File(resized, "image/gif");
}
else
{
using var image = ResizeImage(img, width, height);
using var ms = new MemoryStream();
string contentType = BestEffortConvertImage(image, ms, avatar.ContentType);
return File(ms.ToArray(), contentType);
}
}
else
{
return NotFound();
}
}
/// <summary>
/// Get user profile.
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("{userAccountName}/Public")]
public ActionResult Public(string userAccountName)
{
SessionState.Page.Name = $"Public Profile";
userAccountName = NamespaceNavigation.CleanAndValidate(userAccountName);
if (!GlobalConfiguration.EnablePublicProfiles)
{
return View(new PublicViewModel
{
ErrorMessage = "Public profiles are disabled."
});
}
if (UsersRepository.TryGetAccountProfileByNavigation(userAccountName, out var accountProfile) == false)
{
return View(new PublicViewModel
{
ErrorMessage = "The specified user was not found."
});
}
var model = new PublicViewModel()
{
AccountName = accountProfile.AccountName,
Navigation = accountProfile.Navigation,
Id = accountProfile.UserId,
TimeZone = accountProfile.TimeZone,
Language = accountProfile.Language,
Country = accountProfile.Country,
Biography = WikifierLite.Process(accountProfile.Biography),
Avatar = accountProfile.Avatar
};
model.RecentlyModified = PageRepository.GetTopRecentlyModifiedPagesInfoByUserId(accountProfile.UserId, GlobalConfiguration.DefaultProfileRecentlyModifiedCount)
.OrderByDescending(o => o.ModifiedDate).ThenBy(o => o.Name).ToList();
foreach (var item in model.RecentlyModified)
{
var thisRev = PageRepository.GetPageRevisionByNavigation(item.Navigation, item.Revision);
var prevRev = PageRepository.GetPageRevisionByNavigation(item.Navigation, item.Revision - 1);
item.ChangeSummary = Differentiator.GetComparisonSummary(thisRev?.Body ?? "", prevRev?.Body ?? "");
}
return View(model);
}
/// <summary>
/// Get user profile.
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet]
[HttpGet("My")]
public ActionResult My()
{
SessionState.RequireAuthorizedPermission();
SessionState.Page.Name = $"My Profile";
var model = new AccountProfileViewModel()
{
AccountProfile = AccountProfileAccountViewModel.FromDataModel(
UsersRepository.GetAccountProfileByUserId(SessionState.Profile.EnsureNotNull().UserId)),
Themes = ConfigurationRepository.GetAllThemes(),
TimeZones = TimeZoneItem.GetAll(),
Countries = CountryItem.GetAll(),
Languages = LanguageItem.GetAll()
};
model.AccountProfile.CreatedDate = SessionState.LocalizeDateTime(model.AccountProfile.CreatedDate);
model.AccountProfile.ModifiedDate = SessionState.LocalizeDateTime(model.AccountProfile.ModifiedDate);
return View(model);
}
/// <summary>
/// Save user profile.
/// </summary>
/// <param name="profile"></param>
/// <returns></returns>
[Authorize]
[HttpPost("My")]
public ActionResult My(AccountProfileViewModel model)
{
SessionState.RequireAuthorizedPermission();
SessionState.Page.Name = $"My Profile";
model.TimeZones = TimeZoneItem.GetAll();
model.Countries = CountryItem.GetAll();
model.Languages = LanguageItem.GetAll();
model.Themes = ConfigurationRepository.GetAllThemes();
//Get the UserId from the logged in context because we do not trust anything from the model.
var userId = SessionState.Profile.EnsureNotNull().UserId;
if (!ModelState.IsValid)
{
return View(model);
}
var user = UserManager.FindByIdAsync(userId.ToString()).Result.EnsureNotNull();
var profile = UsersRepository.GetAccountProfileByUserId(userId);
if (!profile.Navigation.Equals(model.AccountProfile.Navigation, StringComparison.CurrentCultureIgnoreCase))
{
if (UsersRepository.DoesProfileAccountExist(model.AccountProfile.AccountName))
{
ModelState.AddModelError("Account.AccountName", "Account name is already in use.");
return View(model);
}
}
model.AccountProfile.Navigation = NamespaceNavigation.CleanAndValidate(model.AccountProfile.AccountName.ToLower());
var file = Request.Form.Files["Avatar"];
if (file != null && file.Length > 0)
{
if (GlobalConfiguration.AllowableImageTypes.Contains(file.ContentType.ToLower()) == false)
{
model.ErrorMessage += "Could not save the attached image, type not allowed.\r\n";
}
else if (file.Length > GlobalConfiguration.MaxAvatarFileSize)
{
model.ErrorMessage += "Could not save the attached image, too large.\r\n";
}
else
{
try
{
var imageBytes = Utility.ConvertHttpFileToBytes(file);
var image = Image.Load(new MemoryStream(imageBytes));
UsersRepository.UpdateProfileAvatar(profile.UserId, imageBytes, file.ContentType.ToLower());
}
catch
{
ModelState.AddModelError("Account.Avatar", "Could not save the attached image.");
}
}
}
profile.AccountName = model.AccountProfile.AccountName;
profile.Navigation = NamespaceNavigation.CleanAndValidate(model.AccountProfile.AccountName);
profile.Biography = model.AccountProfile.Biography;
profile.ModifiedDate = DateTime.UtcNow;
UsersRepository.UpdateProfile(profile);
var claims = new List<Claim>
{
new ("timezone", model.AccountProfile.TimeZone),
new (ClaimTypes.Country, model.AccountProfile.Country),
new ("language", model.AccountProfile.Language),
new ("firstname", model.AccountProfile.FirstName ?? ""),
new ("lastname", model.AccountProfile.LastName ?? ""),
new ("theme", model.AccountProfile.Theme ?? ""),
};
SecurityRepository.UpsertUserClaims(UserManager, user, claims);
SignInManager.RefreshSignInAsync(user);
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.User, [profile.Navigation]));
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.User, [profile.UserId]));
model.SuccessMessage = "Your profile has been saved.";
//This is not 100% necessary, I just want to prevent the user from needing to refresh to view the new theme.
SessionState.UserTheme = ConfigurationRepository.GetAllThemes().SingleOrDefault(o => o.Name == model.AccountProfile.Theme) ?? GlobalConfiguration.SystemTheme;
return View(model);
}
#endregion
#region Delete.
/// <summary>
/// User is deleting their own profile.
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[Authorize]
[HttpPost("Delete")]
public ActionResult Delete(DeleteAccountViewModel model)
{
SessionState.RequireAuthorizedPermission();
var profile = UsersRepository.GetBasicProfileByUserId(SessionState.Profile.EnsureNotNull().UserId);
bool confirmAction = bool.Parse(GetFormValue("IsActionConfirmed").EnsureNotNull());
if (confirmAction == true && profile != null)
{
var user = UserManager.FindByIdAsync(profile.UserId.ToString()).Result;
if (user == null)
{
return NotFound("User not found.");
}
var result = UserManager.DeleteAsync(user).Result;
if (!result.Succeeded)
{
throw new Exception(string.Join("<br />\r\n", result.Errors.Select(o => o.Description)));
}
SignInManager.SignOutAsync();
UsersRepository.AnonymizeProfile(profile.UserId);
WikiCache.ClearCategory(WikiCacheKey.Build(WikiCache.Category.User, [profile.Navigation]));
HttpContext.SignOutAsync(); //Do we still need this??
return Redirect($"{GlobalConfiguration.BasePath}/Profile/Deleted");
}
return Redirect($"{GlobalConfiguration.BasePath}/Profile/My");
}
/// <summary>
/// User is deleting their own profile.
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("Delete")]
public ActionResult Delete()
{
SessionState.RequireAuthorizedPermission();
SessionState.Page.Name = $"Delete Account";
var profile = UsersRepository.GetBasicProfileByUserId(SessionState.Profile.EnsureNotNull().UserId);
var model = new DeleteAccountViewModel()
{
AccountName = profile.AccountName
};
if (profile != null)
{
SessionState.Page.Name = $"Delete {profile.AccountName}";
}
return View(model);
}
/// <summary>
/// User is deleting their own profile.
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("Deleted")]
public ActionResult Deleted()
{
var model = new DeletedAccountViewModel()
{
};
return View(model);
}
#endregion
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Text;
using TightWiki.Engine.Implementation.Utility;
using TightWiki.Library;
using TightWiki.Models.ViewModels.Page;
using TightWiki.Repository;
namespace TightWiki.Controllers
{
[Authorize]
public class TagsController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: WikiControllerBase(signInManager, userManager)
{
[AllowAnonymous]
public ActionResult Browse(string navigation)
{
SessionState.RequireViewPermission();
SessionState.Page.Name = "Tags";
navigation = NamespaceNavigation.CleanAndValidate(navigation);
string glossaryName = "glossary_" + (new Random()).Next(0, 1000000).ToString();
var pages = PageRepository.GetPageInfoByTag(navigation).OrderBy(o => o.Name).ToList();
var glossaryHtml = new StringBuilder();
var alphabet = pages.Select(p => p.Name.Substring(0, 1).ToUpper()).Distinct();
if (pages.Count > 0)
{
glossaryHtml.Append("<center>");
foreach (var alpha in alphabet)
{
glossaryHtml.Append("<a href=\"#" + glossaryName + "_" + alpha + "\">" + alpha + "</a>&nbsp;");
}
glossaryHtml.Append("</center>");
glossaryHtml.Append("<ul>");
foreach (var alpha in alphabet)
{
glossaryHtml.Append("<li><a name=\"" + glossaryName + "_" + alpha + "\">" + alpha + "</a></li>");
glossaryHtml.Append("<ul>");
foreach (var page in pages.Where(p => p.Name.StartsWith(alpha, StringComparison.InvariantCultureIgnoreCase)))
{
glossaryHtml.Append("<li><a href=\"/" + page.Navigation + "\">" + page.Name + "</a>");
if (page.Description.Length > 0)
{
glossaryHtml.Append(" - " + page.Description);
}
glossaryHtml.Append("</li>");
}
glossaryHtml.Append("</ul>");
}
glossaryHtml.Append("</ul>");
}
var model = new BrowseViewModel
{
AssociatedPages = glossaryHtml.ToString(),
TagCloud = TagCloud.Build(navigation, 100)
};
return View(model);
}
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NTDLS.Helpers;
using TightWiki.Models.ViewModels.Utility;
namespace TightWiki.Controllers
{
[Authorize]
[Route("[controller]")]
public class UtilityController(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: WikiControllerBase(signInManager, userManager)
{
[AllowAnonymous]
[HttpGet("Notify")]
public ActionResult Notify()
{
SessionState.RequireViewPermission();
var model = new NotifyViewModel()
{
SuccessMessage = GetQueryValue("SuccessMessage", string.Empty),
ErrorMessage = GetQueryValue("ErrorMessage", string.Empty),
RedirectURL = GetQueryValue("RedirectURL", string.Empty),
RedirectTimeout = GetQueryValue("RedirectTimeout", 0)
};
return View(model);
}
[AllowAnonymous]
[HttpPost("ConfirmAction")]
public ActionResult ConfirmAction(ConfirmActionViewModel model)
{
return View(model);
}
[AllowAnonymous]
[HttpGet("ConfirmAction")]
public ActionResult ConfirmAction()
{
var model = new ConfirmActionViewModel
{
ControllerURL = GetQueryValue("controllerURL").EnsureNotNull(),
YesRedirectURL = GetQueryValue("yesRedirectURL").EnsureNotNull(),
NoRedirectURL = GetQueryValue("noRedirectURL").EnsureNotNull(),
Message = GetQueryValue("message").EnsureNotNull(),
Style = GetQueryValue("Style").EnsureNotNull()
};
return View(model);
}
}
}

View File

@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using NTDLS.Helpers;
using TightWiki.Models;
namespace TightWiki.Controllers
{
public class WikiControllerBase(SignInManager<IdentityUser> signInManager, UserManager<IdentityUser> userManager)
: Controller
{
public SessionState SessionState { get; private set; } = new();
public readonly SignInManager<IdentityUser> SignInManager = signInManager;
public readonly UserManager<IdentityUser> UserManager = userManager;
[NonAction]
public override void OnActionExecuting(ActionExecutingContext filterContext)
=> ViewData["SessionState"] = SessionState.Hydrate(SignInManager, this);
[NonAction]
public override RedirectResult Redirect(string? url)
=> base.Redirect(url.EnsureNotNull());
[NonAction]
protected string? GetQueryValue(string key)
=> Request.Query[key];
[NonAction]
protected string GetQueryValue(string key, string defaultValue)
=> (string?)Request.Query[key] ?? defaultValue;
[NonAction]
protected int GetQueryValue(string key, int defaultValue)
=> int.Parse(GetQueryValue(key, defaultValue.ToString()));
[NonAction]
protected string? GetFormValue(string key)
=> Request.Form[key];
[NonAction]
protected string GetFormValue(string key, string defaultValue)
=> (string?)Request.Form[key] ?? defaultValue;
[NonAction]
protected int GetFormValue(string key, int defaultValue)
=> int.Parse(GetFormValue(key, defaultValue.ToString()));
/// <summary>
/// Displays the successMessage unless the errorMessage is present.
/// </summary>
/// <returns></returns>
protected RedirectResult NotifyOf(string successMessage, string errorMessage, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={Uri.EscapeDataString(successMessage)}&ErrorMessage={Uri.EscapeDataString(errorMessage)}&RedirectUrl={Uri.EscapeDataString($"{GlobalConfiguration.BasePath}{redirectUrl}")}&RedirectTimeout=5");
protected RedirectResult NotifyOfSuccess(string message, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={Uri.EscapeDataString(message)}&RedirectUrl={Uri.EscapeDataString($"{GlobalConfiguration.BasePath}{redirectUrl}")}&RedirectTimeout=5");
protected RedirectResult NotifyOfError(string message, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={Uri.EscapeDataString(message)}&RedirectUrl={Uri.EscapeDataString(Uri.EscapeDataString($"{GlobalConfiguration.BasePath}{redirectUrl}"))}");
protected RedirectResult NotifyOf(string successMessage, string errorMessage)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={Uri.EscapeDataString(successMessage)}&ErrorMessage={Uri.EscapeDataString(errorMessage)}&RedirectTimeout=5");
protected RedirectResult NotifyOfSuccess(string message)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={Uri.EscapeDataString(message)}");
protected RedirectResult NotifyOfError(string message)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={Uri.EscapeDataString(message)}");
}
}

View File

@@ -0,0 +1,58 @@
using System.Text;
using TightWiki.Exceptions;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki
{
/// <summary>
/// Intercepts exceptions so that we can throw "UnauthorizedException" from controllers to simplify permissions.
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (UnauthorizedException ex)
{
_logger.LogError(ex, ex.Message);
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(string.Empty);
}
catch (Exception ex)
{
string request = $"{context.Request.Path}{context.Request.QueryString}";
var routeValues = new StringBuilder();
foreach (var rv in context.Request.RouteValues)
{
routeValues.AppendLine($"{rv},");
}
if (routeValues.Length > 1) routeValues.Length--; //Trim trailing comma.
var exceptionText = $"IP Address: {context.Connection.RemoteIpAddress},\r\n Request: {request},\r\n RouteValues: {routeValues}\r\n";
_logger.LogError(ex, exceptionText);
ExceptionRepository.InsertException(ex, exceptionText);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
context.Response.Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={Uri.EscapeDataString("An unexpected error has occurred. The details of this exception have been logged.")}");
}
}
}
}

View File

@@ -0,0 +1,15 @@
namespace TightWiki.Exceptions
{
public class UnauthorizedException : Exception
{
public UnauthorizedException()
: base()
{
}
public UnauthorizedException(string message)
: base(message)
{
}
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TightWiki.Models;
namespace TightWiki
{
public class PageModelBase : PageModel
{
public SessionState SessionState { get; private set; } = new();
public SignInManager<IdentityUser> SignInManager { get; private set; }
public string CustomSuccessMessage { get; set; } = string.Empty;
public string CustomErrorMessage { get; set; } = string.Empty;
public PageModelBase(SignInManager<IdentityUser> signInManager)
{
SignInManager = signInManager;
}
public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
{
ViewData["SessionState"] = SessionState.Hydrate(SignInManager, this);
}
/*
[NonAction]
public override RedirectResult Redirect(string? url)
=> base.Redirect(url.EnsureNotNull());
*/
[NonAction]
protected string? GetQueryString(string key)
=> Request.Query[key];
[NonAction]
protected string GetQueryString(string key, string defaultValue)
=> ((string?)Request.Query[key]) ?? defaultValue;
[NonAction]
protected int GetQueryString(string key, int defaultValue)
=> int.Parse(GetQueryString(key, defaultValue.ToString()));
[NonAction]
protected string? GetFormString(string key)
=> Request.Form[key];
[NonAction]
protected string GetFormString(string key, string defaultValue)
=> ((string?)Request.Form[key]) ?? defaultValue;
[NonAction]
protected int GetFormString(string key, int defaultValue)
=> int.Parse(GetFormString(key, defaultValue.ToString()));
protected RedirectResult NotifyOfAction(string successMessage, string errorMessage, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/NotifyWithRedirectCountdown?SuccessMessage={successMessage}&ErrorMessage={errorMessage}&RedirectUrl={GlobalConfiguration.BasePath}{redirectUrl}");
protected RedirectResult NotifyOfSuccessAction(string message, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/NotifyWithRedirectCountdown?SuccessMessage={message}&RedirectUrl={GlobalConfiguration.BasePath}{redirectUrl}");
protected RedirectResult NotifyOfErrorAction(string message, string redirectUrl)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/NotifyWithRedirectCountdown?ErrorMessage={message}&RedirectUrl={GlobalConfiguration.BasePath}{redirectUrl}");
protected RedirectResult Notify(string successMessage, string errorMessage)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={successMessage}&ErrorMessage={GlobalConfiguration.BasePath}{errorMessage}");
protected RedirectResult NotifyOfSuccess(string message)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?SuccessMessage={message}");
protected RedirectResult NotifyOfError(string message)
=> Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={message}");
}
}

View File

@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace TightWiki.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModelBase
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(SignInManager<IdentityUser> signInManager, ILogger<ErrorModel> logger)
: base(signInManager)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

View File

@@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Identity;
namespace TightWiki.Pages
{
public class PrivacyModel : PageModelBase
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(SignInManager<IdentityUser> signInManager, ILogger<PrivacyModel> logger)
: base(signInManager)
{
_logger = logger;
}
public void OnGet()
{
}
}
}

View File

@@ -0,0 +1,26 @@
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager
<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Profile/Manage/Index" title="Manage">Hello @User.Identity?.Name!</a>
</li>
<li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/Index", new { area = "" })">
<button type="submit" class="nav-link btn btn-link text-dark">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Register">Register</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Login">Login</a>
</li>
}
</ul>

View File

@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@@ -0,0 +1,4 @@
@using Microsoft.AspNetCore.Identity
@using TightWiki
@namespace TightWiki.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

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

232
TightWiki/Program.cs Normal file
View File

@@ -0,0 +1,232 @@
using Autofac;
using Autofac.Core;
using Autofac.Extensions.DependencyInjection;
using Dapper;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using TightWiki.Email;
using TightWiki.Engine;
using TightWiki.Engine.Implementation;
using TightWiki.Engine.Library.Interfaces;
using TightWiki.Library;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Repository;
namespace TightWiki
{
public class Program
{
public static void Main(string[] args)
{
SqlMapper.AddTypeHandler(new GuidTypeHandler());
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("UsersConnection")));
ManagedDataStorage.Pages.SetConnectionString(builder.Configuration.GetConnectionString("PagesConnection"));
ManagedDataStorage.DeletedPages.SetConnectionString(builder.Configuration.GetConnectionString("DeletedPagesConnection"));
ManagedDataStorage.DeletedPageRevisions.SetConnectionString(builder.Configuration.GetConnectionString("DeletedPageRevisionsConnection"));
ManagedDataStorage.Statistics.SetConnectionString(builder.Configuration.GetConnectionString("StatisticsConnection"));
ManagedDataStorage.Emoji.SetConnectionString(builder.Configuration.GetConnectionString("EmojiConnection"));
ManagedDataStorage.Exceptions.SetConnectionString(builder.Configuration.GetConnectionString("ExceptionsConnection"));
ManagedDataStorage.Users.SetConnectionString(builder.Configuration.GetConnectionString("UsersConnection"));
ManagedDataStorage.Config.SetConnectionString(builder.Configuration.GetConnectionString("ConfigConnection"));
ConfigurationRepository.UpgradeDatabase();
ConfigurationRepository.ReloadEverything();
var membershipConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("Membership");
var requireConfirmedAccount = membershipConfig.Value<bool>("Require Email Verification");
// Add services to the container.
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddControllersWithViews(); // Adds support for controllers and views
builder.Services.AddSingleton<IWikiEmailSender, WikiEmailSender>();
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = requireConfirmedAccount)
.AddEntityFrameworkStores<ApplicationDbContext>();
var ExternalAuthenticationConfig = ConfigurationRepository.GetConfigurationEntryValuesByGroupName("External Authentication");
var authentication = builder.Services.AddAuthentication();
if (ExternalAuthenticationConfig.Value<bool>("Google : Use Google Authentication"))
{
var clientId = ExternalAuthenticationConfig.Value<string>("Google : ClientId");
var clientSecret = ExternalAuthenticationConfig.Value<string>("Google : ClientSecret");
if (clientId != null && clientSecret != null && !string.IsNullOrEmpty(clientId) && !string.IsNullOrEmpty(clientSecret))
{
authentication.AddGoogle(options =>
{
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.Events = new OAuthEvents
{
OnRemoteFailure = context =>
{
context.Response.Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={Uri.EscapeDataString("External login was canceled.")}");
context.HandleResponse();
return Task.CompletedTask;
}
};
});
}
}
if (ExternalAuthenticationConfig.Value<bool>("Microsoft : Use Microsoft Authentication"))
{
var clientId = ExternalAuthenticationConfig.Value<string>("Microsoft : ClientId");
var clientSecret = ExternalAuthenticationConfig.Value<string>("Microsoft : ClientSecret");
if (clientId != null && clientSecret != null && !string.IsNullOrEmpty(clientId) && !string.IsNullOrEmpty(clientSecret))
{
authentication.AddMicrosoftAccount(options =>
{
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.Events = new OAuthEvents
{
OnRemoteFailure = context =>
{
context.Response.Redirect($"{GlobalConfiguration.BasePath}/Utility/Notify?ErrorMessage={Uri.EscapeDataString("External login was canceled.")}");
context.HandleResponse();
return Task.CompletedTask;
}
};
});
}
}
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
containerBuilder.RegisterType<StandardFunctionHandler>().As<IStandardFunctionHandler>().SingleInstance();
containerBuilder.RegisterType<ScopeFunctionHandler>().As<IScopeFunctionHandler>().SingleInstance();
containerBuilder.RegisterType<ProcessingInstructionFunctionHandler>().As<IProcessingInstructionFunctionHandler>().SingleInstance();
containerBuilder.RegisterType<PostProcessingFunctionHandler>().As<IPostProcessingFunctionHandler>().SingleInstance();
containerBuilder.RegisterType<MarkupHandler>().As<IMarkupHandler>().SingleInstance();
containerBuilder.RegisterType<HeadingHandler>().As<IHeadingHandler>().SingleInstance();
containerBuilder.RegisterType<CommentHandler>().As<ICommentHandler>().SingleInstance();
containerBuilder.RegisterType<EmojiHandler>().As<IEmojiHandler>().SingleInstance();
containerBuilder.RegisterType<ExternalLinkHandler>().As<IExternalLinkHandler>().SingleInstance();
containerBuilder.RegisterType<InternalLinkHandler>().As<IInternalLinkHandler>().SingleInstance();
containerBuilder.RegisterType<ExceptionHandler>().As<IExceptionHandler>().SingleInstance();
containerBuilder.RegisterType<CompletionHandler>().As<ICompletionHandler>().SingleInstance();
containerBuilder.RegisterType<TightEngine>().As<ITightEngine>().SingleInstance();
});
var basePath = builder.Configuration.GetValue<string>("BasePath");
if (!string.IsNullOrEmpty(basePath))
{
GlobalConfiguration.BasePath = basePath;
builder.Services.ConfigureApplicationCookie(options =>
{
if (!string.IsNullOrEmpty(basePath))
{
options.LoginPath = new PathString($"{basePath}/Identity/Account/Login");
options.LogoutPath = new PathString($"{basePath}/Identity/Account/Logout");
options.AccessDeniedPath = new PathString($"{basePath}/Identity/Account/AccessDenied");
options.Cookie.Path = basePath; // Ensure the cookie is scoped to the sub-site path.
}
else
{
options.LoginPath = new PathString("/Identity/Account/Login");
options.LogoutPath = new PathString("/Identity/Account/Logout");
options.AccessDeniedPath = new PathString("/Identity/Account/AccessDenied");
options.Cookie.Path = "/"; // Use root path if no base path is set.
}
});
}
var app = builder.Build();
//Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseStaticFiles();
if (!string.IsNullOrEmpty(basePath))
{
app.UsePathBase(basePath);
// Redirect root requests to basePath (something like '/TightWiki').
app.Use(async (context, next) =>
{
if (context.Request.Path == "/")
{
context.Response.Redirect(basePath);
return;
}
await next();
});
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
ctx.Context.Request.PathBase = basePath;
}
});
}
app.UseRouting();
app.UseAuthentication(); // Ensures the authentication middleware is configured
app.UseAuthorization();
app.MapRazorPages();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Page}/{action=Display}");
app.MapControllerRoute(
name: "Page_Edit",
pattern: "Page/{givenCanonical}/Edit");
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var userManager = services.GetRequiredService<UserManager<IdentityUser>>();
SecurityRepository.ValidateEncryptionAndCreateAdminUser(userManager);
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while seeding the database.");
}
}
app.Run();
}
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<DeleteExistingFiles>true</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>C:\DropZone\TightWiki.Production\TightWiki</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net9.0</TargetFramework>
<ProjectGuid>d738e581-7699-4c8d-b965-92970c75c110</ProjectGuid>
<SelfContained>false</SelfContained>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,38 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5088"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7053;http://localhost:5088"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:63292",
"sslPort": 44394
}
}
}

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"sqlite1": {
"type": "sqlite",
"connectionId": "ConnectionStrings:Default"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"dependencies": {
"sqlite1": {
"secretStore": "LocalSecretsFile",
"resourceId": null,
"type": "sqlite.local",
"connectionId": "ConnectionStrings:Default"
}
}
}

266
TightWiki/SessionState.cs Normal file
View File

@@ -0,0 +1,266 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using NTDLS.Helpers;
using System.Security.Claims;
using TightWiki.Exceptions;
using TightWiki.Library;
using TightWiki.Library.Interfaces;
using TightWiki.Models;
using TightWiki.Models.DataModels;
using TightWiki.Repository;
using static TightWiki.Library.Constants;
namespace TightWiki
{
public class SessionState : ISessionState
{
public IQueryCollection? QueryString { get; set; }
#region Authentication.
public bool IsAuthenticated { get; set; }
public IAccountProfile? Profile { get; set; }
public string Role { get; set; } = string.Empty;
public Theme UserTheme { get; set; } = new();
#endregion
#region Current Page.
/// <summary>
/// Custom page title set by a call to @@Title("...")
/// </summary>
public string? PageTitle { get; set; }
public bool ShouldCreatePage { get; set; }
public string PageNavigation { get; set; } = string.Empty;
public string PageNavigationEscaped { get; set; } = string.Empty;
public string PageTags { get; set; } = string.Empty;
public ProcessingInstructionCollection PageInstructions { get; set; } = new();
/// <summary>
/// The "page" here is more of a "mock page", we use the name for various stuff.
/// </summary>
public IPage Page { get; set; } = new Models.DataModels.Page() { Name = GlobalConfiguration.Name };
#endregion
public SessionState Hydrate(SignInManager<IdentityUser> signInManager, PageModel pageModel)
{
QueryString = pageModel.Request.Query;
HydrateSecurityContext(pageModel.HttpContext, signInManager, pageModel.User);
return this;
}
public SessionState Hydrate(SignInManager<IdentityUser> signInManager, Controller controller)
{
QueryString = controller.Request.Query;
PageNavigation = RouteValue("givenCanonical", "Home");
PageNavigationEscaped = Uri.EscapeDataString(PageNavigation);
HydrateSecurityContext(controller.HttpContext, signInManager, controller.User);
string RouteValue(string key, string defaultValue = "")
{
if (controller.RouteData.Values.ContainsKey(key))
{
return controller.RouteData.Values[key]?.ToString() ?? defaultValue;
}
return defaultValue;
}
return this;
}
private void HydrateSecurityContext(HttpContext httpContext, SignInManager<IdentityUser> signInManager, ClaimsPrincipal user)
{
IsAuthenticated = false;
UserTheme = GlobalConfiguration.SystemTheme;
if (signInManager.IsSignedIn(user))
{
try
{
string emailAddress = (user.Claims.First(x => x.Type == ClaimTypes.Email)?.Value).EnsureNotNull();
if (user.Identity?.IsAuthenticated == true)
{
var userId = Guid.Parse((user.Claims.First(x => x.Type == ClaimTypes.NameIdentifier)?.Value).EnsureNotNull());
if (UsersRepository.TryGetBasicProfileByUserId(userId, out var profile))
{
Profile = profile;
Role = Profile.Role;
UserTheme = ConfigurationRepository.GetAllThemes().SingleOrDefault(o => o.Name == Profile.Theme) ?? GlobalConfiguration.SystemTheme;
IsAuthenticated = true;
}
else
{
//User is signed in, but does not have a profile.
//This likely means that the user has authenticated externally, but has yet to complete the signup process.
}
}
}
catch (Exception ex)
{
httpContext.SignOutAsync();
if (user.Identity != null)
{
httpContext.SignOutAsync(user.Identity.AuthenticationType);
}
ExceptionRepository.InsertException(ex);
}
}
}
/// <summary>
/// Sets the current context pageId and optionally the revision.
/// </summary>
/// <param name="pageId"></param>
/// <param name="revision"></param>
/// <exception cref="Exception"></exception>
public void SetPageId(int? pageId, int? revision = null)
{
Page = new Models.DataModels.Page();
PageInstructions = new();
PageTags = string.Empty;
if (pageId != null)
{
Page = PageRepository.GetLimitedPageInfoByIdAndRevision((int)pageId, revision)
?? throw new Exception("Page not found");
PageInstructions = PageRepository.GetPageProcessingInstructionsByPageId(Page.Id);
if (GlobalConfiguration.IncludeWikiTagsInMeta)
{
PageTags = string.Join(",", PageRepository.GetPageTagsById(Page.Id)?.Select(o => o.Tag) ?? []);
}
}
}
#region Permissions.
public bool IsMemberOf(string role, string[] roles)
=> roles.Contains(role);
public void RequireAuthorizedPermission()
{
if (!IsAuthenticated) throw new UnauthorizedException();
}
public void RequireEditPermission()
{
if (!CanEdit) throw new UnauthorizedException();
}
public void RequireViewPermission()
{
if (!CanView) throw new UnauthorizedException();
}
public void RequireAdminPermission()
{
if (!CanAdmin) throw new UnauthorizedException();
}
public void RequireModeratePermission()
{
if (!CanModerate) throw new UnauthorizedException();
}
public void RequireCreatePermission()
{
if (!CanCreate) throw new UnauthorizedException();
}
public void RequireDeletePermission()
{
if (!CanDelete) throw new UnauthorizedException();
}
/// <summary>
/// Is the current user (or anonymous) allowed to view?
/// </summary>
public bool CanView => true;
/// <summary>
/// Is the current user allowed to edit?
/// </summary>
public bool CanEdit
{
get
{
if (IsAuthenticated)
{
if (PageInstructions.Contains(WikiInstruction.Protect))
{
return IsMemberOf(Role, [Roles.Administrator, Roles.Moderator]);
}
return IsMemberOf(Role, [Roles.Administrator, Roles.Contributor, Roles.Moderator]);
}
return false;
}
}
/// <summary>
/// Is the current user allowed to perform administrative functions?
/// </summary>
public bool CanAdmin =>
IsAuthenticated && IsMemberOf(Role, [Roles.Administrator]);
/// <summary>
/// Is the current user allowed to moderate content (such as delete comments, and view moderation tools)?
/// </summary>
public bool CanModerate =>
IsAuthenticated && IsMemberOf(Role, [Roles.Administrator, Roles.Moderator]);
/// <summary>
/// Is the current user allowed to create pages?
/// </summary>
public bool CanCreate =>
IsAuthenticated && IsMemberOf(Role, [Roles.Administrator, Roles.Contributor, Roles.Moderator]);
/// <summary>
/// Is the current user allowed to delete unprotected pages?
/// </summary>
public bool CanDelete
{
get
{
if (IsAuthenticated)
{
if (PageInstructions.Contains(WikiInstruction.Protect))
{
return false;
}
return IsMemberOf(Role, [Roles.Administrator, Roles.Moderator]);
}
return false;
}
}
#endregion
public DateTime LocalizeDateTime(DateTime datetime)
{
return TimeZoneInfo.ConvertTimeFromUtc(datetime, GetPreferredTimeZone());
}
public TimeZoneInfo GetPreferredTimeZone()
{
if (string.IsNullOrEmpty(Profile?.TimeZone))
{
return TimeZoneInfo.FindSystemTimeZoneById(GlobalConfiguration.DefaultTimeZone);
}
return TimeZoneInfo.FindSystemTimeZoneById(Profile.TimeZone);
}
}
}

View File

@@ -0,0 +1,62 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>2.20.0</Version>
</PropertyGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\css\base.css" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<DebugSymbols>False</DebugSymbols>
<DebugType>None</DebugType>
</PropertyGroup>
<ItemGroup>
<None Include="wwwroot\Avatar.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NTDLS.DelegateThreadPooling" Version="1.5.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TightWiki.Caching\TightWiki.Caching.csproj" />
<ProjectReference Include="..\TightWiki.Email\TightWiki.Email.csproj" />
<ProjectReference Include="..\TightWiki.Engine.Implementation\TightWiki.Engine.Implementation.csproj" />
<ProjectReference Include="..\TightWiki.Engine\TightWiki.Engine.csproj" />
<ProjectReference Include="..\TightWiki.Library\TightWiki.Library.csproj" />
<ProjectReference Include="..\TightWiki.Models\TightWiki.Models.csproj" />
<ProjectReference Include="..\TightWiki.Repository\TightWiki.Repository.csproj" />
<ProjectReference Include="..\TightWiki.Security\TightWiki.Security.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Views\Admin\PageRevisions.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Update="Views\File\Revisions.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Update="wwwroot\css\light.css">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,199 @@
@using TightWiki.Models
@model TightWiki.Models.ViewModels.Admin.AccountProfileViewModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Account
</h3>
<p>
Configuration for user account.<br /><br />
</p>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">@Html.Raw(Model.ErrorMessage)</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Html.Raw(Model.SuccessMessage)</div>
}
@using (Html.BeginForm(null, null, new { navigation = Model.AccountProfile.Navigation }, FormMethod.Post, true, new { action = $"{GlobalConfiguration.BasePath}{Context.Request.Path}", enctype = "multipart/form-data" }))
{
@Html.AntiForgeryToken()
<div class="container">
<form>
@Html.HiddenFor(m => m.AccountProfile.UserId)
@Html.HiddenFor(m => m.AccountProfile.Navigation)
<div class="form-group row mb-1">
<label for="Avatar" class="col-sm-2 col-form-label"><strong>Avatar</strong></label>
<div class="col-sm-10">
@if (@Model.AccountProfile.Navigation != "")
{
<img src="@GlobalConfiguration.BasePath/Profile/@Model.AccountProfile.Navigation/Avatar?max=150" class="mb-3" />
}
<input type="file" id="Avatar" name="Avatar" class="form-control-file" onchange="fileCheck(this);" />
</div>
</div>
<div class="form-group row mb-1">
<label for="EmailAddress" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.EmailAddress)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.EmailAddress, new { @class = "form-control" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.EmailAddress)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="EmailConfirmed" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.EmailConfirmed)</strong></label>
<div class="col-sm-10">
@Html.CheckBoxFor(m => m.AccountProfile.EmailConfirmed, new { @class = "input-control" })
</div>
</div>
<div class="form-group row mb-1">
<label for="AccountName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.AccountName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.AccountName, new { @class = "form-control" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.AccountName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="FirstName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.FirstName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.FirstName, new { @class = "form-control" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.FirstName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="LastName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.LastName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.LastName, new { @class = "form-control" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.LastName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Role" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Role)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Role" id="AccountProfile.Role" class="form-control">
<option value="" style="color:#ccc !important;">Select a role</option>
@foreach (var item in Model.Roles)
{
<option value="@item.Name" selected=@(Model.AccountProfile.Role == item.Name ? "selected" : null)>
@item.Name
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Role)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Theme" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Theme)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Theme" id="AccountProfile.Theme" class="form-control">
<option value="">System Default</option>
@foreach (var item in Model.Themes)
{
<option value="@item.Name" selected=@(Model.AccountProfile.Theme == item.Name ? "selected" : null)>
@item.Name
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Theme)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Country" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Country)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Country" id="AccountProfile.Country" class="form-control">
<option value="" style="color:#ccc !important;">Select a country</option>
@foreach (var item in Model.Countries)
{
<option value="@item.Value" selected=@(Model.AccountProfile.Country == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Country)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Language" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Language)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Language" id="AccountProfile.Language" class="form-control">
<option value="" style="color:#ccc !important;">Select a language</option>
@foreach (var item in Model.Languages)
{
<option value="@item.Value" selected=@(Model.AccountProfile.Language == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Language)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="TimeZone" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.TimeZone)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.TimeZone" id="AccountProfile.TimeZone" class="form-control">
<option value="" style="color:#ccc !important;">Select a time-zone</option>
@foreach (var item in Model.TimeZones)
{
<option value="@item.Value" selected=@(Model.AccountProfile.TimeZone == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.TimeZone)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Password" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Credential.Password)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => m.Credential.Password, new { @class = "form-control", value = Model.Credential.Password })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Credential.Password)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="ComparePassword" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Credential.ComparePassword)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => m.Credential.ComparePassword, new { @class = "form-control", value = Model.Credential.Password })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Credential.ComparePassword)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Biography" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Biography)</strong></label>
<div class="col-sm-10">
@Html.TextAreaFor(m => m.AccountProfile.Biography, new { @class = "form-control", style = "height:200px", Name = "Biography" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Biography)</div>
</div>
</div>
<div class="form-group row mb-1">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn btn-primary rounded-0">Save!</button>
</div>
</div>
</form>
</div>
}
<br />
<form action="@GlobalConfiguration.BasePath/Admin/DeleteAccount/@Model.AccountProfile.Navigation"><button type="submit" class="btn btn-danger rounded-0">Delete Account</button></form>

View File

@@ -0,0 +1,90 @@
@model TightWiki.Models.ViewModels.Admin.AccountsViewModel
@using TightWiki.Library
@using TightWiki.Models
@{
Layout = "/Views/Shared/_Layout.cshtml";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Accounts
</h3>
<p>
Global configuration for user accounts.<br /><br />
</p>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">@Html.Raw(Model.ErrorMessage)</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Html.Raw(Model.SuccessMessage)</div>
}
<a class="btn btn-success btn-thin" href="@GlobalConfiguration.BasePath/Admin/AddAccount">Add new account</a>
<br />
<br />
@using (Html.BeginForm(null, null, FormMethod.Get, new { action = $"{GlobalConfiguration.BasePath}{Context.Request.Path}" }))
{
<div class="container">
<div class="d-flex justify-content-end mb-4">
<div class="flex-grow-1 me-2">
@Html.TextBoxFor(x => x.SearchString, new { @class = "form-control" })
</div>
<button type="submit" value="Search" class="btn btn-primary">Search</button>
</div>
</div>
@if (Model.Users.Count > 0)
{
<table class="table fixedTable100 table-striped" border="0" cellspacing="0" cellpadding="0">
<thead>
<tr>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "Account")">Account</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "FirstName")">First Name</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "LastName")">Last Name</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "Country")">Country</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "TimeZone")">TimeZone</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "EmailAddress")">EmailAddress</a></strong></td>
<td><strong><a href="?@QueryStringConverter.OrderHelper(sessionState, "Created")">CreatedDate</a></strong></td>
</tr>
</thead>
@foreach (var user in Model.Users)
{
<tr>
<td><a href="@GlobalConfiguration.BasePath/Admin/Account/@user.Navigation">@user.AccountName</a></td>
<td>@user.FirstName</td>
<td>@user.LastName</td>
<td>@user.Country</td>
<td>@user.TimeZone</td>
<td>@user.EmailAddress @Html.Raw(((user.EmailConfirmed == true) ? "&check;" : "")) </td>
<td>@user.CreatedDate</td>
</tr>
}
</table>
@Html.Raw(TightWiki.Library.PageSelectorGenerator.Generate(Context.Request.QueryString, Model.PaginationPageCount))
}
else
{
<div class="d-flex small text-muted mb-0">
<strong>
Either there are no accounts configured or your search criteria returned no results.
</strong>
</div>
}
}
<br />
<a class="btn btn-success btn-thin" href="@GlobalConfiguration.BasePath/Admin/AddAccount">Add new account</a>
<script>
window.onload = function () {
document.getElementById("SearchString").focus();
}
</script>

View File

@@ -0,0 +1,166 @@
@using TightWiki.Models
@model TightWiki.Models.ViewModels.Admin.AccountProfileViewModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<h3>
Add Account
</h3>
<p>
Create new user account.<br /><br />
</p>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">@Html.Raw(Model.ErrorMessage)</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Html.Raw(Model.SuccessMessage)</div>
}
@using (Html.BeginForm(null, null, new { navigation = Model.AccountProfile.Navigation }, FormMethod.Post, true, new { action = $"{GlobalConfiguration.BasePath}{Context.Request.Path}", enctype = "multipart/form-data" }))
{
@Html.AntiForgeryToken()
<div class="container">
<form>
@Html.HiddenFor(m => m.AccountProfile.Navigation)
<div class="form-group row mb-1">
<label for="Avatar" class="col-sm-2 col-form-label"><strong>Avatar</strong></label>
<div class="col-sm-10">
<input type="file" id="Avatar" name="Avatar" class="form-control-file" onchange="fileCheck(this);" />
</div>
</div>
<div class="form-group row mb-1">
<label for="EmailAddress" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.EmailAddress)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.EmailAddress, new { @class = "form-control", placeholder = "required" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.EmailAddress)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="AccountName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.AccountName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.AccountName, new { @class = "form-control", placeholder = "required" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.AccountName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="FirstName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.FirstName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.FirstName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.FirstName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="LastName" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.LastName)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.AccountProfile.LastName, new { @class = "form-control", placeholder = "not required" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.LastName)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Role" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Role)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Role" id="AccountProfile.Role" class="form-control">
<option value="" style="color:#ccc !important;">Select a role</option>
@foreach (var item in Model.Roles)
{
<option value="@item.Name" selected=@(Model.AccountProfile.Role == item.Name ? "selected" : null)>
@item.Name
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Role)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Country" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Country)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Country" id="AccountProfile.Country" class="form-control">
<option value="" style="color:#ccc !important;">Select a country</option>
@foreach (var item in Model.Countries)
{
<option value="@item.Value" selected=@(Model.AccountProfile.Country == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Country)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Language" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Language)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.Language" id="AccountProfile.Language" class="form-control">
<option value="" style="color:#ccc !important;">Select a language</option>
@foreach (var item in Model.Languages)
{
<option value="@item.Value" selected=@(Model.AccountProfile.Language == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Language)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="TimeZone" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.TimeZone)</strong></label>
<div class="col-sm-10">
<select name="AccountProfile.TimeZone" id="AccountProfile.TimeZone" class="form-control">
<option value="" style="color:#ccc !important;">Select a time-zone</option>
@foreach (var item in Model.TimeZones)
{
<option value="@item.Value" selected=@(Model.AccountProfile.TimeZone == item.Value ? "selected" : null)>
@item.Text
</option>
}
</select>
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.TimeZone)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Password" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Credential.Password)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => m.Credential.Password, new { @class = "form-control", value = Model.Credential.Password })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Credential.Password)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="ComparePassword" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Credential.ComparePassword)</strong></label>
<div class="col-sm-10">
@Html.PasswordFor(m => m.Credential.ComparePassword, new { @class = "form-control", value = Model.Credential.Password })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Credential.ComparePassword)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Biography" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.AccountProfile.Biography)</strong></label>
<div class="col-sm-10">
@Html.TextAreaFor(m => m.AccountProfile.Biography, new { @class = "form-control", style = "height:200px", Name = "Biography" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.AccountProfile.Biography)</div>
</div>
</div>
<div class="form-group row mb-1">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-success rounded-0">Save!</button>
</div>
</div>
</form>
</div>
}

View File

@@ -0,0 +1,78 @@
@using TightWiki.Models
@model TightWiki.Models.ViewModels.Admin.AddEmojiViewModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
var sessionState = ViewData["SessionState"] as TightWiki.SessionState ?? throw new Exception("Wiki State Context cannot be null.");
}
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', (event) => {
const imageData = document.getElementById('ImageData');
const categories = document.getElementById('Categories');
const name = document.getElementById('Name');
imageData.addEventListener('change', (event) => {
const selectedFile = event.target.files[0];
name.value = selectedFile.name.substr(0, selectedFile.name.lastIndexOf('.')).replace(/ /g, '-').replace(/_/g, '-');
categories.value = name.value.replace(/-/g, ',');
});
});
</script>
<h3>
Add Emoji
</h3>
<p>
Configuration to add an emoji.<br /><br />
</p>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">@Html.Raw(Model.ErrorMessage)</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Html.Raw(Model.SuccessMessage)</div>
}
@using (Html.BeginForm(null, null, null, FormMethod.Post, true, new { action = $"{GlobalConfiguration.BasePath}{Context.Request.Path}", enctype = "multipart/form-data" }))
{
@Html.AntiForgeryToken()
@Html.HiddenFor(model => model.OriginalName)
@Html.HiddenFor(model => model.Id)
<div class="container">
<form>
<div class="form-group row mb-1">
<label for="ImageData" class="col-sm-2 col-form-label"><strong>Image</strong></label>
<div class="col-sm-10">
<input type="file" id="ImageData" name="ImageData" class="form-control-file" onchange="fileCheck(this);" accept="image/png, image/jpeg, image/gif" />
</div>
</div>
<div class="form-group row mb-1">
<label for="Name" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Name)</strong></label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.Name, new { @class = "form-control", placeholder = "required" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Name)</div>
</div>
</div>
<div class="form-group row mb-1">
<label for="Categories" class="col-sm-2 col-form-label"><strong>@Html.LabelFor(m => m.Categories)</strong> (comma separated)</label>
<div class="col-sm-10">
@Html.TextBoxFor(m => m.Categories, new { @class = "form-control" })
<div class="text-danger">@Html.ValidationMessageFor(m => m.Categories)</div>
</div>
</div>
<div class="form-group row mb-1">
<div class="col-sm-10 offset-sm-2">
<button type="submit" class="btn btn-success rounded-0">Save!</button>
</div>
</div>
</form>
</div>
}
<br />

Some files were not shown because too many files have changed in this diff Show More