Implement in-game and website notifications (#932)

* Implement notifications logic, basic calls, and admin command

* Remove unnecessary code

* Add ability to stack notifications and return manually created XML

* Remove test that is no longer needed and is causing failures

* Apply suggestions from code review

* Merge notifications with existing announcements page

* Order notifications by descending ID instead of ascending ID

* Move notification send task to moderation options under user

Also restyles the buttons to line up next to each other like in the slot pages.

* Style/position fixes with granted slots/notification partials

* Fix incorrect form POST route

* Prevent notification text area from breaking out of container

* Actually use builder result for notification text

* Minor restructuring of the notifications page

* Add notifications for team picks, publish issues, and moderation

* Mark notifications as dismissed instead of deleting them

* Add XMLdoc to SendNotification method

* Fix incorrect URL in announcements webhook

* Remove unnecessary inline style from granted slots partial

* Apply suggestions from code review

* Apply first batch of suggestions from code review

* Apply second batch of suggestions from code review

* Change notification icon depending on if user has unread notifications

* Show unread notification icon if there is an announcement posted

* Remove "potential" wording from definitive fixes in error docs

* Remove "Error code:" from publish notifications

* Send notification if user tries to unlock a mod-locked level

* Change notification timestamp format to include date

* Add clarification to level mod-lock notification message

* Change team pick notifications to moderation notifications

Apparently the MMPick type doesn't show a visual notification.

* Apply suggestions from code review

* Add obsolete to notification types that display nothing in-game

* Remove unused imports and remove icon switch case in favor of bell icon

* Last minute fixes

* Send notification upon earth wipe and clarify moderation case notifications

* Add check for empty/too long notification text
This commit is contained in:
sudokoko 2023-10-29 16:27:41 -04:00 committed by GitHub
parent 98b370b106
commit aea66b4a74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 712 additions and 199 deletions

30
Documentation/Errors.md Normal file
View file

@ -0,0 +1,30 @@

# Errors
Here's a list of error codes, as well as their explanations and potential fixes, that are displayed within in-game and
website notifications to indicate what went wrong.
## Level Publishing
- `LH-PUB-0001`: The level failed to publish because the slot is null.
- **Note:** The slot name will not be displayed in the notification if this error occurs.
- `LH-PUB-0002`: The level failed to publish because the slot does not include a `rootLevel`.
- `LH-PUB-0003`: The level failed to publish because the resource list is null.
- `LH-PUB-0004`: The level failed to publish because the level name is too long.
- **Fix:** Shorten the level name to something below 64 characters.
- `LH-PUB-0005`: The level failed to publish because the level description is too long.
- **Fix:** Shorten the level description to something below 512 characters.
- `LH-PUB-0006`: The level failed to publish because the server is missing resources required by the level.
- **Potential Fix:** Remove any resources that are not available on the server from the level.
- `LH-PUB-0007`: The level failed to publish because the root level is not a valid level.
- `LH-PUB-0008`: The level failed to publish because the root level is not an LBP3 Adventure level.
- `LH-PUB-0009`: The level failed to publish because the the user has reached their level publishing limit.
- **Fix:** Delete some of your previously published levels to make room for new ones.
## Level Republishing
- `LH-REP-0001`: The level failed to republish because the old slot does not exist.
- `LH-REP-0002`: The level failed to republish because the original publisher is not the current publisher.
- **Potential Fix:** Copying the level to another slot on your moon typically fixes this issue.
- `LH-REP-0003`: The level could not be unlocked because it was locked by a moderator.
- **Potential Fix:** Ask a server administrator/moderator to unlock the level for you.

View file

@ -60,4 +60,7 @@
<data name="announcements" xml:space="preserve"> <data name="announcements" xml:space="preserve">
<value>Announcements</value> <value>Announcements</value>
</data> </data>
<data name="notifications" xml:space="preserve">
<value>Notifications</value>
</data>
</root> </root>

View file

@ -16,6 +16,7 @@ public static class GeneralStrings
public static readonly TranslatableString RecentActivity = create("recent_activity"); public static readonly TranslatableString RecentActivity = create("recent_activity");
public static readonly TranslatableString Soon = create("soon"); public static readonly TranslatableString Soon = create("soon");
public static readonly TranslatableString Announcements = create("announcements"); public static readonly TranslatableString Announcements = create("announcements");
public static readonly TranslatableString Notifications = create("notifications");
private static TranslatableString create(string key) => new(TranslationAreas.General, key); private static TranslatableString create(string key) => new(TranslationAreas.General, key);
} }

View file

@ -1,14 +1,16 @@
#nullable enable
using System.Text; using System.Text;
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Logging; using LBPUnion.ProjectLighthouse.Types.Logging;
using LBPUnion.ProjectLighthouse.Types.Mail; using LBPUnion.ProjectLighthouse.Types.Mail;
using LBPUnion.ProjectLighthouse.Types.Serialization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -71,7 +73,41 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
} }
[HttpGet("notification")] [HttpGet("notification")]
public IActionResult Notification() => this.Ok(); [Produces("text/xml")]
public async Task<IActionResult> Notification()
{
GameTokenEntity token = this.GetToken();
List<NotificationEntity> notifications = await this.database.Notifications
.Where(n => n.UserId == token.UserId)
.Where(n => !n.IsDismissed)
.OrderByDescending(n => n.Id)
.ToListAsync();
// We don't need to do any more work if there are no unconverted notifications to begin with.
if (notifications.Count == 0) return this.Ok();
StringBuilder builder = new();
// ReSharper disable once ForCanBeConvertedToForeach
// Suppressing this because we need to modify the list while iterating over it.
for (int i = 0; i < notifications.Count; i++)
{
NotificationEntity n = notifications[i];
builder.AppendLine(LighthouseSerializer.Serialize(this.HttpContext.RequestServices,
GameNotification.CreateFromEntity(n)));
n.IsDismissed = true;
}
await this.database.SaveChangesAsync();
return this.Ok(new LbpCustomXml
{
Content = builder.ToString(),
});
}
/// <summary> /// <summary>
/// Filters chat messages sent by a user. /// Filters chat messages sent by a user.

