User settings, level settings, language and timezone selection and more. (#471)

* Initial work for user settings page

* Finish user setting and slot setting pages

* Don't show slot upload date on home page and fix team pick redirection

* Fix upload image button alignment on mobile

* Fix image upload on iPhone

* Remove unused css and add selected button color

* Fix login email check and bump ChromeDriver to 105

* Remove duplicated code and allow users to leave fields empty

* Add unpublish button on level settings and move settings button position

* Don't show edit button on mini card

* Self review bug fixes and users can no longer use an in-use email
This commit is contained in:
Josh 2022-09-17 14:02:46 -05:00 committed by GitHub
parent 9073a8266f
commit f6a7fe6283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 973 additions and 118 deletions

View file

@ -124,7 +124,7 @@ public static class LocalizationManager
.Where(r => r != "resources")
.ToList();
languages.Add(DefaultLang);
languages.Insert(0, DefaultLang);
return languages;
}

View file

@ -67,8 +67,7 @@ public class LoginController : ControllerBase
token = await this.database.AuthenticateUser(npTicket, ipAddress);
if (token == null)
{
Logger.Warn($"Unable to " +
$"find/generate a token for username {npTicket.Username}", LogArea.Login);
Logger.Warn($"Unable to find/generate a token for username {npTicket.Username}", LogArea.Login);
return this.StatusCode(403, ""); // If not, then 403.
}
}
@ -81,6 +80,12 @@ public class LoginController : ControllerBase
return this.StatusCode(403, "");
}
if (ServerConfiguration.Instance.Mail.MailEnabled && (user.EmailAddress == null || !user.EmailAddressVerified))
{
Logger.Error($"Email address unverified for user {user.Username}", LogArea.Login);
return this.StatusCode(403, "");
}
if (ServerConfiguration.Instance.Authentication.UseExternalAuth)
{
if (user.ApprovedIPAddress == ipAddress)

View file

@ -1,8 +1,6 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Administration;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -30,7 +28,7 @@ public class ModerationSlotController : ControllerBase
slot.TeamPick = true;
await this.database.SaveChangesAsync();
return this.Ok();
return this.Redirect("~/slot/" + id);
}
[HttpGet("removeTeamPick")]
@ -44,7 +42,7 @@ public class ModerationSlotController : ControllerBase
slot.TeamPick = false;
await this.database.SaveChangesAsync();
return this.Ok();
return this.Redirect("~/slot/" + id);
}
[HttpGet("delete")]
@ -58,6 +56,6 @@ public class ModerationSlotController : ControllerBase
await this.database.RemoveSlot(slot);
return this.Ok();
return this.Redirect("~/slots/0");
}
}

View file

@ -25,6 +25,27 @@ public class SlotPageController : ControllerBase
this.database = database;
}
[HttpGet("unpublish")]
public async Task<IActionResult> UnpublishSlot([FromRoute] int id)
{
WebToken? token = this.database.WebTokenFromRequest(this.Request);
if (token == null) return this.Redirect("~/login");
Slot? targetSlot = await this.database.Slots.Include(s => s.Location).FirstOrDefaultAsync(s => s.SlotId == id);
if (targetSlot == null) return this.Redirect("~/slots/0");
if (targetSlot.Location == null) throw new ArgumentNullException();
if (targetSlot.CreatorId != token.UserId) return this.Redirect("~/slot/" + id);
this.database.Locations.Remove(targetSlot.Location);
this.database.Slots.Remove(targetSlot);
await this.database.SaveChangesAsync();
return this.Redirect("~/slots/0");
}
[HttpGet("rateComment")]
public async Task<IActionResult> RateComment([FromRoute] int id, [FromQuery] int commentId, [FromQuery] int rating)
{

View file

@ -1,4 +1,3 @@
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using Microsoft.AspNetCore.Html;
@ -9,15 +8,19 @@ namespace LBPUnion.ProjectLighthouse.Servers.Website.Extensions;
public static class PartialExtensions
{
// ReSharper disable once SuggestBaseTypeForParameter
public static ViewDataDictionary<T> WithLang<T>(this ViewDataDictionary<T> viewData, string language)
public static ViewDataDictionary<T> WithLang<T>(this ViewDataDictionary<T> viewData, string language) => WithKeyValue(viewData, "Language", language);
public static ViewDataDictionary<T> WithTime<T>(this ViewDataDictionary<T> viewData, string timeZone) => WithKeyValue(viewData, "TimeZone", timeZone);
private static ViewDataDictionary<T> WithKeyValue<T>(this ViewDataDictionary<T> viewData, string key, object value)
{
try
{
return new(viewData)
return new ViewDataDictionary<T>(viewData)
{
{
"Language", language
key, value
},
};
}
@ -27,9 +30,9 @@ public static class PartialExtensions
}
}
public static Task<IHtmlContent> ToLink<T>(this User user, IHtmlHelper<T> helper, ViewDataDictionary<T> viewData, string language)
=> helper.PartialAsync("Partials/Links/UserLinkPartial", user, viewData.WithLang(language));
public static Task<IHtmlContent> ToLink<T>(this User user, IHtmlHelper<T> helper, ViewDataDictionary<T> viewData, string language, string timeZone = "", bool includeStatus = false)
=> helper.PartialAsync("Partials/Links/UserLinkPartial", user, viewData.WithLang(language).WithTime(timeZone).WithKeyValue("IncludeStatus", includeStatus));
public static Task<IHtmlContent> ToHtml<T>(this Photo photo, IHtmlHelper<T> helper, ViewDataDictionary<T> viewData, string language)
=> helper.PartialAsync("Partials/PhotoPartial", photo, viewData.WithLang(language));
public static Task<IHtmlContent> ToHtml<T>(this Photo photo, IHtmlHelper<T> helper, ViewDataDictionary<T> viewData, string language, string timeZone)
=> helper.PartialAsync("Partials/PhotoPartial", photo, viewData.WithLang(language).WithTime(timeZone));
}

View file

@ -10,7 +10,6 @@ public class HandlePageErrorMiddleware : Middleware
public override async Task InvokeAsync(HttpContext ctx)
{
await this.next(ctx);
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
if (ctx.Response.StatusCode == 404 && !ctx.Request.Path.StartsWithSegments("/gameAssets"))
{
try
@ -20,7 +19,7 @@ public class HandlePageErrorMiddleware : Middleware
finally
{
// not much we can do to save us, carry on anyways
await next(ctx);
await this.next(ctx);
}
}
}

View file

@ -0,0 +1,48 @@
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Middlewares;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Middlewares;
public class UserRequiredRedirectMiddleware : MiddlewareDBContext
{
public UserRequiredRedirectMiddleware(RequestDelegate next) : base(next)
{ }
public override async Task InvokeAsync(HttpContext ctx, Database database)
{
User? user = database.UserFromWebRequest(ctx.Request);
if (user == null || ctx.Request.Path.StartsWithSegments("/logout"))
{
await this.next(ctx);
return;
}
if (user.PasswordResetRequired && !ctx.Request.Path.StartsWithSegments("/passwordResetRequired") &&
!ctx.Request.Path.StartsWithSegments("/passwordReset"))
{
ctx.Response.Redirect("/passwordResetRequired");
return;
}
if (ServerConfiguration.Instance.Mail.MailEnabled)
{
// The normal flow is for users to set their email during login so just force them to log out
if (user.EmailAddress == null)
{
ctx.Response.Redirect("/logout");
return;
}
if (!user.EmailAddressVerified &&
!ctx.Request.Path.StartsWithSegments("/login/sendVerificationEmail") &&
!ctx.Request.Path.StartsWithSegments("/verifyEmail"))
{
ctx.Response.Redirect("/login/sendVerificationEmail");
return;
}
}
await this.next(ctx);
}
}

