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

@ -3,7 +3,7 @@
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
@ -60,4 +60,7 @@
<data name="announcements" xml:space="preserve">
<value>Announcements</value>
</data>
<data name="notifications" xml:space="preserve">
<value>Notifications</value>
</data>
</root>

View file

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

View file

@ -1,14 +1,16 @@
#nullable enable
using System.Text;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
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.Token;
using LBPUnion.ProjectLighthouse.Types.Logging;
using LBPUnion.ProjectLighthouse.Types.Mail;
using LBPUnion.ProjectLighthouse.Types.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -66,12 +68,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
$"token.ExpiresAt: {token.ExpiresAt.ToString()}\n" +
"---DEBUG INFO---");
#endif
return this.Ok(announceText.ToString());
}
[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>
/// Filters chat messages sent by a user.
@ -104,7 +140,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.";
}
string username = await this.database.UsernameFromGameToken(token);
string filteredText = CensorHelper.FilterMessage(message);
if (ServerConfiguration.Instance.LogChatMessages) Logger.Info($"{username}: \"{message}\"", LogArea.Filter);

View file

@ -1,4 +1,3 @@
#nullable enable
using System.Diagnostics;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
@ -47,12 +46,16 @@ public class PublishController : ControllerBase
if (slot == null)
{
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
}
if (string.IsNullOrEmpty(slot.RootLevel))
{
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();
}
@ -61,6 +64,8 @@ public class PublishController : ControllerBase
if (slot.Resources == null)
{
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();
}
@ -73,11 +78,15 @@ public class PublishController : ControllerBase
if (oldSlot == null)
{
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();
}
if (oldSlot.CreatorId != user.UserId)
{
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();
}
}
@ -111,36 +120,48 @@ public class PublishController : ControllerBase
if (slot == null)
{
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 (slot.Resources?.Length == 0)
{
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();
}
// Yes Rider, this isn't 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);
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();
}
if (slot.Resources.Any(resource => !FileHelper.ResourceExists(resource)))
{
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();
}
@ -149,6 +170,8 @@ public class PublishController : ControllerBase
if (rootLevel == null)
{
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();
}
@ -157,6 +180,8 @@ public class PublishController : ControllerBase
if (rootLevel.FileType != LbpFileType.Level)
{
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();
}
}
@ -165,8 +190,10 @@ public class PublishController : ControllerBase
if (rootLevel.FileType != LbpFileType.Adventure)
{
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();
}
}
}
GameVersion slotVersion = FileHelper.ParseLevelVersion(rootLevel);
@ -193,6 +220,8 @@ public class PublishController : ControllerBase
if (oldSlot.CreatorId != user.UserId)
{
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();
}
@ -240,10 +269,12 @@ public class PublishController : ControllerBase
oldSlot.MinimumPlayers = Math.Clamp(slot.MinimumPlayers, 1, 4);
oldSlot.MaximumPlayers = Math.Clamp(slot.MaximumPlayers, 1, 4);
// 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;
}
@ -256,6 +287,8 @@ public class PublishController : ControllerBase
if (usedSlots > user.EntitledSlots)
{
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();
}

View file