View file

@ -1,4 +1,3 @@
#nullable enable
using System.Diagnostics; using System.Diagnostics;
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Database;
@ -47,12 +46,16 @@ public class PublishController : ControllerBase
if (slot == null) if (slot == null)
{ {
Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish); Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
"Your level failed to publish. (LH-PUB-0001)");
return this.BadRequest(); // if the level cant be parsed then it obviously cant be uploaded return this.BadRequest(); // if the level cant be parsed then it obviously cant be uploaded
} }
if (string.IsNullOrEmpty(slot.RootLevel)) if (string.IsNullOrEmpty(slot.RootLevel))
{ {
Logger.Warn("Rejecting level upload, slot does not include rootLevel", LogArea.Publish); Logger.Warn("Rejecting level upload, slot does not include rootLevel", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0002)");
return this.BadRequest(); return this.BadRequest();
} }
@ -61,6 +64,8 @@ public class PublishController : ControllerBase
if (slot.Resources == null) if (slot.Resources == null)
{ {
Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish); Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0003)");
return this.BadRequest(); return this.BadRequest();
} }
@ -73,11 +78,15 @@ public class PublishController : ControllerBase
if (oldSlot == null) if (oldSlot == null)
{ {
Logger.Warn("Rejecting level republish, could not find old slot", LogArea.Publish); Logger.Warn("Rejecting level republish, could not find old slot", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish. (LH-REP-0001)");
return this.NotFound(); return this.NotFound();
} }
if (oldSlot.CreatorId != user.UserId) if (oldSlot.CreatorId != user.UserId)
{ {
Logger.Warn("Rejecting level republish, old slot's creator is not publishing user", LogArea.Publish); Logger.Warn("Rejecting level republish, old slot's creator is not publishing user", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish because you are not the original publisher. (LH-REP-0002)");
return this.BadRequest(); return this.BadRequest();
} }
} }
@ -111,36 +120,48 @@ public class PublishController : ControllerBase
if (slot == null) if (slot == null)
{ {
Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish); Logger.Warn("Rejecting level upload, slot is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
"Your level failed to publish. (LH-PUB-0001)");
return this.BadRequest(); return this.BadRequest();
} }
if (slot.Resources?.Length == 0) if (slot.Resources?.Length == 0)
{ {
Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish); Logger.Warn("Rejecting level upload, resource list is null", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0003)");
return this.BadRequest(); return this.BadRequest();
} }
// Yes Rider, this isn't null // Yes Rider, this isn't null
Debug.Assert(slot.Resources != null, "slot.ResourceList != null"); Debug.Assert(slot.Resources != null, "slot.ResourceList != null");
slot.Description = CensorHelper.FilterMessage(slot.Description);
if (slot.Description.Length > 512)
{
Logger.Warn($"Rejecting level upload, description too long ({slot.Description.Length} characters)", LogArea.Publish);
return this.BadRequest();
}
slot.Name = CensorHelper.FilterMessage(slot.Name); slot.Name = CensorHelper.FilterMessage(slot.Name);
if (slot.Name.Length > 64) if (slot.Name.Length > 64)
{ {
Logger.Warn($"Rejecting level upload, title too long ({slot.Name.Length} characters)", LogArea.Publish); Logger.Warn($"Rejecting level upload, title too long ({slot.Name.Length} characters)",
LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the name is too long, {slot.Name.Length} characters. (LH-PUB-0004)");
return this.BadRequest();
}
slot.Description = CensorHelper.FilterMessage(slot.Description);
if (slot.Description.Length > 512)
{
Logger.Warn($"Rejecting level upload, description too long ({slot.Description.Length} characters)",
LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the description is too long, {slot.Description.Length} characters. (LH-PUB-0005)");
return this.BadRequest(); return this.BadRequest();
} }
if (slot.Resources.Any(resource => !FileHelper.ResourceExists(resource))) if (slot.Resources.Any(resource => !FileHelper.ResourceExists(resource)))
{ {
Logger.Warn("Rejecting level upload, missing resource(s)", LogArea.Publish); Logger.Warn("Rejecting level upload, missing resource(s)", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because the server is missing resources. (LH-PUB-0006)");
return this.BadRequest(); return this.BadRequest();
} }
@ -149,6 +170,8 @@ public class PublishController : ControllerBase
if (rootLevel == null) if (rootLevel == null)
{ {
Logger.Warn("Rejecting level upload, unable to find rootLevel", LogArea.Publish); Logger.Warn("Rejecting level upload, unable to find rootLevel", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0002)");
return this.BadRequest(); return this.BadRequest();
} }
@ -157,6 +180,8 @@ public class PublishController : ControllerBase
if (rootLevel.FileType != LbpFileType.Level) if (rootLevel.FileType != LbpFileType.Level)
{ {
Logger.Warn("Rejecting level upload, rootLevel is not a level", LogArea.Publish); Logger.Warn("Rejecting level upload, rootLevel is not a level", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0007)");
return this.BadRequest(); return this.BadRequest();
} }
} }
@ -165,6 +190,8 @@ public class PublishController : ControllerBase
if (rootLevel.FileType != LbpFileType.Adventure) if (rootLevel.FileType != LbpFileType.Adventure)
{ {
Logger.Warn("Rejecting level upload, rootLevel is not a LBP 3 Adventure", LogArea.Publish); Logger.Warn("Rejecting level upload, rootLevel is not a LBP 3 Adventure", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish. (LH-PUB-0008)");
return this.BadRequest(); return this.BadRequest();
} }
} }
@ -193,6 +220,8 @@ public class PublishController : ControllerBase
if (oldSlot.CreatorId != user.UserId) if (oldSlot.CreatorId != user.UserId)
{ {
Logger.Warn("Rejecting level republish, old level not owned by current user", LogArea.Publish); Logger.Warn("Rejecting level republish, old level not owned by current user", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to republish because you are not the original publisher. (LH-REP-0002)");
return this.BadRequest(); return this.BadRequest();
} }
@ -242,8 +271,10 @@ public class PublishController : ControllerBase
oldSlot.MaximumPlayers = Math.Clamp(slot.MaximumPlayers, 1, 4); oldSlot.MaximumPlayers = Math.Clamp(slot.MaximumPlayers, 1, 4);
// Check if the level has been locked by a moderator to avoid unlocking it // Check if the level has been locked by a moderator to avoid unlocking it
if (oldSlot.LockedByModerator) if (oldSlot.LockedByModerator && !slot.InitiallyLocked)
{ {
await this.database.SendNotification(user.UserId,
$"{slot.Name} will not be unlocked because it has been locked by a moderator. (LH-REP-0003)");
oldSlot.InitiallyLocked = true; oldSlot.InitiallyLocked = true;
} }
@ -256,6 +287,8 @@ public class PublishController : ControllerBase
if (usedSlots > user.EntitledSlots) if (usedSlots > user.EntitledSlots)
{ {
Logger.Warn("Rejecting level upload, too many published slots", LogArea.Publish); Logger.Warn("Rejecting level upload, too many published slots", LogArea.Publish);
await this.database.SendNotification(user.UserId,
$"{slot.Name} failed to publish because you have reached the maximum number of levels on your earth. (LH-PUB-0009)");
return this.BadRequest(); return this.BadRequest();
} }

View file

@ -83,6 +83,9 @@ public class AdminUserController : ControllerBase
} }
} }
await this.database.SendNotification(targetedUser.UserId,
"Your earth decorations have been reset by a moderator.");
await this.database.SaveChangesAsync(); await this.database.SaveChangesAsync();
return this.Redirect($"/user/{targetedUser.UserId}"); return this.Redirect($"/user/{targetedUser.UserId}");