View file

@ -3,7 +3,6 @@ using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -30,6 +29,12 @@ public class CompleteEmailVerificationPage : BaseLayout
return this.Page();
}
if (DateTime.Now > emailVerifyToken.ExpiresAt)
{
this.Error = "This token has expired";
return this.Page();
}
if (emailVerifyToken.UserId != user.UserId)
{
this.Error = "This token doesn't belong to you!";

View file

@ -5,6 +5,8 @@
@{
Layout = "Layouts/BaseLayout";
Model.Title = "Authentication";
string timeZone = Model.GetTimeZone();
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
}
@if (Model.AuthenticationAttempts.Count == 0)
@ -41,9 +43,9 @@ else
@foreach (AuthenticationAttempt authAttempt in Model.AuthenticationAttempts)
{
DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp).ToLocalTime();
DateTimeOffset timestamp = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(authAttempt.Timestamp), timeZoneInfo);
<div class="ui red segment">
<p>A <b>@authAttempt.Platform</b> authentication request was logged at <b>@timestamp.ToString("MM/dd/yyyy @ h:mm tt") UTC</b> from the IP address <b>@authAttempt.IPAddress</b>.</p>
<p>A <b>@authAttempt.Platform</b> authentication request was logged at <b>@timestamp.ToString("M/d/yyyy @ h:mm tt")</b> from the IP address <b>@authAttempt.IPAddress</b>.</p>
<div>
<a href="/authentication/autoApprove/@authAttempt.AuthenticationAttemptId">
<button class="ui small green button">

View file

@ -11,8 +11,9 @@
Layout = "Layouts/BaseLayout";
Model.ShowTitleInPage = false;
bool isMobile = this.Request.IsMobile();
bool isMobile = Request.IsMobile();
string language = Model.GetLanguage();
string timeZone = Model.GetTimeZone();
}
<h1 class="lighthouse-welcome lighthouse-title">
@Model.Translate(LandingPageStrings.Welcome, ServerConfiguration.Instance.Customization.ServerName)
@ -48,7 +49,7 @@ else
foreach (User user in Model.PlayersOnline)
{
i++;
@await user.ToLink(Html, ViewData, language)if (i != Model.PlayersOnline.Count){<span>,</span>} @* whitespace has forced my hand *@
@await user.ToLink(Html, ViewData, language, timeZone, true)if (i != Model.PlayersOnline.Count){<span>,</span>} @* whitespace has forced my hand *@
}
}

View file

@ -1,6 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Localization;
using LBPUnion.ProjectLighthouse.Localization.StringLists;
@ -46,20 +44,31 @@ public class BaseLayout : PageModel
}
private string? language;
private string? timeZone;
public string GetLanguage()
{
if (ServerStatics.IsUnitTesting) return LocalizationManager.DefaultLang;
if (this.language != null) return this.language;
if (this.User?.IsAPirate ?? false) return "en-PT";
if (this.User != null) return this.language = this.User.Language;
IRequestCultureFeature? requestCulture = Request.HttpContext.Features.Get<IRequestCultureFeature>();
IRequestCultureFeature? requestCulture = this.Request.HttpContext.Features.Get<IRequestCultureFeature>();
if (requestCulture == null) return this.language = LocalizationManager.DefaultLang;
return this.language = requestCulture.RequestCulture.UICulture.Name;
}
public string GetTimeZone()
{
if (ServerStatics.IsUnitTesting) return TimeZoneInfo.Local.Id;
if (this.timeZone != null) return this.timeZone;
string userTimeZone = this.User?.TimeZone ?? TimeZoneInfo.Local.Id;
return this.timeZone = userTimeZone;
}
public string Translate(TranslatableString translatableString) => translatableString.Translate(this.GetLanguage());
public string Translate(TranslatableString translatableString, params object?[] format) => translatableString.Translate(this.GetLanguage(), format);
}

View file

@ -26,6 +26,9 @@
{
"Language", Model.GetLanguage()
},
{
"TimeZone", Model.GetTimeZone()
},
})
</div>
}

View file

@ -1,10 +1,13 @@
@page "/moderation/cases/{pageNumber:int}"
@using LBPUnion.ProjectLighthouse.Administration
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation.CasePage
@{
Layout = "Layouts/BaseLayout";
Model.Title = "Cases";
string timeZone = Model.GetTimeZone();
}
<p>There are @Model.CaseCount total cases, @Model.DismissedCaseCount of which have been dismissed.</p>
@ -20,5 +23,5 @@
@foreach (ModerationCase @case in Model.Cases)
{
@(await Html.PartialAsync("Partials/ModerationCasePartial", @case))
@(await Html.PartialAsync("Partials/ModerationCasePartial", @case, ViewData.WithTime(timeZone)))
}

View file

