Files
ZelWiki/TightWiki/Controllers/ProfileController.cs
2025-01-22 23:31:03 +08:00

445 lines
17 KiB
C#

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