View file

@ -1,4 +1,3 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Helpers;
@ -34,6 +33,10 @@ public class ModerationSlotController : ControllerBase
// Send webhook with slot.Name and slot.Creator.Username // Send webhook with slot.Name and slot.Creator.Username
await WebhookHelper.SendWebhook("New Team Pick!", $"The level [**{slot.Name}**]({ServerConfiguration.Instance.ExternalUrl}/slot/{slot.SlotId}) by **{slot.Creator?.Username}** has been team picked"); await WebhookHelper.SendWebhook("New Team Pick!", $"The level [**{slot.Name}**]({ServerConfiguration.Instance.ExternalUrl}/slot/{slot.SlotId}) by **{slot.Creator?.Username}** has been team picked");
// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been team picked!");
await this.database.SaveChangesAsync(); await this.database.SaveChangesAsync();
return this.Redirect("~/slot/" + id); return this.Redirect("~/slot/" + id);
@ -50,6 +53,10 @@ public class ModerationSlotController : ControllerBase
slot.TeamPick = false; slot.TeamPick = false;
// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer team picked.");
await this.database.SaveChangesAsync(); await this.database.SaveChangesAsync();
return this.Redirect("~/slot/" + id); return this.Redirect("~/slot/" + id);
@ -64,6 +71,10 @@ public class ModerationSlotController : ControllerBase
SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id); SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id);
if (slot == null) return this.Ok(); if (slot == null) return this.Ok();
// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been deleted by a moderator.");
await this.database.RemoveSlot(slot); await this.database.RemoveSlot(slot);
return this.Redirect("~/slots/0"); return this.Redirect("~/slots/0");

View file

@ -0,0 +1,9 @@
@page "/admin/user/{id:int}/sendNotification"
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin.AdminSendNotificationPage
@{
Layout = "Layouts/BaseLayout";
Model.Title = $"Send notification to {Model.TargetedUser!.Username}";
}
@await Html.PartialAsync("Partials/AdminSendNotificationPartial", Model.TargetedUser)

View file

@ -0,0 +1,39 @@
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin;
public class AdminSendNotificationPage : BaseLayout
{
public AdminSendNotificationPage(DatabaseContext database) : base(database)
{ }
public UserEntity? TargetedUser;
public async Task<IActionResult> OnGet([FromRoute] int id)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null || !user.IsAdmin) return this.NotFound();
this.TargetedUser = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == id);
if (this.TargetedUser == null) return this.NotFound();
return this.Page();
}
public async Task<IActionResult> OnPost([FromRoute] int id, [FromForm] string notificationContent)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null || !user.IsAdmin) return this.NotFound();
this.TargetedUser = await this.Database.Users.FirstOrDefaultAsync(u => u.UserId == id);
if (this.TargetedUser == null) return this.NotFound();
await this.Database.SendNotification(this.TargetedUser.UserId, notificationContent);
return this.Redirect($"/user/{this.TargetedUser.UserId}");
}
}