@ -1,5 +1,4 @@
@page "/moderation/newCase"
@using LBPUnion.ProjectLighthouse.Administration
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation.NewCasePage
@{

View file

@ -1,9 +1,11 @@
@page "/moderation/report/{reportId:int}"
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation.ReportPage
@{
Layout = "Layouts/BaseLayout";
Model.Title = $"Report {Model.Report.ReportId}";
string timeZone = Model.GetTimeZone();
}
<script>
@ -14,5 +16,5 @@
let images = [];
</script>
@await Html.PartialAsync("Partials/ReportPartial", Model.Report)
@await Html.PartialAsync("Partials/ReportPartial", Model.Report, ViewData.WithTime(timeZone))
@await Html.PartialAsync("Partials/RenderReportBoundsPartial")

View file

@ -1,10 +1,12 @@
@page "/moderation/reports/{pageNumber:int}"
@using LBPUnion.ProjectLighthouse.Administration.Reports
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation.ReportsPage
@{
Layout = "Layouts/BaseLayout";
Model.Title = "Reports";
string timeZone = Model.GetTimeZone();
}
<p>There are @Model.ReportCount total reports.</p>
@ -28,7 +30,7 @@
@foreach (GriefReport report in Model.Reports)
{
@await Html.PartialAsync("Partials/ReportPartial", report)
@await Html.PartialAsync("Partials/ReportPartial", report, ViewData.WithTime(timeZone))
}
@await Html.PartialAsync("Partials/RenderReportBoundsPartial")

View file

@ -6,6 +6,8 @@
@{
string language = (string?)ViewData["Language"] ?? LocalizationManager.DefaultLang;
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
}
<div class="ui yellow segment" id="comments">
@ -85,7 +87,7 @@
<span>@decodedMessage</span>
}
<p>
<i>@timestamp.ToString("MM/dd/yyyy @ h:mm tt") UTC</i>
<i>@TimeZoneInfo.ConvertTime(timestamp, timeZoneInfo).ToString("M/d/yyyy @ h:mm:ss tt")</i>
</p>
@if (i != Model.Comments.Count - 1)
{

View file

@ -3,9 +3,12 @@
@{
string language = (string?)ViewData["Language"] ?? LocalizationManager.DefaultLang;
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
bool includeStatus = (bool?)ViewData["IncludeStatus"] ?? false;
string userStatus = includeStatus ? Model.Status.ToTranslatedString(language, timeZone) : "";
}
<a href="/user/@Model.UserId" title="@Model.Status.ToTranslatedString(language)" class="user-link">
<a href="/user/@Model.UserId" title="@userStatus" class="user-link">
<img src="/gameAssets/@Model.WebsiteAvatarHash" alt=""/>
@Model.Username
</a>

View file

@ -1,7 +1,6 @@
@using System.Diagnostics
@using LBPUnion.ProjectLighthouse
@using LBPUnion.ProjectLighthouse.Administration
@using LBPUnion.ProjectLighthouse.Configuration
@using LBPUnion.ProjectLighthouse.Levels
@using LBPUnion.ProjectLighthouse.PlayerData.Profiles
@model LBPUnion.ProjectLighthouse.Administration.ModerationCase
@ -10,6 +9,9 @@
Database database = new();
string color = "blue";
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
if (Model.Expired) color = "yellow";
if (Model.Dismissed) color = "green";
}
@ -23,19 +25,19 @@
Debug.Assert(Model.DismissedAt != null);
<h3 class="ui @color header">
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.Dismisser.Username</a> on @Model.DismissedAt.Value.ToString("MM/dd/yyyy @ h:mm tt").
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.Dismisser.Username</a> on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
else if (Model.Expired)
else if (Model.Expired && Model.ExpiresAt != null)
{
<h3 class="ui @color header">
This case expired on @Model.ExpiresAt!.Value.ToString("MM/dd/yyyy @ h:mm tt").
This case expired on @TimeZoneInfo.ConvertTime(Model.ExpiresAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
<span>
Case created by <a href="/user/@Model.Creator!.UserId">@Model.Creator.Username</a>
on @Model.CreatedAt.ToString("MM/dd/yyyy @ h:mm tt")
on @TimeZoneInfo.ConvertTime(Model.CreatedAt, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt")
</span><br>
@if (Model.Type.AffectsLevel())

View file

@ -8,6 +8,8 @@
@{
string language = (string?)ViewData["Language"] ?? LocalizationManager.DefaultLang;
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
}
<div style="position: relative">
@ -26,7 +28,7 @@
{
<span>by</span>
<b>
@await Model.Creator.ToLink(Html, ViewData, language)
@await Model.Creator.ToLink(Html, ViewData, language, timeZone)
</b>
}
@if (Model.Slot != null)
@ -49,7 +51,7 @@
break;
}
}
at @DateTime.UnixEpoch.AddSeconds(Model.Timestamp).ToString(CultureInfo.CurrentCulture)
at @TimeZoneInfo.ConvertTime(DateTime.UnixEpoch.AddSeconds(Model.Timestamp), timeZoneInfo).ToString("M/d/yyyy h:mm:ss tt")
</i>
</p>
@ -62,7 +64,7 @@
<div id="hover-subjects-@Model.PhotoId">
@foreach (PhotoSubject subject in Model.Subjects)
{
@await subject.User.ToLink(Html, ViewData, language)
@await subject.User.ToLink(Html, ViewData, language, timeZone)
}
</div>

View file

@ -1,7 +1,12 @@
@using LBPUnion.ProjectLighthouse.Administration.Reports
@model LBPUnion.ProjectLighthouse.Administration.Reports.GriefReport
<div class="ui segment">
@{
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
}
<div class="ui segment">
<div>
<canvas class="hide-subjects" id="canvas-subjects-@Model.ReportId" width="1920" height="1080"
style="position: absolute; transform: rotate(180deg)">
@ -26,7 +31,7 @@
<br>
<div>
<b>Report time: </b>@(DateTimeOffset.FromUnixTimeMilliseconds(Model.Timestamp).ToLocalTime().ToString("R"))
<b>Report time: </b>@(TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeMilliseconds(Model.Timestamp), timeZoneInfo).ToString("M/d/yyyy h:mm:ss tt"))
</div>
<div>
<b>Report reason: </b>@Model.Type

View file

@ -18,6 +18,8 @@
bool isMobile = (bool?)ViewData["IsMobile"] ?? false;
bool mini = (bool?)ViewData["IsMini"] ?? false;
string language = (string?)ViewData["Language"] ?? LocalizationManager.DefaultLang;
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
bool isQueued = false;
bool isHearted = false;
@ -39,9 +41,9 @@
int size = isMobile || mini ? 50 : 100;
}
<div>
<img src="~/assets/slotCardOverlay.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; pointer-events: none; position: absolute">
<img src="~/assets/slotCardBackground.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; position: absolute; z-index: -1;">
<img class="cardIcon slotCardIcon" src="/gameAssets/@iconHash" style="min-width: @(size)px; width: @(size)px; height: @(size)px;"
<img src="~/assets/slotCardOverlay.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; pointer-events: none; position: absolute; z-index: 3">
<img src="~/assets/slotCardBackground.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; position: absolute; z-index: 1;">
<img class="cardIcon slotCardIcon" src="/gameAssets/@iconHash" style="min-width: @(size)px; width: @(size)px; height: @(size)px; position: relative; z-index: 2"
onerror="this.onerror='';this.src='/gameAssets/@ServerConfiguration.Instance.WebsiteConfiguration.MissingIconHash'">
</div>
<div class="cardStats">
@ -90,12 +92,24 @@
</div>
@if (Model.Creator != null)
{
string date = "";
if(!mini)
date = " on " + TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeMilliseconds(Model.FirstUploaded), timeZoneInfo).DateTime.ToShortDateString();
<p>
<i>Created by @await Model.Creator.ToLink(Html, ViewData, language) on @Model.GameVersion.ToPrettyString()</i>
<i>Created by @await Model.Creator.ToLink(Html, ViewData, language) in @Model.GameVersion.ToPrettyString()@date</i>
</p>
}
</div>
<div class="cardButtons">
<br>
@if (user != null && !mini && (user.IsModerator || user.UserId == Model.CreatorId))
{
<a class="ui blue tiny button" href="/slot/@Model.SlotId/settings" title="Settings">
<i class="cog icon" style="margin: 0"></i>
</a>
}
</div>
<div class="cardButtons" style="margin-left: 0">
<br>
@if (user != null && !mini)
{

View file

@ -6,13 +6,14 @@
bool showLink = (bool?)ViewData["ShowLink"] ?? false;
bool isMobile = (bool?)ViewData["IsMobile"] ?? false;
string language = (string?)ViewData["Language"] ?? LocalizationManager.DefaultLang;
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
}
<div class="card">
@{
int size = isMobile ? 50 : 100;
}
<div class="cardIcon userCardIcon" style="background-image: url('/gameAssets/@Model.WebsiteAvatarHash'); min-width: @(size)px; width: @(size)px; height: @(size)px; background-position: center center; background-size: auto @(size)px;">
<div class="cardIcon userCardIcon" style="background-image: url('/gameAssets/@Model.WebsiteAvatarHash'); min-width: @(size)px; width: @(size)px; height: @(size)px; background-position: center center; background-size: cover; background-repeat: no-repeat">
</div>
<div class="cardStats">
@if (showLink)
@ -28,7 +29,7 @@
</h1>
}
<span>
<i>@Model.Status.ToTranslatedString(language)</i>
<i>@Model.Status.ToTranslatedString(language, timeZone)</i>
</span>
<div class="cardStatsUnderTitle">
<i class="pink heart icon" title="Hearts"></i> <span>@Model.Hearts</span>

View file