@ -32,7 +32,7 @@ public class AdminUserController : ControllerBase
UserEntity? targetedUser = await this.database.Users.FirstOrDefaultAsync(u => u.UserId == id);
if (targetedUser == null) return this.NotFound();
string[] hashes = {
targetedUser.PlanetHashLBP2,
targetedUser.PlanetHashLBP3,
@ -44,7 +44,7 @@ public class AdminUserController : ControllerBase
{
// Don't try to remove empty hashes. That's a horrible idea.
if (string.IsNullOrWhiteSpace(hash)) continue;
// Find users with a matching hash
List<UserEntity> users = await this.database.Users
.Where(u => u.PlanetHashLBP2 == hash ||
@ -54,7 +54,7 @@ public class AdminUserController : ControllerBase
// We should match at least the targeted user...
System.Diagnostics.Debug.Assert(users.Count != 0);
// Reset each users' hash.
foreach (UserEntity userWithPlanet in users)
{
@ -63,7 +63,7 @@ public class AdminUserController : ControllerBase
userWithPlanet.PlanetHashLBPVita = "";
Logger.Success($"Deleted planets for {userWithPlanet.Username} (id:{userWithPlanet.UserId})", LogArea.Admin);
}
// And finally, attempt to remove the resource from the filesystem. We don't want that taking up space.
try
{
@ -82,7 +82,10 @@ public class AdminUserController : ControllerBase
Logger.Error($"Failed to delete planet resource {hash}\n{e}", LogArea.Admin);
}
}
await this.database.SendNotification(targetedUser.UserId,
"Your earth decorations have been reset by a moderator.");
await this.database.SaveChangesAsync();
return this.Redirect($"/user/{targetedUser.UserId}");

View file

@ -1,4 +1,3 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers;
@ -34,6 +33,10 @@ public class ModerationSlotController : ControllerBase
// 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");
// Send a notification to the creator
await this.database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been team picked!");
await this.database.SaveChangesAsync();
return this.Redirect("~/slot/" + id);
@ -50,6 +53,10 @@ public class ModerationSlotController : ControllerBase
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();
return this.Redirect("~/slot/" + id);
@ -64,6 +71,10 @@ public class ModerationSlotController : ControllerBase
SlotEntity? slot = await this.database.Slots.FirstOrDefaultAsync(s => s.SlotId == id);
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);
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";
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)

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.Localization.StringLists
@using LBPUnion.ProjectLighthouse.Servers.Website.Types
@using Microsoft.EntityFrameworkCore
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts.BaseLayout
@{
if (Model.User == null)
{
if (ServerConfiguration.Instance.Authentication.RegistrationEnabled)
{
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderLoginRegister, "/login", "sign in"));
}
else
{
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderLogin, "/login", "sign in"));
}
Model.NavigationItemsRight.Add(ServerConfiguration.Instance.Authentication.RegistrationEnabled
? new PageNavigationItem(BaseLayoutStrings.HeaderLoginRegister, "/login", "sign in")
: new PageNavigationItem(BaseLayoutStrings.HeaderLogin, "/login", "sign in"));
}
else
{
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)
{
Model.NavigationItemsRight.Add(new PageNavigationItem(BaseLayoutStrings.HeaderAdminPanel, "/admin", "wrench", "yellow"));
@ -33,8 +39,8 @@
Model.IsMobile = Model.Request.IsMobile();
string title = Model.Title == string.Empty
? ServerConfiguration.Instance.Customization.ServerName
string title = Model.Title == string.Empty
? ServerConfiguration.Instance.Customization.ServerName
: $"{Model.Title} - {ServerConfiguration.Instance.Customization.ServerName}";
}
@ -89,7 +95,7 @@
<a class="item home-logo" href="/">
<img src="~/logo-mono.png" alt="Home" class="logo-mono"/>
<img src="~/@(ServerConfiguration.Instance.WebsiteConfiguration.PrideEventEnabled && DateTime.Now.Month == 6 ? "logo-pride.png" : "logo-color.png")"
<img src="~/@(ServerConfiguration.Instance.WebsiteConfiguration.PrideEventEnabled && DateTime.Now.Month == 6 ? "logo-pride.png" : "logo-color.png")"
alt="Home"
class="logo-color"/>
</a>
@ -103,7 +109,9 @@
@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>
}
@ -122,11 +130,11 @@
}
</a>
}
@if (Model.User != null)
{
<a class="item" href="/user/@Model.User.UserId">
<img src="/gameAssets/@Model.User.WebsiteAvatarHash"
<img src="/gameAssets/@Model.User.WebsiteAvatarHash"
onerror="this.onerror='';this.src='/gameAssets/@ServerConfiguration.Instance.WebsiteConfiguration.MissingIconHash'"
alt=""
class="lighthouse-avatar"/>
@ -157,11 +165,11 @@
<i class="warning icon"></i>
<span style="font-size: 1.2rem;">@Model.Translate(BaseLayoutStrings.LicenseWarnTitle)</span>
<p>
@Html.Raw(Model.Translate(BaseLayoutStrings.LicenseWarn1,
@Html.Raw(Model.Translate(BaseLayoutStrings.LicenseWarn1,
"<a href=\"https://github.com/LBPUnion/project-lighthouse/blob/main/LICENSE\">GNU Affero General Public License v3.0</a>"))
</p>
<p>
@Html.Raw(Model.Translate(BaseLayoutStrings.LicenseWarn2,
@Html.Raw(Model.Translate(BaseLayoutStrings.LicenseWarn2,
"<code>git status</code>", "<a href=\"https://github.com/LBPUnion/project-lighthouse/issues\">", "</a>"))
</p>
<p>
@ -239,18 +247,18 @@
<script>
const collapsible = document.getElementsByClassName("collapsible");
for (let i = 0; i < collapsible.length; i++)
for (let i = 0; i < collapsible.length; i++)
{
collapsible[i].addEventListener("click", function()
collapsible[i].addEventListener("click", function()
{
this.classList.toggle("active");
const content = this.nextElementSibling;
if (content.style.display === "block")
if (content.style.display === "block")
{
content.style.display = "none";
}
else
}
else
{
content.style.display = "block";
}

View file

@ -1,4 +1,3 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Localization;
@ -26,6 +25,7 @@ public class BaseLayout : PageModel
public string Title = string.Empty;
private UserEntity? user;
public BaseLayout(DatabaseContext 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.HeaderPhotos, "/photos/0", "camera"));
this.NavigationItems.Add(new PageNavigationItem(BaseLayoutStrings.HeaderSlots, "/slots/0", "globe americas"));
this.NavigationItemsRight.Add(new PageNavigationItem(GeneralStrings.Announcements, "/announce", "bullhorn"));
}
public new UserEntity? User {
@ -55,10 +53,10 @@ public class BaseLayout : PageModel
if (this.language != null) return this.language;
if (this.User != null) return this.language = this.User.Language;
IRequestCultureFeature? requestCulture = this.Request.HttpContext.Features.Get<IRequestCultureFeature>();
if (requestCulture == null) return this.language = LocalizationManager.DefaultLang;
return this.language = requestCulture.RequestCulture.UICulture.Name;
}

View file

@ -1,12 +1,14 @@
@page "/announce"
@page "/notifications"
@using LBPUnion.ProjectLighthouse.Localization.StringLists
@using LBPUnion.ProjectLighthouse.Types.Entities.Notifications
@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
@{
Layout = "Layouts/BaseLayout";
Model.Title = Model.Translate(GeneralStrings.Announcements);
Model.Title = Model.Translate(GeneralStrings.Notifications);
}
@if (Model.User != null && Model.User.IsAdmin)
@ -17,7 +19,7 @@
{
@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()
<div class="field">
<label style="text-align: left" for="title">Announcement Title</label>
@ -32,7 +34,7 @@
</div>
}
@if (Model.Announcements.Any())
@if (Model.Announcements.Count > 0)
{
@foreach (WebsiteAnnouncementEntity announcement in Model.Announcements)
{
@ -40,7 +42,7 @@
<div>
<h3>@announcement.Title</h3>
<div style="padding-bottom: 2em;">
<span style="white-space: pre-line; ">@announcement.Content</span>
<span style="white-space: pre-line">@announcement.Content</span>
</div>
@if (announcement.Publisher != null)
{
@ -58,6 +60,7 @@
@Html.AntiForgeryToken()
<button
asp-page-handler="delete"
asp-route-type="announcement"
asp-route-id="@announcement.AnnouncementId"
onclick="return confirm('Are you sure you want to delete this announcement?')"
class="ui red icon button"
@ -69,9 +72,45 @@
</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
{
<div class="ui blue segment" style="position: relative;">
<p>There are no announcements to display.</p>
</div>
@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
{
<div class="ui blue segment" style="position: relative;">
<p>You don't have any new notifications.</p>
</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">
@Html.AntiForgeryToken()
<div class="ui left action input">
<span class="ui left action input">
<button type="submit" class="ui blue button">
<i class="pencil icon"></i>
<span>Set Granted Slots</span>
</button>
<input type="text" name="grantedSlotCount" placeholder="Granted Slots" value="@Model.AdminGrantedSlots">
</div>
</span>
</form>

View file

@ -141,7 +141,7 @@
@if (isMobile)
{
<br/>
}
}
}
</div>
@ -241,7 +241,7 @@ else
<p>
<i>The user's privacy settings prevent you from viewing this page.</i>
</p>
</div>
</div>
}
else
{
@ -256,7 +256,7 @@ else
@await slot.ToHtml(Html, ViewData, Model.User, $"~/user/{Model.ProfileUser.UserId}#levels", language, timeZone, isMobile, true)
</div>
}
</div>
</div>
}
</div>
<div class="lh-content" id="lh-playlists">
@ -314,37 +314,31 @@ else
@if (!Model.ProfileUser.IsBanned)
{
<div>
<a class="ui red button" href="/moderation/newCase?type=@((int)CaseType.UserBan)&affectedId=@Model.ProfileUser.UserId">
<i class="ban icon"></i>
<span>Ban User</span>
</a>
</div>
<div class="ui fitted hidden divider"></div>
}
<div>
<a class="ui red button" href="/moderation/user/@Model.ProfileUser.UserId/wipePlanets">
<i class="trash alternate icon"></i>
<span>Wipe Earth Decorations</span>
<a class="ui red button" href="/moderation/newCase?type=@((int)CaseType.UserBan)&affectedId=@Model.ProfileUser.UserId">
<i class="ban icon"></i>
<span>Ban User</span>
</a>
</div>
<div class="ui fitted hidden divider"></div>
}
<a class="ui red button" href="/moderation/user/@Model.ProfileUser.UserId/wipePlanets">
<i class="trash alternate icon"></i>
<span>Wipe Earth Decorations</span>
</a>
@if (!Model.CommentsDisabledByModerator)
{
<div>
<a class="ui yellow button" href="/moderation/newCase?type=@((int)CaseType.UserDisableComments)&affectedId=@Model.ProfileUser.UserId">
<i class="lock icon"></i>
<span>Forcibly Disable Comments</span>
</a>
</div>
<div class="ui fitted hidden divider"></div>
<a class="ui yellow button" href="/moderation/newCase?type=@((int)CaseType.UserDisableComments)&affectedId=@Model.ProfileUser.UserId">
<i class="lock icon"></i>
<span>Forcibly Disable Comments</span>
</a>
}
@if (Model.User.IsAdmin)
{
<div class="ui divider"></div>
@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>
@if (isMobile)
@ -375,7 +369,7 @@ function setVisible(e){
}
// unhide content
eTarget.style.display = "";
e.classList.add("active");
}
@ -394,7 +388,7 @@ if (selectedElement != null) {
while (selectedElement != null && !selectedElement.classList.contains("lh-content")){
selectedElement = selectedElement.parentElement;
}
let sidebarEle = document.querySelector("[target=" + selectedElement.id + "]")
setVisible(sidebarEle);
}

View file

@ -107,19 +107,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
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]
public async Task Filter_ShouldNotCensor_WhenCensorDisabled()
{
@ -194,7 +181,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>." + "\nuni
public async Task Filter_ShouldSendEmail_WhenMailEnabled_AndEmailNotTaken()
{
await using DatabaseContext dbMock = await MockHelper.GetTestDatabase();
Mock<IMailService> mailMock = getMailServiceMock();
const string request = "/setemail unittest@unittest.com";

View file

@ -48,7 +48,7 @@ public class PerformCaseActionsTask : IRepeatingTask
continue;
}
}
if (@case.Expired || @case.Dismissed)
{
switch (@case.Type)
@ -63,18 +63,30 @@ public class PerformCaseActionsTask : IRepeatingTask
case CaseType.UserDisableComments:
{
user!.CommentsEnabled = true;
await database.SendNotification(user.UserId,
"Your profile comments have been re-enabled by a moderator.");
break;
}
case CaseType.LevelHide:
{
slot!.Hidden = false;
slot.HiddenReason = "";
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer hidden by a moderator.");
break;
}
case CaseType.LevelDisableComments:
{
slot!.CommentsEnabled = true;
await database.SendNotification(slot.CreatorId,
$"The comments on your level, {slot.Name}, have been re-enabled by a moderator.");
break;
}
case CaseType.LevelLock:
@ -82,6 +94,10 @@ public class PerformCaseActionsTask : IRepeatingTask
slot!.InitiallyLocked = false;
slot.LockedByModerator = false;
slot.LockedReason = "";
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, is no longer locked by a moderator.");
break;
}
default: throw new ArgumentOutOfRangeException();
@ -105,7 +121,7 @@ public class PerformCaseActionsTask : IRepeatingTask
{
user!.PermissionLevel = PermissionLevel.Banned;
user.BannedReason = @case.Reason;
database.GameTokens.RemoveRange(database.GameTokens.Where(t => t.UserId == user.UserId));
database.WebTokens.RemoveRange(database.WebTokens.Where(t => t.UserId == user.UserId));
break;
@ -113,6 +129,10 @@ public class PerformCaseActionsTask : IRepeatingTask
case CaseType.UserDisableComments:
{
user!.CommentsEnabled = false;
await database.SendNotification(user.UserId,
"Your profile comments have been disabled by a moderator.");
break;
}
@ -120,12 +140,19 @@ public class PerformCaseActionsTask : IRepeatingTask
{
slot!.Hidden = true;
slot.HiddenReason = @case.Reason;
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been hidden by a moderator.");
break;
}
case CaseType.LevelDisableComments:
{
slot!.CommentsEnabled = false;
await database.SendNotification(slot.CreatorId,
$"The comments on your level, {slot.Name}, have been disabled by a moderator.");
break;
}
case CaseType.LevelLock:
@ -133,6 +160,10 @@ public class PerformCaseActionsTask : IRepeatingTask
slot!.InitiallyLocked = true;
slot.LockedByModerator = true;
slot.LockedReason = @case.Reason;
await database.SendNotification(slot.CreatorId,
$"Your level, {slot.Name}, has been locked by a moderator.");
break;
}
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.
// If you are modifying anything here, this value MUST be incremented.
// 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 string WebsiteListenUrl { get; set; } = "http://localhost:10060";
@ -43,6 +43,7 @@ public class ServerConfiguration : ConfigurationBase<ServerConfiguration>
public RateLimitConfiguration RateLimitConfiguration { get; set; } = new();
public TwoFactorConfiguration TwoFactorConfiguration { 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);
}

View file

@ -11,7 +11,6 @@ namespace LBPUnion.ProjectLighthouse.Database;
public partial class DatabaseContext
{
public async Task<string> UsernameFromGameToken(GameTokenEntity? token)
{
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.Maintenance;
using LBPUnion.ProjectLighthouse.Types.Entities.Moderation;
using LBPUnion.ProjectLighthouse.Types.Entities.Notifications;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
@ -59,10 +60,14 @@ public partial class DatabaseContext : DbContext
public DbSet<GriefReportEntity> Reports { get; set; }
#endregion
#region Notifications
public DbSet<NotificationEntity> Notifications { get; set; }
#endregion
#region Misc
public DbSet<CompletedMigrationEntity> CompletedMigrations { get; set; }
#endregion
#region Website
public DbSet<WebsiteAnnouncementEntity> WebsiteAnnouncements { get; set; }
#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
modelBuilder
.HasAnnotation("ProductVersion", "7.0.10")
.HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b =>
@ -598,6 +598,31 @@ namespace ProjectLighthouse.Migrations
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 =>
{
b.Property<int>("BlockedProfileId")
@ -1308,6 +1333,17 @@ namespace ProjectLighthouse.Migrations
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 =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "BlockedUser")

View file

@ -26,12 +26,18 @@ public class LbpOutputFormatter : TextOutputFormatter
if (isSerializable) return base.CanWriteType(type);
Logger.Warn($"Unable to serialize type '{type?.Name}' because it doesn't extend ISerializable: (fullType={type?.FullName}", LogArea.Serialization);
return false;
}
}
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
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);
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))]
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; }
}