View file

@ -3,7 +3,7 @@
@{ @{
Layout = "Layouts/BaseLayout"; Layout = "Layouts/BaseLayout";
Model.Title = "Set granted slots for " + Model.TargetedUser!.Username; Model.Title = $"Set granted slots for {Model.TargetedUser!.Username}";
} }
@await Html.PartialAsync("Partials/AdminSetGrantedSlotsFormPartial", Model.TargetedUser) @await Html.PartialAsync("Partials/AdminSetGrantedSlotsFormPartial", Model.TargetedUser)

View file

@ -1,83 +0,0 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
public class AnnouncePage : BaseLayout
{
public AnnouncePage(DatabaseContext database) : base(database)
{ }
public List<WebsiteAnnouncementEntity> Announcements { get; set; } = new();
public string Error { get; set; } = "";
public async Task<IActionResult> OnGet()
{
this.Announcements = await this.Database.WebsiteAnnouncements
.Include(a => a.Publisher)
.OrderByDescending(a => a.AnnouncementId)
.ToListAsync();
return this.Page();
}
public async Task<IActionResult> OnPost([FromForm] string title, [FromForm] string content)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.BadRequest();
if (!user.IsAdmin) return this.BadRequest();
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content))
{
this.Error = "Invalid form data, please ensure all fields are filled out.";
return this.Page();
}
WebsiteAnnouncementEntity announcement = new()
{
Title = title.Trim(),
Content = content.Trim(),
PublisherId = user.UserId,
};
await this.Database.WebsiteAnnouncements.AddAsync(announcement);
await this.Database.SaveChangesAsync();
if (DiscordConfiguration.Instance.DiscordIntegrationEnabled)
{
string truncatedAnnouncement = content.Length > 250
? content[..250] + $"... [read more]({ServerConfiguration.Instance.ExternalUrl}/announce)"
: content;
await WebhookHelper.SendWebhook($":mega: {title}", truncatedAnnouncement);
}
return this.RedirectToPage();
}
public async Task<IActionResult> OnPostDelete(int id)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.BadRequest();
if (!user.IsAdmin) return this.BadRequest();
WebsiteAnnouncementEntity? announcement = await this.Database.WebsiteAnnouncements
.Where(a => a.AnnouncementId == id)
.FirstOrDefaultAsync();
if (announcement == null) return this.BadRequest();
this.Database.WebsiteAnnouncements.Remove(announcement);
await this.Database.SaveChangesAsync();
return this.RedirectToPage();
}
}

View file

@ -3,24 +3,30 @@
@using LBPUnion.ProjectLighthouse.Helpers @using LBPUnion.ProjectLighthouse.Helpers
@using LBPUnion.ProjectLighthouse.Localization.StringLists @using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.Servers.Website.Types @using LBPUnion.ProjectLighthouse.Servers.Website.Types
@using Microsoft.EntityFrameworkCore
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts.BaseLayout @model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts.BaseLayout
@{ @{
if (Model.User == null) if (Model.User == null)
{ {
if (ServerConfiguration.Instance.Authentication.RegistrationEnabled) Model.NavigationItemsRight.Add(ServerConfiguration.Instance.Authentication.RegistrationEnabled
{ ? new PageNavigationItem(BaseLayoutStrings.HeaderLoginRegister, "/login", "sign in")
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderLoginRegister, "/login", "sign in")); : new PageNavigationItem(BaseLayoutStrings.HeaderLogin, "/login", "sign in"));
}
else
{
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderLogin, "/login", "sign in"));
}
} }
else else
{ {
Model.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAuthentication, "/authentication", "key")); Model.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAuthentication, "/authentication", "key"));
@if (await Model.Database.Notifications.AnyAsync(n => n.UserId == Model.User.UserId && !n.IsDismissed) ||
await Model.Database.WebsiteAnnouncements.AnyAsync())
{
Model.NavigationItemsRight.Add(new PageNavigationItem(GeneralStrings.Notifications, "/notifications", "bell"));
}
else
{
Model.NavigationItemsRight.Add(new PageNavigationItem(GeneralStrings.Notifications, "/notifications", "bell outline"));
}
@if (Model.User.IsAdmin) @if (Model.User.IsAdmin)
{ {
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAdminPanel, "/admin", "wrench", "yellow")); Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAdminPanel, "/admin", "wrench", "yellow"));
@ -103,7 +109,9 @@
@if (!Model.IsMobile) @if (!Model.IsMobile)
{ {
<span class="ui inline @navigationItem.CustomColor text">@Model.Translate(navigationItem.Name)</span> <span class="ui inline @navigationItem.CustomColor text">
@Model.Translate(navigationItem.Name)
</span>
} }
</a> </a>
} }

View file