@ -1,6 +1,4 @@
@page "/passwordResetRequired"
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.PasswordResetRequiredPage
@{

View file

@ -2,7 +2,6 @@
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.PlayerData
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@using LBPUnion.ProjectLighthouse.Types
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.PhotosPage
@{
@ -10,6 +9,7 @@
Model.Title = Model.Translate(BaseLayoutStrings.HeaderPhotos);
string language = Model.GetLanguage();
string timeZone = Model.GetTimeZone();
}
<p>There are @Model.PhotoCount total photos!</p>
@ -25,7 +25,7 @@
@foreach (Photo photo in Model.Photos)
{
<div class="ui segment">
@await photo.ToHtml(Html, ViewData, language)
@await photo.ToHtml(Html, ViewData, language, timeZone)
</div>
}

View file

@ -7,7 +7,7 @@
}
<!--suppress GrazieInspection -->
@if (!Model.User!.IsAPirate)
@if (Model.User!.Language != "en-PT")
{
<p>So, ye wanna be a pirate? Well, ye came to the right place!</p>
<p>Just click this 'ere button, and welcome aboard!</p>

View file

@ -1,8 +1,6 @@
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
@ -24,7 +22,7 @@ public class PirateSignupPage : BaseLayout
User? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("/login");
user.IsAPirate = !user.IsAPirate;
user.Language = user.Language == "en-PT" ? "en" : "en-PT";
await this.Database.SaveChangesAsync();
return this.Redirect("/");

View file

@ -72,7 +72,7 @@ public class RegisterForm : BaseLayout
}
if (ServerConfiguration.Instance.Mail.MailEnabled &&
await this.Database.Users.FirstOrDefaultAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == emailAddress.ToLower()) != null)
await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == emailAddress.ToLower()))
{
this.Error = this.Translate(ErrorStrings.EmailTaken);
return this.Page();
@ -86,7 +86,7 @@ public class RegisterForm : BaseLayout
if (this.Request.Query.ContainsKey("token"))
{
await Database.RemoveRegistrationToken(this.Request.Query["token"]);
await this.Database.RemoveRegistrationToken(this.Request.Query["token"]);
}
User user = await this.Database.CreateUser(username, CryptoHelper.BCryptHash(password), emailAddress);

View file

@ -6,8 +6,15 @@
Model.Title = "Verify Email Address";
}
<p>An email address on your account has been set, but hasn't been verified yet.</p>
<p>To verify it, check the email sent to <a href="mailto:@Model.User?.EmailAddress">@Model.User?.EmailAddress</a> and click the link in the email.</p>
@if (Model.Success)
{
<p>An email address on your account has been set, but hasn't been verified yet.</p>
<p>To verify it, check the email sent to <a href="mailto:@Model.User?.EmailAddress">@Model.User?.EmailAddress</a> and click the link in the email.</p>
}
else
{
<p>Failed to send email, please try again later</p>
}
<a href="/login/sendVerificationEmail">
<div class="ui blue button">Resend email</div>

View file

@ -4,8 +4,8 @@ using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
@ -14,6 +14,8 @@ public class SendVerificationEmailPage : BaseLayout
public SendVerificationEmailPage(Database database) : base(database)
{}
public bool Success { get; set; }
public async Task<IActionResult> OnGet()
{
if (!ServerConfiguration.Instance.Mail.MailEnabled) return this.NotFound();
@ -33,29 +35,29 @@ public class SendVerificationEmailPage : BaseLayout
}
#endif
EmailVerificationToken verifyToken = new()
EmailVerificationToken? verifyToken = await this.Database.EmailVerificationTokens.FirstOrDefaultAsync(v => v.UserId == user.UserId);
// If user doesn't have a token or it is expired then regenerate
if (verifyToken == null || DateTime.Now > verifyToken.ExpiresAt)
{
verifyToken = new EmailVerificationToken
{
UserId = user.UserId,
User = user,
EmailToken = CryptoHelper.GenerateAuthToken(),
ExpiresAt = DateTime.Now.AddHours(6),
};
this.Database.EmailVerificationTokens.Add(verifyToken);
await this.Database.SaveChangesAsync();
}
string body = "Hello,\n\n" +
$"This email is a request to verify this email for your (likely new!) Project Lighthouse account ({user.Username}).\n\n" +
$"To verify your account, click the following link: {ServerConfiguration.Instance.ExternalUrl}/verifyEmail?token={verifyToken.EmailToken}\n\n\n" +
"If this wasn't you, feel free to ignore this email.";
if (SMTPHelper.SendEmail(user.EmailAddress, "Project Lighthouse Email Verification", body))
{
this.Success = SMTPHelper.SendEmail(user.EmailAddress, "Project Lighthouse Email Verification", body);
return this.Page();
}
else
{
throw new Exception("failed to send email");
}
}
}

View file

