Implement read-only mode (#1001)

* Implement read-only mode

* Use localized string under default language for announce text

* Redirect to user page rather than returning blank 400

* Protect call to `ParseBase64Image`

* Add protections to SlotSettingsPage and nitpick format

* Display the latest announcement (if any) on the landing page

* Fix a kokoism

Accidentally tried to use markdown within the landing page... I'm rather smart aren't I

* Prevent possible XSS

* Separate truncated announcement text and link with "..."

* Apply suggestion from code review

* Add read-only check to /postComment in slot page controller

* Fix inconsistent tabbing
This commit is contained in:
sudokoko 2024-03-29 22:51:12 -04:00 committed by GitHub
parent 975fcab100
commit 0ee8970c64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 176 additions and 18 deletions

View file

@ -87,4 +87,10 @@
<data name="license_warn_3" xml:space="preserve">
<value>If not, please publish the source code somewhere accessible to your users.</value>
</data>
<data name="read_only_warn_title" xml:space="preserve">
<value>Read-Only Mode</value>
</data>
<data name="read_only_warn" xml:space="preserve">
<value>This instance is currently in read-only mode. Level and photo uploads, comments, reviews, and certain profile changes will be restricted until read-only mode is disabled.</value>
</data>
</root>

View file

@ -23,5 +23,8 @@ public static class BaseLayoutStrings
public static readonly TranslatableString LicenseWarn2 = create("license_warn_2");
public static readonly TranslatableString LicenseWarn3 = create("license_warn_3");
public static readonly TranslatableString ReadOnlyWarnTitle = create("read_only_warn_title");
public static readonly TranslatableString ReadOnlyWarn = create("read_only_warn");
private static TranslatableString create(string key) => new(TranslationAreas.BaseLayout, key);
}

View file

@ -119,6 +119,9 @@ public class CommentController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
GameComment? comment = await this.DeserializeBody<GameComment>();
if (comment?.Message == null) return this.BadRequest();
@ -159,6 +162,9 @@ public class CommentController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
if ((slotId == 0 || SlotHelper.IsTypeInvalid(slotType)) == (username == null)) return this.BadRequest();
CommentEntity? comment = await this.database.Comments.FirstOrDefaultAsync(c => c.CommentId == commentId);

View file