@ -1,4 +1,3 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Localization; using LBPUnion.ProjectLighthouse.Localization;
@ -26,6 +25,7 @@ public class BaseLayout : PageModel
public string Title = string.Empty; public string Title = string.Empty;
private UserEntity? user; private UserEntity? user;
public BaseLayout(DatabaseContext database) public BaseLayout(DatabaseContext database)
{ {
this.Database = database; this.Database = database;
@ -33,8 +33,6 @@ public class BaseLayout : PageModel
this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderUsers, "/users/0", "user friends")); this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderUsers, "/users/0", "user friends"));
this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderPhotos, "/photos/0", "camera")); this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderPhotos, "/photos/0", "camera"));
this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderSlots, "/slots/0", "globe americas")); this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderSlots, "/slots/0", "globe americas"));
this.NavigationItemsRight.Add(new PageNavigationItem(GeneralStrings.Announcements, "/announce", "bullhorn"));
} }
public new UserEntity? User { public new UserEntity? User {

View file

@ -1,12 +1,14 @@
@page "/announce" @page "/notifications"
@using LBPUnion.ProjectLighthouse.Localization.StringLists @using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.Types.Entities.Notifications
@using LBPUnion.ProjectLighthouse.Types.Entities.Website @using LBPUnion.ProjectLighthouse.Types.Entities.Website
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.AnnouncePage @using LBPUnion.ProjectLighthouse.Types.Notifications
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.NotificationsPage
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{ @{
Layout = "Layouts/BaseLayout"; Layout = "Layouts/BaseLayout";
Model.Title = Model.Translate(GeneralStrings.Announcements); Model.Title = Model.Translate(GeneralStrings.Notifications);
} }
@if (Model.User != null && Model.User.IsAdmin) @if (Model.User != null && Model.User.IsAdmin)
@ -17,7 +19,7 @@
{ {
@await Html.PartialAsync("Partials/ErrorModalPartial", (Model.Translate(GeneralStrings.Error), Model.Error), ViewData) @await Html.PartialAsync("Partials/ErrorModalPartial", (Model.Translate(GeneralStrings.Error), Model.Error), ViewData)
} }
<form id="form" method="POST" class="ui form center aligned" action="/announce"> <form id="form" method="POST" class="ui form center aligned" action="/notifications">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<div class="field"> <div class="field">
<label style="text-align: left" for="title">Announcement Title</label> <label style="text-align: left" for="title">Announcement Title</label>
@ -32,7 +34,7 @@
</div> </div>
} }
@if (Model.Announcements.Any()) @if (Model.Announcements.Count > 0)
{ {
@foreach (WebsiteAnnouncementEntity announcement in Model.Announcements) @foreach (WebsiteAnnouncementEntity announcement in Model.Announcements)
{ {
@ -40,7 +42,7 @@
<div> <div>
<h3>@announcement.Title</h3> <h3>@announcement.Title</h3>
<div style="padding-bottom: 2em;"> <div style="padding-bottom: 2em;">
<span style="white-space: pre-line; ">@announcement.Content</span> <span style="white-space: pre-line">@announcement.Content</span>
</div> </div>
@if (announcement.Publisher != null) @if (announcement.Publisher != null)
{ {
@ -58,6 +60,7 @@
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button <button
asp-page-handler="delete" asp-page-handler="delete"
asp-route-type="announcement"
asp-route-id="@announcement.AnnouncementId" asp-route-id="@announcement.AnnouncementId"
onclick="return confirm('Are you sure you want to delete this announcement?')" onclick="return confirm('Are you sure you want to delete this announcement?')"
class="ui red icon button" class="ui red icon button"
@ -69,9 +72,45 @@
</div> </div>
} }
} }
@if (Model.Notifications.Count > 0)
{
@foreach (NotificationEntity notification in Model.Notifications)
{
<div class="ui blue segment" style="position: relative;">
<div>
<div>
<i class="bell icon"></i>
<span style="white-space: pre-line">@notification.Text</span>
</div>
</div>
<form method="post">
@Html.AntiForgeryToken()
<button
asp-page-handler="delete"
asp-route-type="notification"
asp-route-id="@notification.Id"
onclick="return confirm('Are you sure you want to mark this notification as read?')"
class="ui green icon button"
style="position: absolute; right: 0.5em; top: 0.5em">
<i class="check icon"></i>
</button>
</form>
</div>
}
}
else
{
@if (Model.User == null)
{
<div class="ui blue segment" style="position: relative;">
<p>You need to <a href="/login">log in</a> to view your notifications.</p>
</div>
}
else else
{ {
<div class="ui blue segment" style="position: relative;"> <div class="ui blue segment" style="position: relative;">
<p>There are no announcements to display.</p> <p>You don't have any new notifications.</p>
</div> </div>
} }
}

View file

@ -0,0 +1,112 @@
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages;
public class NotificationsPage : BaseLayout
{
public NotificationsPage(DatabaseContext database) : base(database)
{ }
public List<WebsiteAnnouncementEntity> Announcements { get; set; } = new();
public List<NotificationEntity> Notifications { get; set; } = new();
public string Error { get; set; } = "";
public async Task<IActionResult> OnGet()
{
this.Announcements = await this.Database.WebsiteAnnouncements
.Include(a => a.Publisher)
.OrderByDescending(a => a.AnnouncementId)
.ToListAsync();
if (this.User == null) return this.Page();
this.Notifications = await this.Database.Notifications
.Where(n => n.UserId == this.User.UserId)
.Where(n => !n.IsDismissed)
.OrderByDescending(n => n.Id)
.ToListAsync();
return this.Page();
}
public async Task<IActionResult> OnPost([FromForm] string title, [FromForm] string content)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.BadRequest();
if (!user.IsAdmin) return this.BadRequest();
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(content))
{
this.Error = "Invalid form data, please ensure all fields are filled out.";
return this.Page();
}
WebsiteAnnouncementEntity announcement = new()
{
Title = title.Trim(),
Content = content.Trim(),
PublisherId = user.UserId,
};
this.Database.WebsiteAnnouncements.Add(announcement);
await this.Database.SaveChangesAsync();
if (!DiscordConfiguration.Instance.DiscordIntegrationEnabled) return this.RedirectToPage();
string truncatedAnnouncement = content.Length > 250
? content[..250] + $"... [read more]({ServerConfiguration.Instance.ExternalUrl}/notifications)"
: content;
await WebhookHelper.SendWebhook($":mega: {title}", truncatedAnnouncement);
return this.RedirectToPage();
}
public async Task<IActionResult> OnPostDelete(string type, int id)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.BadRequest();
switch (type)
{
case "announcement":
{
WebsiteAnnouncementEntity? announcement = await this.Database.WebsiteAnnouncements
.Where(a => a.AnnouncementId == id)
.FirstOrDefaultAsync();
if (announcement == null || !user.IsAdmin) return this.BadRequest();
this.Database.WebsiteAnnouncements.Remove(announcement);
await this.Database.SaveChangesAsync();
break;
}
case "notification":
{
NotificationEntity? notification = await this.Database.Notifications
.Where(n => n.Id == id)
.Where(n => !n.IsDismissed)
.FirstOrDefaultAsync();
if (notification == null || notification.UserId != user.UserId) return this.BadRequest();
notification.IsDismissed = true;
await this.Database.SaveChangesAsync();
break;
}
}
return this.RedirectToPage();
}
}