@ -1,5 +1,6 @@
@page "/login/setEmail"
@using LBPUnion.ProjectLighthouse.Configuration
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.SetEmailForm
@{
@ -9,6 +10,16 @@
<p>This instance requires email verification. As your account was created before this was a requirement, you must now set an email for your account before continuing.</p>
@if (!string.IsNullOrWhiteSpace(Model.Error))
{
<div class="ui negative message">
<div class="header">
@Model.Translate(GeneralStrings.Error)
</div>
<p style="white-space: pre-line">@Model.Error</p>
</div>
}
<form class="ui form" onsubmit="return onSubmit(this)" method="post">
@Html.AntiForgeryToken()

View file

@ -1,12 +1,13 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Localization.StringLists;
using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles.Email;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -19,6 +20,8 @@ public class SetEmailForm : BaseLayout
public EmailSetToken? EmailToken;
public string? Error { get; private set; }
public async Task<IActionResult> OnGet(string? token = null)
{
if (!ServerConfiguration.Instance.Mail.MailEnabled) return this.NotFound();
@ -32,6 +35,7 @@ public class SetEmailForm : BaseLayout
return this.Page();
}
[SuppressMessage("ReSharper", "SpecifyStringComparison")]
public async Task<IActionResult> OnPost(string emailAddress, string token)
{
if (!ServerConfiguration.Instance.Mail.MailEnabled) return this.NotFound();
@ -39,6 +43,13 @@ public class SetEmailForm : BaseLayout
EmailSetToken? emailToken = await this.Database.EmailSetTokens.Include(t => t.User).FirstOrDefaultAsync(t => t.EmailToken == token);
if (emailToken == null) return this.Redirect("/login");
if (await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == emailAddress.ToLower()))
{
this.Error = this.Translate(ErrorStrings.EmailTaken);
this.EmailToken = emailToken;
return this.Page();
}
emailToken.User.EmailAddress = emailAddress;
this.Database.EmailSetTokens.Remove(emailToken);

View file

@ -19,6 +19,7 @@
bool isMobile = this.Request.IsMobile();
string language = Model.GetLanguage();
string timeZone = Model.GetTimeZone();
}
@if (Model.Slot!.Hidden)
@ -96,7 +97,7 @@
<br/>
}
<div class="eight wide column">
@await Html.PartialAsync("Partials/CommentsPartial", ViewData.WithLang(language))
@await Html.PartialAsync("Partials/CommentsPartial", ViewData.WithLang(language).WithTime(timeZone))
</div>
@if (isMobile)
{
@ -216,7 +217,7 @@
@foreach (Photo photo in Model.Photos)
{
<div class="eight wide column">
@await photo.ToHtml(Html, ViewData, language)
@await photo.ToHtml(Html, ViewData, language, timeZone)
</div>
}
</div>

View file

@ -0,0 +1,168 @@
@page "/slot/{slotId:int}/settings"
@using System.Web
@using LBPUnion.ProjectLighthouse.Configuration
@using LBPUnion.ProjectLighthouse.Extensions
@using LBPUnion.ProjectLighthouse.Helpers
@using LBPUnion.ProjectLighthouse.Levels
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.PlayerData
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.SlotSettingsPage
@{
Layout = "Layouts/BaseLayout";
Model.ShowTitleInPage = false;
Model.Title = HttpUtility.HtmlDecode(Model.Slot?.Name ?? "");
bool isMobile = Request.IsMobile();
int size = isMobile ? 100 : 200;
}
<script>
function onSubmit(){
document.getElementById("avatar-encoded").value = selectedAvatar.toString();
document.getElementById("labels").value = serializeLabels();
return true;
}
</script>
<div class="@(isMobile ? "" : "ui center aligned grid")">
<div class="eight wide column">
<div class="ui blue segment">
<h1><i class="cog icon"></i>Slot Settings</h1>
<div class="ui divider"></div>
<form id="form" method="POST" class="ui form center aligned" action="/slot/@Model.Slot!.SlotId/settings" onsubmit="onSubmit()">
@Html.AntiForgeryToken()
<div class="field" style="display: flex; justify-content: center; align-items: center;">
<div>
<div>
<img src="~/assets/slotCardOverlay.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; pointer-events: none; position: absolute; z-index: 3;">
<img src="~/assets/slotCardBackground.png" style="min-width: @(size)px; width: @(size)px; height: @(size)px; position: absolute; z-index: 1;">
<img id="slotIcon" class="cardIcon slotCardIcon" src="/gameAssets/@Model.Slot.IconHash" style="min-width: @(size)px; width: @(size)px; height: @(size)px; position: relative; z-index: 2"
onerror="this.onerror='';this.src='/gameAssets/@ServerConfiguration.Instance.WebsiteConfiguration.MissingIconHash'">
</div>
<div class="ui fitted divider hidden"></div>
<label for="avatar" class="ui blue button" style="color: white; max-width: @(size)px">
<i class="arrow circle up icon"></i>
<span>Upload file</span>
</label>
<input style="display: none" type="file" id="avatar" accept="image/png, image/jpeg">
<input type="hidden" name="avatar" id="avatar-encoded">
</div>
</div>
<div class="field">
<label style="text-align: left" for="name">@Model.Translate(GeneralStrings.Username)</label>
<input type="text" name="name" id="name" value="@HttpUtility.HtmlDecode(Model.Slot.Name)" placeholder="Name">
</div>
<div class="field">
<label style="text-align: left" for="description">Description</label>
<textarea name="description" id="description" spellcheck="false" placeholder="Description">@HttpUtility.HtmlDecode(Model.Slot.Description)</textarea>
</div>
@if (Model.Slot.GameVersion != GameVersion.LittleBigPlanet1)
{
<div class="field">
<label style="text-align: left">Labels</label>
@{
foreach (string s in Enum.GetNames(typeof(LevelLabels)))
{
if (!LabelHelper.isValidForGame(s, Model.Slot.GameVersion)) continue;
string color = "";
if (Model.Slot.AuthorLabels.Contains(s)) color += "selected";
<button type="button" onclick="labelButtonClick(event)" onmouseleave="onHoverStart(this)" onmouseenter="onHoverStart(this)" style="margin: .35em" class="ui button skew @color" id="@s">@LabelHelper.TranslateTag(s)</button>
}
}
<input type="hidden" name="labels" id="labels">
</div>
}
<button class="ui button green" tabindex="0">Save Changes</button>
<a class="ui button red" href="/slot/@Model.Slot.SlotId">Discard Changes</a>
<div class="ui divider fitted hidden"></div>
@if (Model.Slot.CreatorId == Model.User!.UserId)
{
<button type="button" class="ui button red" onclick="confirmUnpublish()">Unpublish level</button>
}
</form>
</div>
</div>
</div>
<script>
let selectedButtons = [];
@if (Model.Slot.CreatorId == Model.User.UserId)
{
<text>
function confirmUnpublish(){
if (window.confirm("Are you sure you want to unpublish this level?\nThis action cannot be undone")){
window.location.href = "/slot/@Model.Slot.SlotId/unpublish";
}
}
</text>
}
function onHoverStart(btn){
generateRandomSkew(btn);
}
function generateRandomSkew(element){
let rand = Math.random() * 6 - 3;
element.style.setProperty("--skew", "rotate(" + rand + "deg)");
}
function setupButtons(){
const elements = document.getElementsByClassName("skew");
for (let i = 0; i < elements.length; i++) {
generateRandomSkew(elements[i]);
if (elements[i].classList.contains("selected"))
selectedButtons.push(elements[i]);
}
}
function serializeLabels(){
let labels = "";
for (let i = 0; i < selectedButtons.length; i++) {
if (selectedButtons[i] == null) continue;
labels += selectedButtons[i].id;
if (i !== selectedButtons.length - 1) {
labels += ",";
}
}
return labels;
}
function labelButtonClick(e){
e.preventDefault();
const target = e.target;
target.blur();
if (target.classList.contains("selected")){
target.classList.remove("selected");
} else {
target.classList.add("selected");
}
if (selectedButtons.includes(target)){
let startIndex = selectedButtons.indexOf(target);
selectedButtons.splice(startIndex, 1);
} else {
selectedButtons.push(target);
if (selectedButtons.length > 5){
let removed = selectedButtons.shift();
removed.classList.remove("selected");
}
}
}
setupButtons();
let selectedAvatar = "";
document.getElementById("avatar").onchange = function (e){
const file = e.target.files.item(0);
if (file.type !== "image/jpeg" && file.type !== "image/png")
return;
const output = document.getElementById('slotIcon');
const reader = new FileReader();
reader.onload = function(){
output.src = reader.result;
selectedAvatar = reader.result;
};
reader.readAsDataURL(file);
}
</script>

View file

@ -0,0 +1,61 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Files;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
public class SlotSettingsPage : BaseLayout
{
public Slot? Slot;
public SlotSettingsPage(Database database) : base(database)
{}
public async Task<IActionResult> OnPost([FromRoute] int slotId, [FromForm] string? avatar, [FromForm] string? name, [FromForm] string? description, string? labels)
{
this.Slot = await this.Database.Slots.FirstOrDefaultAsync(u => u.SlotId == slotId);
if (this.Slot == null) return this.NotFound();
if (this.User == null) return this.Redirect("~/slot/" + slotId);
if (!this.User.IsModerator && this.User != this.Slot.Creator) return this.Redirect("~/slot/" + slotId);
string? avatarHash = await FileHelper.ParseBase64Image(avatar);
if (avatarHash != null) this.Slot.IconHash = avatarHash;
name = SanitizationHelper.SanitizeString(name);
if (this.Slot.Name != name) this.Slot.Name = name;
description = SanitizationHelper.SanitizeString(description);
if (this.Slot.Description != description) this.Slot.Description = description;
labels = LabelHelper.RemoveInvalidLabels(SanitizationHelper.SanitizeString(labels));
if (this.Slot.AuthorLabels != labels) this.Slot.AuthorLabels = labels;
// ReSharper disable once InvertIf
if (this.Database.ChangeTracker.HasChanges())
{
this.Slot.LastUpdated = TimeHelper.TimestampMillis;
await this.Database.SaveChangesAsync();
}
return this.Redirect("~/slot/" + slotId);
}
public async Task<IActionResult> OnGet([FromRoute] int slotId)
{
this.Slot = await this.Database.Slots.FirstOrDefaultAsync(s => s.SlotId == slotId);
if (this.Slot == null) return this.NotFound();
if (this.User == null) return this.Redirect("~/slot/" + slotId);
if(!this.User.IsModerator && this.User.UserId != this.Slot.CreatorId) return this.Redirect("~/slot/" + slotId);
return this.Page();
}
}

View file

@ -5,7 +5,6 @@
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.PlayerData
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@using LBPUnion.ProjectLighthouse.Types
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.UserPage
@{
@ -15,8 +14,9 @@
Model.Title = Model.Translate(ProfileStrings.Title, Model.ProfileUser!.Username);
Model.Description = Model.ProfileUser!.Biography;
bool isMobile = this.Request.IsMobile();
bool isMobile = Request.IsMobile();
string language = Model.GetLanguage();
string timeZone = Model.GetTimeZone();
}
@if (Model.ProfileUser.IsBanned)
@ -50,7 +50,10 @@
},
{
"Language", Model.GetLanguage()
}
},
{
"TimeZone", Model.GetTimeZone()
},
})
</div>
<div class="eight wide right aligned column">
@ -72,13 +75,15 @@
</a>
}
}
@if (Model.ProfileUser == Model.User || (Model.User?.IsModerator ?? false))
{
<a class="ui blue button" href="/user/@Model.ProfileUser.UserId/settings">
<i class="cog icon"></i>
<span>Settings</span>
</a>
}
@if (Model.ProfileUser == Model.User)
{
<a class="ui blue button" href="/passwordReset">
<i class="key icon"></i>
<span>@Model.Translate(GeneralStrings.ResetPassword)</span>
</a>
<a href="/logout" class="ui red button">
<i class="user slash icon"></i>
@Model.Translate(BaseLayoutStrings.HeaderLogout)
@ -129,7 +134,7 @@
{
string width = isMobile ? "sixteen" : "eight";
<div class="@width wide column">
@await photo.ToHtml(Html, ViewData, language)
@await photo.ToHtml(Html, ViewData, language, timeZone)
</div>
}
</div>
@ -140,7 +145,7 @@
}
}
@await Html.PartialAsync("Partials/CommentsPartial", ViewData.WithLang(language))
@await Html.PartialAsync("Partials/CommentsPartial", ViewData.WithLang(language).WithTime(timeZone))
@if (Model.User != null && Model.User.IsModerator)
{

View file

@ -0,0 +1,148 @@
@page "/user/{userId:int}/settings"
@using System.Globalization
@using System.Web
@using LBPUnion.ProjectLighthouse.Configuration
@using LBPUnion.ProjectLighthouse.Extensions
@using LBPUnion.ProjectLighthouse.Localization
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.UserSettingsPage
@{
Layout = "Layouts/BaseLayout";
Model.ShowTitleInPage = false;
Model.Title = Model.Translate(ProfileStrings.Title, Model.ProfileUser!.Username);
bool isMobile = Request.IsMobile();
int size = isMobile ? 100 : 200;
}
<script>
function onSubmit(e){
document.getElementById("avatar-encoded").value = selectedAvatar.toString();
@if(ServerConfiguration.Instance.Mail.MailEnabled){
<text>
let newEmail = document.getElementById("email").value;
if (newEmail.length === 0){
e.preventDefault();
return false;
}
if (newEmail !== email){
if (!window.confirm("This action will change your email to '" + newEmail + "'\nYour old email will be removed from your account if you continue")){
e.preventDefault();
return false;
}
}
</text>
}
return true;
}
</script>
<div class="@(isMobile ? "" : "ui center aligned grid")">
<div class="eight wide column">
<div class="ui blue segment">
<h1><i class="cog icon"></i>@Model.ProfileUser.Username's Settings</h1>
<div class="ui divider"></div>
<form id="form" method="POST" class="ui form center aligned" action="/user/@Model.ProfileUser.UserId/settings" onsubmit="onSubmit(event)">
@Html.AntiForgeryToken()
<div class="field" style="display: flex; justify-content: center; align-items: center">
<div>
<div class="cardIcon userCardIcon" id="userPicture" style="background-image: url('/gameAssets/@Model.ProfileUser.WebsiteAvatarHash'); min-width: @(size)px; width: @(size)px; height: @(size)px; background-position: center center; background-size: cover; background-repeat: no-repeat;"></div>
<div class="ui fitted divider hidden"></div>
<label for="avatar" class="ui blue button" style="color: white; max-width: @(size)px">
<i class="arrow circle up icon"></i>
<span>Upload file</span>
</label>
<input style="display: none" type="file" id="avatar" accept="image/png, image/jpeg">
<input type="hidden" name="avatar" id="avatar-encoded">
</div>
</div>
<div class="field">
<label style="text-align: left" for="username">@Model.Translate(GeneralStrings.Username)</label>
<input type="text" name="username" id="username" value="@Model.ProfileUser.Username" placeholder="Username" readonly>
</div>
@if (ServerConfiguration.Instance.Mail.MailEnabled && (Model.User == Model.ProfileUser || Model.User!.IsAdmin))
{
<div class="field">
<label style="text-align: left" for="email">Email</label>
<input type="text" name="email" id="email" required value="@Model.ProfileUser.EmailAddress" placeholder="Email Address">
</div>
}
<div class="field">
<label style="text-align: left" for="biography">@Model.Translate(ProfileStrings.Biography)</label>
<textarea name="biography" id="biography" spellcheck="false" placeholder="Biography">@HttpUtility.HtmlDecode(Model.ProfileUser.Biography)</textarea>
</div>
@if (Model.User == Model.ProfileUser)
{
<div class="field">
<label style="text-align: left">Language</label>
<select class="ui fluid dropdown" name="language">
@foreach (string lang in LocalizationManager.GetAvailableLanguages())
{
string selected = "";
if (lang.Equals(Model.ProfileUser.Language))
{
selected = " selected=\"selected\"";
}
string langName = new CultureInfo(lang).DisplayName;
if (lang == "en-PT") langName = "Pirate Speak (The Seven Seas)";
<option value="@lang"@selected>@langName</option>
}
</select>
</div>
<div class="field">
<label style="text-align: left">Timezone</label>
<select class="ui fluid dropdown" name="timeZone">
@foreach (TimeZoneInfo systemTimeZone in TimeZoneInfo.GetSystemTimeZones())
{
string selected = "";
if (systemTimeZone.Id.Equals(Model.ProfileUser.TimeZone))
{
selected = " selected=\"selected\"";
}
<option value="@systemTimeZone.Id"@selected>@systemTimeZone.DisplayName</option>
}
</select>
</div>
}
<button class="ui button green" tabindex="0">Save Changes</button>
<a class="ui button red" href="/user/@Model.ProfileUser.UserId">Discard Changes</a>
<div class="ui divider fitted hidden"></div>
@if (Model.User == Model.ProfileUser)
{
<a class="ui blue button" href="/passwordReset">
<i class="key icon"></i>
@Model.Translate(GeneralStrings.ResetPassword)
</a>
}
</form>
</div>
</div>
</div>
<script>
@if (ServerConfiguration.Instance.Mail.MailEnabled)
{
<text>
const email = document.getElementById("email").value;
</text>
}
let selectedAvatar = "";
document.getElementById("avatar").addEventListener("change", e => {
const file = e.target.files.item(0);
if (file.type !== "image/jpeg" && file.type !== "image/png")
return;
const output = document.getElementById('userPicture');
const reader = new FileReader();
reader.onload = function(){
output.style.backgroundImage = "url(" + reader.result + ")";
selectedAvatar = reader.result;
};
reader.readAsDataURL(file);
});
</script>

View file

@ -0,0 +1,86 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Files;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Localization;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
public class UserSettingsPage : BaseLayout
{
public User? ProfileUser;
public UserSettingsPage(Database database) : base(database)
{}
private static bool IsValidEmail(string? email) => !string.IsNullOrWhiteSpace(email) && new EmailAddressAttribute().IsValid(email);
[SuppressMessage("ReSharper", "SpecifyStringComparison")]
public async Task<IActionResult> OnPost([FromRoute] int userId, [FromForm] string? avatar, [FromForm] string? username, [FromForm] string? email, [FromForm] string? biography, [FromForm] string? timeZone, [FromForm] string? language)
{
this.ProfileUser = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == userId);
if (this.ProfileUser == null) return this.NotFound();
if (this.User == null) return this.Redirect("~/user/" + userId);
if (!this.User.IsModerator && this.User != this.ProfileUser) return this.Redirect("~/user/" + userId);
string? avatarHash = await FileHelper.ParseBase64Image(avatar);
if (avatarHash != null) this.ProfileUser.IconHash = avatarHash;
biography = SanitizationHelper.SanitizeString(biography);
if (this.ProfileUser.Biography != biography) this.ProfileUser.Biography = biography;
if (ServerConfiguration.Instance.Mail.MailEnabled && IsValidEmail(email) && (this.User == this.ProfileUser || this.User.IsAdmin))
{
// if email hasn't already been used
if (!await this.Database.Users.AnyAsync(u => u.EmailAddress != null && u.EmailAddress.ToLower() == email!.ToLower()))
{
if (this.ProfileUser.EmailAddress != email)
{
this.ProfileUser.EmailAddress = email;
this.ProfileUser.EmailAddressVerified = false;
}
}
}
if (this.ProfileUser == this.User)
{
if (!string.IsNullOrWhiteSpace(language) && this.ProfileUser.Language != language)
{
if (LocalizationManager.GetAvailableLanguages().Contains(language))
this.ProfileUser.Language = language;
}
if (!string.IsNullOrWhiteSpace(timeZone) && this.ProfileUser.TimeZone != timeZone)
{
HashSet<string> timeZoneIds = TimeZoneInfo.GetSystemTimeZones().Select(t => t.Id).ToHashSet();
if (timeZoneIds.Contains(timeZone)) this.ProfileUser.TimeZone = timeZone;
}
}
await this.Database.SaveChangesAsync();
return this.Redirect("~/user/" + userId);
}
public async Task<IActionResult> OnGet([FromRoute] int userId)
{
this.ProfileUser = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == userId);
if (this.ProfileUser == null) return this.NotFound();
if (this.User == null) return this.Redirect("~/user/" + userId);
if(!this.User.IsModerator && this.User != this.ProfileUser) return this.Redirect("~/user/" + userId);
return this.Page();
}
}