@ -3,6 +3,8 @@ using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Localization;
using LBPUnion.ProjectLighthouse.Localization.StringLists;
using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
@ -59,6 +61,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
announceText.Replace("%user", username);
announceText.Replace("%id", token.UserId.ToString());
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
{
announceText.Insert(0, BaseLayoutStrings.ReadOnlyWarn.Translate(LocalizationManager.DefaultLang));
}
#if DEBUG
announceText.Append("\n\n---DEBUG INFO---\n" +
$"user.UserId: {token.UserId}\n" +

View file

@ -37,6 +37,9 @@ public class PhotosController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
int photoCount = await this.database.Photos.CountAsync(p => p.CreatorId == token.UserId);
if (photoCount >= ServerConfiguration.Instance.UserGeneratedContentLimits.PhotosQuota) return this.BadRequest();
@ -90,7 +93,7 @@ public class PhotosController : ControllerBase
case SlotType.Developer:
{
SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.Type == photoSlot.SlotType && s.InternalSlotId == photoSlot.SlotId);
if (slot != null)
if (slot != null)
photoSlot.SlotId = slot.SlotId;
else
photoSlot.SlotId = await SlotHelper.GetPlaceholderSlotId(this.database, photoSlot.SlotId, photoSlot.SlotType);

View file

@ -1,5 +1,6 @@
#nullable enable
using System.Text;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Files;
using LBPUnion.ProjectLighthouse.Logging;
@ -58,10 +59,14 @@ public class ResourcesController : ControllerBase
string fullPath = Path.GetFullPath(path);
FileHelper.EnsureDirectoryCreated(assetsDirectory);
// lbp treats code 409 as success and as an indicator that the file is already present
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
// LBP treats code 409 as success and as an indicator that the file is already present
if (FileHelper.ResourceExists(hash)) return this.Conflict();
// theoretically shouldn't be possible because of hash check but handle anyways
// Theoretically shouldn't be possible because of hash check but handle anyways
if (!fullPath.StartsWith(FileHelper.FullResourcePath)) return this.BadRequest();
Logger.Info($"Processing resource upload (hash: {hash})", LogArea.Resources);

View file

@ -43,6 +43,9 @@ public class PublishController : ControllerBase
UserEntity? user = await this.database.UserFromGameToken(token);
if (user == null) return this.Forbid();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
GameUserSlot? slot = await this.DeserializeBody<GameUserSlot>();
if (slot == null)
{
@ -116,6 +119,9 @@ public class PublishController : ControllerBase
UserEntity? user = await this.database.UserFromGameToken(token);
if (user == null) return this.Forbid();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
GameUserSlot? slot = await this.DeserializeBody<GameUserSlot>();
if (slot == null)
@ -335,6 +341,9 @@ public class PublishController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id);
if (slot == null) return this.NotFound();

View file

@ -1,4 +1,5 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
@ -92,6 +93,9 @@ public class ReviewController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
GameReview? newReview = await this.DeserializeBody<GameReview>();
if (newReview == null) return this.BadRequest();
@ -115,7 +119,7 @@ public class ReviewController : ControllerBase
}
review.Thumb = Math.Clamp(newReview.Thumb, -1, 1);
review.LabelCollection = LabelHelper.RemoveInvalidLabels(newReview.LabelCollection);
review.Text = newReview.Text;
review.Deleted = false;
review.Timestamp = TimeHelper.TimestampMillis;
@ -239,6 +243,9 @@ public class ReviewController : ControllerBase
{
GameTokenEntity token = this.GetToken();
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
int creatorId = await this.database.Slots.Where(s => s.SlotId == slotId).Select(s => s.CreatorId).FirstOrDefaultAsync();
if (creatorId == 0) return this.BadRequest();

View file

@ -1,4 +1,5 @@
using System.Text.Json;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Files;
@ -73,6 +74,9 @@ public class UserController : ControllerBase
if (update.Biography != null)
{
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
if (update.Biography.Length > 512) return this.BadRequest();
user.Biography = update.Biography;
@ -85,6 +89,9 @@ public class UserController : ControllerBase
{
if (string.IsNullOrWhiteSpace(resource)) continue;
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.BadRequest();
if (!FileHelper.ResourceExists(resource) && !resource.StartsWith('g')) return this.BadRequest();
if (!GameResourceHelper.IsValidTexture(resource)) return this.BadRequest();

View file

@ -10,7 +10,7 @@ using LBPUnion.ProjectLighthouse.Types.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
// I would like to apologize in advance for anyone dealing with this file.
// I would like to apologize in advance for anyone dealing with this file.
// Theres probably a better way to do this with delegates but I'm tired.
// TODO: Clean up this file
// - jvyden
@ -63,6 +63,9 @@ public class SlotPageController : ControllerBase
WebTokenEntity? token = this.database.WebTokenFromRequest(this.Request);
if (token == null) return this.Redirect("~/login");
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.Redirect("~/slot/" + id);
if (msg == null)
{
Logger.Error($"Refusing to post comment from {token.UserId} on level {id}, {nameof(msg)} is null", LogArea.Comments);

View file

@ -39,6 +39,9 @@ public class UserPageController : ControllerBase
WebTokenEntity? token = this.database.WebTokenFromRequest(this.Request);
if (token == null) return this.Redirect("~/login");
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode) return this.Redirect("~/user/" + id);
if (msg == null)
{
Logger.Error($"Refusing to post comment from {token.UserId} on user {id}, {nameof(msg)} is null", LogArea.Comments);

View file

@ -60,7 +60,42 @@
}
}
<br><br>
@if (Model.LatestAnnouncement != null)
{
<div class="ui blue segment" style="position: relative;">
<div>
<h3>@Model.LatestAnnouncement.Title</h3>
<div style="padding-bottom: 2em;">
@if (Model.LatestAnnouncement.Content.Length > 250)
{
<span style="white-space: pre-line">
@Model.LatestAnnouncement.Content[..250]...
<a href="@ServerConfiguration.Instance.ExternalUrl/notifications">read more</a>
</span>
}
else
{
<span style="white-space: pre-line">
@Model.LatestAnnouncement.Content
</span>
}
</div>
@if (Model.LatestAnnouncement.Publisher != null)
{
<div class="ui tiny bottom left attached label">
Posted by
<a style="color: black" href="~/user/@Model.LatestAnnouncement.Publisher.UserId">
@Model.LatestAnnouncement.Publisher.Username
</a>
</div>
}
</div>
</div>
}
else
{
<br /><br />
}
<div class="@(isMobile ? "" : "ui center aligned grid")">
<div class="eight wide column">

View file

@ -5,6 +5,7 @@ using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using LBPUnion.ProjectLighthouse.Types.Levels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -19,6 +20,8 @@ public class LandingPage : BaseLayout
public int PendingAuthAttempts;
public List<UserEntity> PlayersOnline = new();
public WebsiteAnnouncementEntity? LatestAnnouncement;
public LandingPage(DatabaseContext database) : base(database)
{ }
@ -54,6 +57,10 @@ public class LandingPage : BaseLayout
.Include(s => s.Creator)
.ToListAsync();
this.LatestAnnouncement = await this.Database.WebsiteAnnouncements.Include(a => a.Publisher)
.OrderByDescending(a => a.AnnouncementId)
.FirstOrDefaultAsync();
return this.Page();
}
}