View file

@ -0,0 +1,12 @@
@model LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity
<form method="post" action="/admin/user/@Model.UserId/sendNotification">
@Html.AntiForgeryToken()
<span class="ui left action input">
<button type="submit" class="ui blue button">
<i class="comment icon"></i>
<span>Send Notification</span>
</button>
<textarea name="notificationContent" placeholder="Notification Content" rows="1" cols="100" style="width: 100%;"></textarea>
</span>
</form>

View file

@ -2,11 +2,11 @@
<form method="post" action="/admin/user/@Model.UserId/setGrantedSlots"> <form method="post" action="/admin/user/@Model.UserId/setGrantedSlots">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<div class="ui left action input"> <span class="ui left action input">
<button type="submit" class="ui blue button"> <button type="submit" class="ui blue button">
<i class="pencil icon"></i> <i class="pencil icon"></i>
<span>Set Granted Slots</span> <span>Set Granted Slots</span>
</button> </button>
<input type="text" name="grantedSlotCount" placeholder="Granted Slots" value="@Model.AdminGrantedSlots"> <input type="text" name="grantedSlotCount" placeholder="Granted Slots" value="@Model.AdminGrantedSlots">
</div> </span>
</form> </form>

View file

@ -314,37 +314,31 @@ else
@if (!Model.ProfileUser.IsBanned) @if (!Model.ProfileUser.IsBanned)
{ {
<div>
<a class="ui red button" href="/moderation/newCase?type=@((int)CaseType.UserBan)&affectedId=@Model.ProfileUser.UserId"> <a class="ui red button" href="/moderation/newCase?type=@((int)CaseType.UserBan)&affectedId=@Model.ProfileUser.UserId">
<i class="ban icon"></i> <i class="ban icon"></i>
<span>Ban User</span> <span>Ban User</span>
</a> </a>
</div>
<div class="ui fitted hidden divider"></div>
} }
<div>
<a class="ui red button" href="/moderation/user/@Model.ProfileUser.UserId/wipePlanets"> <a class="ui red button" href="/moderation/user/@Model.ProfileUser.UserId/wipePlanets">
<i class="trash alternate icon"></i> <i class="trash alternate icon"></i>
<span>Wipe Earth Decorations</span> <span>Wipe Earth Decorations</span>
</a> </a>
</div>
<div class="ui fitted hidden divider"></div>
@if (!Model.CommentsDisabledByModerator) @if (!Model.CommentsDisabledByModerator)
{ {
<div>
<a class="ui yellow button" href="/moderation/newCase?type=@((int)CaseType.UserDisableComments)&affectedId=@Model.ProfileUser.UserId"> <a class="ui yellow button" href="/moderation/newCase?type=@((int)CaseType.UserDisableComments)&affectedId=@Model.ProfileUser.UserId">
<i class="lock icon"></i> <i class="lock icon"></i>
<span>Forcibly Disable Comments</span> <span>Forcibly Disable Comments</span>
</a> </a>
</div>
<div class="ui fitted hidden divider"></div>
} }
@if (Model.User.IsAdmin) @if (Model.User.IsAdmin)
{ {
<div class="ui divider"></div>
@await Html.PartialAsync("Partials/AdminSetGrantedSlotsFormPartial", Model.ProfileUser) @await Html.PartialAsync("Partials/AdminSetGrantedSlotsFormPartial", Model.ProfileUser)
<div class="ui fitted hidden divider" style="margin-bottom: 3px;"></div>
@await Html.PartialAsync("Partials/AdminSendNotificationPartial", Model.ProfileUser)
} }
</div> </div>
@if (isMobile) @if (isMobile)

View file