View file

@ -2,7 +2,6 @@
@using LBPUnion.ProjectLighthouse.Extensions
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.PlayerData.Profiles
@using LBPUnion.ProjectLighthouse.Types
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.UsersPage
@{
@ -35,6 +34,9 @@
{
"Language", Model.GetLanguage()
},
{
"TimeZone", Model.GetTimeZone()
},
})
</div>
}

View file

@ -80,6 +80,7 @@ public class WebsiteStartup
app.UseMiddleware<HandlePageErrorMiddleware>();
app.UseMiddleware<RequestLogMiddleware>();
app.UseMiddleware<UserRequiredRedirectMiddleware>();
app.UseRouting();

View file

@ -16,7 +16,7 @@
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.4.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="103.0.5060.13400" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="105.0.5195.5200" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -6,14 +6,15 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DDSReader;
using ICSharpCode.SharpZipLib.Zip.Compression;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.PlayerData;
using Pfim;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
@ -240,6 +241,34 @@ public static class FileHelper
if (!Directory.Exists(path)) Directory.CreateDirectory(path ?? throw new ArgumentNullException(nameof(path)));
}
private static readonly Regex base64Regex = new(@"data:([^\/]+)\/([^;]+);base64,(.*)", RegexOptions.Compiled);
public static async Task<string?> ParseBase64Image(string? image)
{
if (string.IsNullOrWhiteSpace(image)) return null;
System.Text.RegularExpressions.Match match = base64Regex.Match(image);
if (!match.Success) return null;
if (match.Groups.Count != 4) return null;
byte[] data = Convert.FromBase64String(match.Groups[3].Value);
LbpFile file = new(data);
if (file.FileType is not (LbpFileType.Jpeg or LbpFileType.Png)) return null;
if (ResourceExists(file.Hash)) return file.Hash;
string assetsDirectory = ResourcePath;
string path = GetResourcePath(file.Hash);
EnsureDirectoryCreated(assetsDirectory);
await File.WriteAllBytesAsync(path, file.Data);
return file.Hash;
}
public static string[] ResourcesNotUploaded(params string[] hashes) => hashes.Where(hash => !ResourceExists(hash)).ToArray();
public static void ConvertAllTexturesToPng()
@ -284,7 +313,7 @@ public static class FileHelper
public static bool LbpFileToPNG(LbpFile file) => LbpFileToPNG(file.Data, file.Hash, file.FileType);
public static bool LbpFileToPNG(byte[] data, string hash, LbpFileType type)
private static bool LbpFileToPNG(byte[] data, string hash, LbpFileType type)
{
if (type != LbpFileType.Jpeg && type != LbpFileType.Png && type != LbpFileType.Texture) return false;
@ -342,6 +371,7 @@ public static class FileHelper
if (compressed[i] == decompressed[i])
{
writer.Write(deflatedData);
continue;
}
Inflater inflater = new();
@ -357,19 +387,25 @@ public static class FileHelper
private static bool DDSToPNG(string hash, byte[] data)
{
using MemoryStream stream = new();
DDSImage image = new(data);
Dds ddsImage = Dds.Create(data, new PfimConfig());
if(ddsImage.Compressed)
ddsImage.Decompress();
image.SaveAsPng(stream);
// ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
Image image = ddsImage.Format switch
{
ImageFormat.Rgba32 => Image.LoadPixelData<Bgra32>(ddsImage.Data, ddsImage.Width, ddsImage.Height),
_ => throw new ArgumentOutOfRangeException($"ddsImage.Format is not supported: {ddsImage.Format}")
};
Directory.CreateDirectory("png");
File.WriteAllBytes($"png/{hash}.png", stream.ToArray());
image.SaveAsPngAsync($"png/{hash}.png");
return true;
}
private static bool JPGToPNG(string hash, byte[] data)
{
using Image<Rgba32> image = Image.Load(data);
using Image image = Image.Load(data);
using MemoryStream ms = new();
image.SaveAsPng(ms);

View file

@ -1,32 +1,84 @@
using System;
using System.Collections.Generic;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.PlayerData;
namespace LBPUnion.ProjectLighthouse.Helpers;
public static class LabelHelper
{
private static readonly List<string> lbp3Labels = new()
{
"LABEL_SINGLE_PLAYER",
"LABEL_RPG",
"LABEL_TOP_DOWN",
"LABEL_CO_OP",
"LABEL_1st_Person",
"LABEL_3rd_Person",
"LABEL_Sci_Fi",
"LABEL_Social",
"LABEL_Arcade_Game",
"LABEL_Board_Game",
"LABEL_Card_Game",
"LABEL_Mini_Game",
"LABEL_Party_Game",
"LABEL_Defence",
"LABEL_Driving",
"LABEL_Hangout",
"LABEL_Hide_And_Seek",
"LABEL_Prop_Hunt",
"LABEL_Music_Gallery",
"LABEL_Costume_Gallery",
"LABEL_Sticker_Gallery",
"LABEL_Movie",
"LABEL_Pinball",
"LABEL_Technology",
"LABEL_Homage",
"LABEL_8_Bit",
"LABEL_16_Bit",
"LABEL_Seasonal",
"LABEL_Time_Trial",
"LABEL_INTERACTIVE_STREAM",
"LABEL_QUESTS",
"LABEL_SACKPOCKET",
"LABEL_SPRINGINATOR",
"LABEL_HOVERBOARD_NAME",
"LABEL_FLOATY_FLUID_NAME",
"LABEL_ODDSOCK",
"LABEL_TOGGLE",
"LABEL_SWOOP",
"LABEL_SACKBOY",
"LABEL_CREATED_CHARACTERS",
};
private static readonly Dictionary<string, string> translationTable = new()
{
{"Label_SinglePlayer", "Single Player"},
{"LABEL_Quick", "Short"},
{"LABEL_Competitive", "Versus"},
{"LABEL_Puzzle", "Puzzler"},
{"LABEL_Platform", "Platformer"},
{"LABEL_Race", "Racer"},
{"LABEL_SurvivalChallenge", "Survival Challenge"},
{"LABEL_DirectControl", "Controllinator"},
{"LABEL_GrapplingHook", "Grappling Hook"},
{"LABEL_JumpPads", "Bounce Pads"},
{"LABEL_MagicBag", "Creatinator"},
{"LABEL_LowGravity", "Low Gravity"},
{"LABEL_PowerGlove", "Grabinator"},
{"LABEL_PowerGlove", "Grabinators"},
{"LABEL_ATTRACT_GEL", "Attract-o-Gel"},
{"LABEL_ATTRACT_TWEAK", "Attract-o-Tweaker"},
{"LABEL_HEROCAPE", "Hero Cape"},
{"LABEL_MEMORISER", "Memorizer"},
{"LABEL_WALLJUMP", "Wall Jump"},
{"LABEL_SINGLE_PLAYER", "Single Player"},
{"LABEL_SurvivalChallenge", "Survival Challenge"},
{"LABEL_TOP_DOWN", "Top Down"},
{"LABEL_CO_OP", "Co-Op"},
{"LABEL_Sci_Fi", "Sci-Fi"},
{"LABEL_INTERACTIVE_STREAM", "Interactive Stream"},
{"LABEL_QUESTS", "Quests"},
{"LABEL_Mini_Game", "Mini-Game"},
{"8_Bit", "8-bit"},
{"16_Bit", "16-bit"},
{"LABEL_SACKPOCKET", "Sackpocket"},
@ -40,6 +92,17 @@ public static class LabelHelper
{"LABEL_CREATED_CHARACTERS", "Created Characters"},
};
public static bool isValidForGame(string label, GameVersion gameVersion)
{
return gameVersion switch
{
GameVersion.LittleBigPlanet1 => IsValidTag(label),
GameVersion.LittleBigPlanet2 => IsValidLabel(label) && !lbp3Labels.Contains(label),
GameVersion.LittleBigPlanet3 => IsValidLabel(label),
_ => false,
};
}
public static bool IsValidTag(string tag) => Enum.IsDefined(typeof(LevelTags), tag.Replace("TAG_", "").Replace("-", "_"));
public static bool IsValidLabel(string label) => Enum.IsDefined(typeof(LevelLabels), label);

View file

@ -30,8 +30,9 @@ public static class SanitizationHelper
}
}
public static string SanitizeString(string input)
public static string SanitizeString(string? input)
{
if (input == null) return "";
foreach ((string? key, string? value) in charsToReplace)
{

View file

@ -31,6 +31,7 @@ public enum LevelLabels
LABEL_Strategy,
LABEL_SurvivalChallenge,
LABEL_Tutorial,
LABEL_Retro,
LABEL_Collectables,
LABEL_DirectControl,
LABEL_Explosives,
@ -52,7 +53,6 @@ public enum LevelLabels
LABEL_HEROCAPE,
LABEL_MEMORISER,
LABEL_WALLJUMP,
LABEL_Retro,
LABEL_SINGLE_PLAYER,
LABEL_RPG,
LABEL_TOP_DOWN,

View file

@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace LBPUnion.ProjectLighthouse.Middlewares;
public abstract class MiddlewareDBContext
{
// this makes it consistent with typical middleware usage
[SuppressMessage("ReSharper", "InconsistentNaming")]
protected RequestDelegate next { get; }
protected MiddlewareDBContext(RequestDelegate next)
{
this.next = next;
}
[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public abstract Task InvokeAsync(HttpContext ctx, Database db);
}

View file

@ -0,0 +1,45 @@
using System;
using LBPUnion.ProjectLighthouse;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(Database))]
[Migration("20220910190711_AddUserLanguageAndTimezone")]
public partial class AddUserLanguageAndTimezone : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
table: "Users",
type: "longtext",
defaultValue: "en",
nullable: false)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "TimeZone",
table: "Users",
type: "longtext",
defaultValue: TimeZoneInfo.Local.Id,
nullable: false)
.Annotation("MySql:CharSet", "utf8mb4");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
table: "Users");
migrationBuilder.DropColumn(
name: "TimeZone",
table: "Users");
}
}
}