View file

@ -178,6 +178,18 @@
</div>
</div>
}
@if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
{
<div class="ui bottom attached red message large">
<div class="ui container">
<i class="warning icon"></i>
<span style="font-size: 1.2rem;">@Model.Translate(BaseLayoutStrings.ReadOnlyWarnTitle)</span>
<p>
@Html.Raw(Model.Translate(BaseLayoutStrings.ReadOnlyWarn))
</p>
</div>
</div>
}
</header>
<div class="main">
<div class="ui container">

View file

@ -1,4 +1,5 @@
@using System.Web
@using LBPUnion.ProjectLighthouse.Configuration
@using LBPUnion.ProjectLighthouse.Database
@using LBPUnion.ProjectLighthouse.Localization
@using LBPUnion.ProjectLighthouse.Servers.Website.Extensions
@ -31,18 +32,32 @@
@if (Model.CommentsEnabled && Model.User != null)
{
<div class="ui divider"></div>
<form class="ui reply form" action="postComment" method="post">
<div class="field">
<textarea style="min-height: 70px; height: 70px; max-height:120px" maxlength="100" name="msg"></textarea>
@if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
{
<div class="ui red segment">
<p>
<i>
@ServerConfiguration.Instance.Customization.ServerName is currently in read-only mode.
You will not be able to post comments until read-only mode is disabled.
</i>
</p>
</div>
<input type="submit" class="ui blue button">
</form>
}
else
{
<form class="ui reply form" action="postComment" method="post">
<div class="field">
<textarea style="min-height: 70px; height: 70px; max-height:120px" maxlength="100" name="msg"></textarea>
</div>
<input type="submit" class="ui blue button">
</form>
}
@if (Model.Comments.Count > 0)
{
<div class="ui divider"></div>
}
}
@{
int i = 0;
foreach (KeyValuePair<CommentEntity, RatedCommentEntity?> commentAndReaction in Model.Comments)

View file

@ -4,7 +4,6 @@
@using LBPUnion.ProjectLighthouse.Helpers
@using LBPUnion.ProjectLighthouse.Types.Entities.Level
@using LBPUnion.ProjectLighthouse.Types.Serialization
@{
bool isMobile = (bool?)ViewData["IsMobile"] ?? false;
bool canDelete = (bool?)ViewData["CanDelete"] ?? false;
@ -29,6 +28,18 @@
<div class="ui divider"></div>
}
@if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
{
<div class="ui red segment">
<p>
<i>
@ServerConfiguration.Instance.Customization.ServerName is currently in read-only mode.
You will not be able to post reviews in-game until read-only mode is disabled.
</i>
</p>
</div>
}
@for(int i = 0; i < Model.Reviews.Count; i++)
{
ReviewEntity review = Model.Reviews[i];
@ -36,7 +47,7 @@
-1 => review.Reviewer?.BooHash,
0 => review.Reviewer?.MehHash,
1 => review.Reviewer?.YayHash,
_ => throw new ArgumentOutOfRangeException(),
}) ?? "";
@ -49,7 +60,7 @@
-1 => "Boo!",
0 => "Meh.",
1 => "Yay!",
_ => throw new ArgumentOutOfRangeException(),
};
@ -114,7 +125,7 @@
if (window.confirm("Are you sure you want to delete this?\nThis action cannot be undone.")){
window.location.hash = "reviews";
window.location.href = "/moderation/deleteReview/" + reviewId + "?callbackUrl=" + this.window.location;
}
}
}
</script>
</div>