@ -107,19 +107,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
Assert.Equal(expected, announceMsg); Assert.Equal(expected, announceMsg);
} }
[Fact]
public async Task Notification_ShouldReturn_Empty()
{
await using DatabaseContext dbMock = await MockHelper.GetTestDatabase();
MessageController messageController = new(dbMock);
messageController.SetupTestController();
IActionResult result = messageController.Notification();
Assert.IsType<OkResult>(result);
}
[Fact] [Fact]
public async Task Filter_ShouldNotCensor_WhenCensorDisabled() public async Task Filter_ShouldNotCensor_WhenCensorDisabled()
{ {

View file

@ -63,6 +63,10 @@ public class PerformCaseActionsTask : IRepeatingTask
case CaseType.UserDisableComments: case CaseType.UserDisableComments:
{ {
user!.CommentsEnabled = true; user!.CommentsEnabled = true;
await database.SendNotification(user.UserId,
"Your profile comments have been re-enabled by a moderator.");
break; break;
} }
@ -70,11 +74,19 @@ public class PerformCaseActionsTask : IRepeatingTask
{ {
slot!.Hidden = false; slot!.Hidden = false;
slot.HiddenReason = ""; slot.HiddenReason = "";
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer hidden by a moderator.");
break; break;
} }
case CaseType.LevelDisableComments: case CaseType.LevelDisableComments:
{ {
slot!.CommentsEnabled = true; slot!.CommentsEnabled = true;
await database.SendNotification(slot.CreatorId,
$"The comments on your level, {slot.Name}, have been re-enabled by a moderator.");
break; break;
} }
case CaseType.LevelLock: case CaseType.LevelLock:
@ -82,6 +94,10 @@ public class PerformCaseActionsTask : IRepeatingTask
slot!.InitiallyLocked = false; slot!.InitiallyLocked = false;
slot.LockedByModerator = false; slot.LockedByModerator = false;
slot.LockedReason = ""; slot.LockedReason = "";
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer locked by a moderator.");
break; break;
} }
default: throw new ArgumentOutOfRangeException(); default: throw new ArgumentOutOfRangeException();
@ -113,6 +129,10 @@ public class PerformCaseActionsTask : IRepeatingTask
case CaseType.UserDisableComments: case CaseType.UserDisableComments:
{ {
user!.CommentsEnabled = false; user!.CommentsEnabled = false;
await database.SendNotification(user.UserId,
"Your profile comments have been disabled by a moderator.");
break; break;
} }
@ -121,11 +141,18 @@ public class PerformCaseActionsTask : IRepeatingTask
slot!.Hidden = true; slot!.Hidden = true;
slot.HiddenReason = @case.Reason; slot.HiddenReason = @case.Reason;
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been hidden by a moderator.");
break; break;
} }
case CaseType.LevelDisableComments: case CaseType.LevelDisableComments:
{ {
slot!.CommentsEnabled = false; slot!.CommentsEnabled = false;
await database.SendNotification(slot.CreatorId,
$"The comments on your level, {slot.Name}, have been disabled by a moderator.");
break; break;
} }
case CaseType.LevelLock: case CaseType.LevelLock:
@ -133,6 +160,10 @@ public class PerformCaseActionsTask : IRepeatingTask
slot!.InitiallyLocked = true; slot!.InitiallyLocked = true;
slot.LockedByModerator = true; slot.LockedByModerator = true;
slot.LockedReason = @case.Reason; slot.LockedReason = @case.Reason;
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been locked by a moderator.");
break; break;
} }
default: throw new ArgumentOutOfRangeException(); default: throw new ArgumentOutOfRangeException();

View file

@ -0,0 +1,7 @@
namespace LBPUnion.ProjectLighthouse.Configuration.ConfigurationCategories;
public class NotificationConfiguration
{
public bool ShowServerNameInText { get; set; } = true;
public bool ShowTimestampInText { get; set; } = false;
}

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. // 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. // If you are modifying anything here, this value MUST be incremented.
// Thanks for listening~ // Thanks for listening~
public override int ConfigVersion { get; set; } = 23; public override int ConfigVersion { get; set; } = 24;
public override string ConfigName { get; set; } = "lighthouse.yml"; public override string ConfigName { get; set; } = "lighthouse.yml";
public string WebsiteListenUrl { get; set; } = "http://localhost:10060"; public string WebsiteListenUrl { get; set; } = "http://localhost:10060";
@ -43,6 +43,7 @@ public class ServerConfiguration : ConfigurationBase<ServerConfiguration>
public RateLimitConfiguration RateLimitConfiguration { get; set; } = new(); public RateLimitConfiguration RateLimitConfiguration { get; set; } = new();
public TwoFactorConfiguration TwoFactorConfiguration { get; set; } = new(); public TwoFactorConfiguration TwoFactorConfiguration { get; set; } = new();
public RichPresenceConfiguration RichPresenceConfiguration { get; set; } = new(); public RichPresenceConfiguration RichPresenceConfiguration { get; set; } = new();
public NotificationConfiguration NotificationConfiguration { get; set; } = new();
public override ConfigurationBase<ServerConfiguration> Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize<ServerConfiguration>(text); public override ConfigurationBase<ServerConfiguration> Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize<ServerConfiguration>(text);
} }

View file