View file

@ -0,0 +1,33 @@
using LBPUnion.ProjectLighthouse;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(Database))]
[Migration("20220910190824_RemoveUserIsAPirate")]
public partial class RemoveUserIsAPirate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Users SET Language = \"en-PT\" WHERE isAPirate");
migrationBuilder.DropColumn(
name: "IsAPirate",
table: "Users");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAPirate",
table: "Users",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
@ -180,10 +181,9 @@ public class User
public string? ApprovedIPAddress { get; set; }
#nullable disable
/// <summary>
/// ARRR! Forces the user to see Pirate English translations on the website.
/// </summary>
public bool IsAPirate { get; set; }
public string Language { get; set; } = "en";
public string TimeZone { get; set; } = TimeZoneInfo.Local.Id;
public PrivacyType LevelVisibility { get; set; } = PrivacyType.All;

View file

@ -47,7 +47,7 @@ public class UserStatus
this.CurrentRoom = RoomHelper.FindRoomByUserId(userId);
}
private string FormatOfflineTimestamp(string language)
private string FormatOfflineTimestamp(string language, string timeZone)
{
if (this.LastLogout <= 0 && this.LastLogin <= 0)
{
@ -56,11 +56,12 @@ public class UserStatus
long timestamp = this.LastLogout;
if (timestamp <= 0) timestamp = this.LastLogin;
string formattedTime = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).ToLocalTime().ToString("M/d/yyyy h:mm:ss tt");
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
string formattedTime = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeMilliseconds(timestamp), timeZoneInfo).ToString("M/d/yyyy h:mm:ss tt");
return StatusStrings.LastOnline.Translate(language, formattedTime);
}
public string ToTranslatedString(string language)
public string ToTranslatedString(string language, string timeZone)
{
this.CurrentVersion ??= GameVersion.Unknown;
this.CurrentPlatform ??= Platform.Unknown;
@ -69,7 +70,7 @@ public class UserStatus
{
StatusType.Online => StatusStrings.CurrentlyOnline.Translate(language,
((GameVersion)this.CurrentVersion).ToPrettyString(), (Platform)this.CurrentPlatform),
StatusType.Offline => this.FormatOfflineTimestamp(language),
StatusType.Offline => this.FormatOfflineTimestamp(language, timeZone),
_ => GeneralStrings.Unknown.Translate(language),
};
}