View file

@ -1,4 +1,5 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Files;
using LBPUnion.ProjectLighthouse.Helpers;
@ -25,6 +26,10 @@ public class SlotSettingsPage : BaseLayout
if (!this.User.IsModerator && this.User != this.Slot.Creator) return this.Redirect("~/slot/" + slotId);
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
return this.Redirect($"~/slot/{slotId}");
string? avatarHash = await FileHelper.ParseBase64Image(avatar);
if (avatarHash != null) this.Slot.IconHash = avatarHash;
@ -46,7 +51,7 @@ public class SlotSettingsPage : BaseLayout
if (labels != null)
{
labels = LabelHelper.RemoveInvalidLabels(labels);
if (this.Slot.AuthorLabels != labels)
if (this.Slot.AuthorLabels != labels)
this.Slot.AuthorLabels = labels;
}

View file

@ -39,6 +39,10 @@ public class UserSettingsPage : BaseLayout
if (!this.User.IsModerator && this.User != this.ProfileUser) return this.Redirect("~/user/" + userId);
// Deny request if in read-only mode
if (avatar != null && ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
return this.Redirect($"~/user/{userId}");
string? avatarHash = await FileHelper.ParseBase64Image(avatar);
if (avatarHash != null) this.ProfileUser.IconHash = avatarHash;
@ -47,6 +51,10 @@ public class UserSettingsPage : BaseLayout
if (biography != null)
{
// Deny request if in read-only mode
if (ServerConfiguration.Instance.UserGeneratedContentLimits.ReadOnlyMode)
return this.Redirect($"~/user/{userId}");
biography = CensorHelper.FilterMessage(biography);
if (this.ProfileUser.Biography != biography && biography.Length <= 512)
this.ProfileUser.Biography = biography;

View file

@ -11,6 +11,12 @@ public class UserGeneratedContentLimitConfiguration
public int PhotosQuota { get; set; } = 500;
/// <summary>
/// When enabled, all UGC uploads are disabled. This includes levels, photos, reviews,
/// comments, and certain profile settings.
/// </summary>
public bool ReadOnlyMode { get; set; } = false;
public bool ProfileCommentsEnabled { get; set; } = true;
public bool LevelCommentsEnabled { get; set; } = true;

View file

@ -11,7 +11,7 @@ public class ServerConfiguration : ConfigurationBase<ServerConfiguration>
// This is so Lighthouse can properly identify outdated configurations and update them with newer settings accordingly.
// If you are modifying anything here, this value MUST be incremented.
// Thanks for listening~
public override int ConfigVersion { get; set; } = 25;
public override int ConfigVersion { get; set; } = 26;
public override string ConfigName { get; set; } = "lighthouse.yml";
public string WebsiteListenUrl { get; set; } = "http://localhost:10060";