@ -11,7 +11,6 @@ namespace LBPUnion.ProjectLighthouse.Database;
public partial class DatabaseContext public partial class DatabaseContext
{ {
public async Task<string> UsernameFromGameToken(GameTokenEntity? token) public async Task<string> UsernameFromGameToken(GameTokenEntity? token)
{ {
if (token == null) return ""; if (token == null) return "";

View file

@ -0,0 +1,55 @@
using System;
using System.Text;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Notifications;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Database;
public partial class DatabaseContext
{
/// <summary>
/// Sends a notification to a user.
/// </summary>
/// <param name="userId">The user ID of the target user.</param>
/// <param name="text">The message to send.</param>
/// <param name="type">The <see cref="NotificationType"/> for the notification. Defaults to <c>ModerationNotification</c>.</param>
public async Task SendNotification
(int userId, string text, NotificationType type = NotificationType.ModerationNotification)
{
if (!await this.Users.AnyAsync(u => u.UserId == userId))
{
return;
}
if (string.IsNullOrWhiteSpace(text) || text.Length > 2048)
{
return;
}
StringBuilder builder = new(text);
// Prepend server name to notification text if enabled
if (ServerConfiguration.Instance.NotificationConfiguration.ShowServerNameInText)
{
builder.Insert(0, $"[{ServerConfiguration.Instance.Customization.ServerName}] ");
}
// Prepend timestamp to notification text if enabled
if (ServerConfiguration.Instance.NotificationConfiguration.ShowTimestampInText)
{
builder.Insert(0, $"[{DateTime.Now:g}] ");
}
NotificationEntity notification = new()
{
UserId = userId,
Type = type,
Text = builder.ToString(),
};
this.Notifications.Add(notification);
await this.SaveChangesAsync();
}
}

View file

@ -3,6 +3,7 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Interaction;
using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Maintenance; using LBPUnion.ProjectLighthouse.Types.Entities.Maintenance;
using LBPUnion.ProjectLighthouse.Types.Entities.Moderation; using LBPUnion.ProjectLighthouse.Types.Entities.Moderation;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Entities.Website; using LBPUnion.ProjectLighthouse.Types.Entities.Website;
@ -59,6 +60,10 @@ public partial class DatabaseContext : DbContext
public DbSet<GriefReportEntity> Reports { get; set; } public DbSet<GriefReportEntity> Reports { get; set; }
#endregion #endregion
#region Notifications
public DbSet<NotificationEntity> Notifications { get; set; }
#endregion
#region Misc #region Misc
public DbSet<CompletedMigrationEntity> CompletedMigrations { get; set; } public DbSet<CompletedMigrationEntity> CompletedMigrations { get; set; }
#endregion #endregion

View file

@ -0,0 +1,52 @@
using LBPUnion.ProjectLighthouse.Database;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20231027215257_AddNotificationEntity")]
public partial class AddNotificationEntity : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<int>(type: "int", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Text = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsDismissed = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
table.ForeignKey(
name: "FK_Notifications_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId",
table: "Notifications",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}

View file

@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "7.0.10") .HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64); .HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b =>
@ -598,6 +598,31 @@ namespace ProjectLighthouse.Migrations
b.ToTable("Cases"); b.ToTable("Cases");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<bool>("IsDismissed")
.HasColumnType("tinyint(1)");
b.Property<string>("Text")
.HasColumnType("longtext");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Notifications");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b =>
{ {
b.Property<int>("BlockedProfileId") b.Property<int>("BlockedProfileId")
@ -1308,6 +1333,17 @@ namespace ProjectLighthouse.Migrations
b.Navigation("Dismisser"); b.Navigation("Dismisser");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b =>
{ {
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "BlockedUser") b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "BlockedUser")

View file

@ -32,6 +32,12 @@ public class LbpOutputFormatter : TextOutputFormatter
{ {
if (context.Object is not ILbpSerializable o) return; if (context.Object is not ILbpSerializable o) return;
if (o is LbpCustomXml customXml)
{
await context.HttpContext.Response.WriteAsync(customXml.Content);
return;
}
string serialized = LighthouseSerializer.Serialize(context.HttpContext.RequestServices, o); string serialized = LighthouseSerializer.Serialize(context.HttpContext.RequestServices, o);
await context.HttpContext.Response.WriteAsync(serialized); await context.HttpContext.Response.WriteAsync(serialized);

View file

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Notifications;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
public class NotificationEntity
{
[Key]
public int Id { get; set; }
public int UserId { get; set; }
#nullable enable
[ForeignKey(nameof (UserId))]
public UserEntity? User { get; set; }
#nullable disable
public NotificationType Type { get; set; } = NotificationType.ModerationNotification;
public string Text { get; set; } = "";
public bool IsDismissed { get; set; }
}

View file

@ -19,6 +19,4 @@ public class WebsiteAnnouncementEntity
[ForeignKey(nameof(PublisherId))] [ForeignKey(nameof(PublisherId))]
public UserEntity? Publisher { get; set; } public UserEntity? Publisher { get; set; }
#nullable disable
} }

View file

@ -0,0 +1,37 @@
using System;
using System.Xml.Serialization;
namespace LBPUnion.ProjectLighthouse.Types.Notifications;
public enum NotificationType
{
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("mmPick")]
MMPick,
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("playsOnSlot")]
PlaysOnSlot,
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("top100Hottest")]
Top100Hottest,
/// <summary>
/// Displays a moderation notification upon login and in LBP Messages.
/// </summary>
[XmlEnum("moderationNotification")]
ModerationNotification,
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("commentOnSlot")]
CommentOnSlot,
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("heartsOnSlot")]
HeartsOnSlot,
[Obsolete("This notification type is ignored by the game and does nothing.")]
[XmlEnum("heartedAsAuthor")]
HeartedAsAuthor,
}

View file

@ -0,0 +1,21 @@
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Notifications;
namespace LBPUnion.ProjectLighthouse.Types.Serialization;
[XmlRoot("notification")]
public class GameNotification : ILbpSerializable
{
[XmlAttribute("type")]
public NotificationType Type { get; set; }
[XmlElement("text")]
public string Text { get; set; } = "";
public static GameNotification CreateFromEntity(NotificationEntity notification) => new()
{
Type = notification.Type,
Text = notification.Text,
};
}

View file

@ -0,0 +1,6 @@
namespace LBPUnion.ProjectLighthouse.Types.Serialization;
public class LbpCustomXml : ILbpSerializable
{
public required string Content { get; init; }
}