View file

@ -10,7 +10,8 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="DDSReader" Version="1.0.8-pre" />
<PackageReference Include="Pfim" Version="0.11.1"/>
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3"/>
<PackageReference Include="Discord.Net.Webhook" Version="3.8.0" />
<PackageReference Include="InfluxDB.Client" Version="4.5.0" />
<PackageReference Include="JetBrains.Annotations" Version="2022.1.0" />

View file

@ -730,8 +730,8 @@ namespace ProjectLighthouse.Migrations
b.Property<string>("IconHash")
.HasColumnType("longtext");
b.Property<bool>("IsAPirate")
.HasColumnType("tinyint(1)");
b.Property<string>("Language")
.HasColumnType("longtext");
b.Property<long>("LastLogin")
.HasColumnType("bigint");
@ -772,6 +772,9 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("ProfileVisibility")
.HasColumnType("int");
b.Property<string>("TimeZone")
.HasColumnType("longtext");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("longtext");

View file

@ -160,3 +160,26 @@ div.cardStatsUnderTitle > span {
}
/*#endregion Comments*/
/*#region Slot labels */
.selected {
color: #fff !important;
background-color: #0e91f5 !important;
}
.selected:hover {
background-color: #0084ea !important;
}
.skew {
--scale: scale(1, 1);
--skew: rotate(0deg);
transition: transform 0.25s !important;
transform: var(--skew) var(--scale);
}
.skew:hover {
--scale: scale(1.2, 1.2);
}
/*#endregion Slot labels */