Compare commits
4 commits
79b8c4799e
...
d518cdf739
Author | SHA1 | Date | |
---|---|---|---|
d518cdf739 | |||
27846a4fe4 | |||
f766a2054b | |||
36cb1d2043 |
27 changed files with 1568 additions and 49 deletions
|
@ -44,7 +44,7 @@ public class FediverseAuthController(
|
||||||
[ProducesResponseType<SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFediverseUrlAsync(
|
public async Task<IActionResult> GetFediverseUrlAsync(
|
||||||
[FromQuery] string instance,
|
[FromQuery] string instance,
|
||||||
[FromQuery] bool forceRefresh = false
|
[FromQuery(Name = "force-refresh")] bool forceRefresh = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
||||||
|
@ -139,7 +139,7 @@ public class FediverseAuthController(
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddFediverseAccountAsync(
|
public async Task<IActionResult> AddFediverseAccountAsync(
|
||||||
[FromQuery] string instance,
|
[FromQuery] string instance,
|
||||||
[FromQuery] bool forceRefresh = false
|
[FromQuery(Name = "force-refresh")] bool forceRefresh = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers.Moderation;
|
||||||
|
|
||||||
|
[Route("/api/v2/moderation/audit-log")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(RequireModerator = true)]
|
||||||
|
public class AuditLogController(DatabaseContext db, ModerationRendererService moderationRenderer)
|
||||||
|
: ApiControllerBase
|
||||||
|
{
|
||||||
|
public async Task<IActionResult> GetAuditLogAsync(
|
||||||
|
[FromQuery] AuditLogEntryType? type = null,
|
||||||
|
[FromQuery] int? limit = null,
|
||||||
|
[FromQuery] Snowflake? before = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
limit = limit switch
|
||||||
|
{
|
||||||
|
> 100 => 100,
|
||||||
|
< 0 => 100,
|
||||||
|
null => 100,
|
||||||
|
_ => limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
|
||||||
|
|
||||||
|
if (before != null)
|
||||||
|
query = query.Where(e => e.Id < before.Value);
|
||||||
|
if (type != null)
|
||||||
|
query = query.Where(e => e.Type == type);
|
||||||
|
|
||||||
|
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
|
||||||
|
|
||||||
|
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
|
||||||
|
}
|
||||||
|
}
|
138
Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
Normal file
138
Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using System.Net;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers.Moderation;
|
||||||
|
|
||||||
|
[Route("/api/v2/moderation")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(RequireModerator = true)]
|
||||||
|
public class ModActionsController(
|
||||||
|
DatabaseContext db,
|
||||||
|
ModerationService moderationService,
|
||||||
|
ModerationRendererService moderationRenderer
|
||||||
|
) : ApiControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("warnings/{id}")]
|
||||||
|
public async Task<IActionResult> WarnUserAsync(Snowflake id, [FromBody] WarnUserRequest req)
|
||||||
|
{
|
||||||
|
User user = await db.ResolveUserAsync(id);
|
||||||
|
if (user.Deleted)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"This user is already deleted.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidWarningTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Id == CurrentUser!.Id)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"You can't warn yourself.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidWarningTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Member? member = null;
|
||||||
|
if (req.MemberId != null)
|
||||||
|
{
|
||||||
|
member = await db.Members.FirstOrDefaultAsync(m =>
|
||||||
|
m.Id == req.MemberId && m.UserId == user.Id
|
||||||
|
);
|
||||||
|
if (member == null)
|
||||||
|
throw new ApiError.NotFound("No member with that ID found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Report? report = null;
|
||||||
|
if (req.ReportId != null)
|
||||||
|
{
|
||||||
|
report = await db.Reports.FindAsync(req.ReportId);
|
||||||
|
if (report is not { Status: ReportStatus.Open })
|
||||||
|
{
|
||||||
|
throw new ApiError.NotFound(
|
||||||
|
"No report with that ID found, or it's already closed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogEntry entry = await moderationService.ExecuteWarningAsync(
|
||||||
|
CurrentUser,
|
||||||
|
user,
|
||||||
|
member,
|
||||||
|
report,
|
||||||
|
req.Reason,
|
||||||
|
req.ClearFields
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("suspensions/{id}")]
|
||||||
|
public async Task<IActionResult> SuspendUserAsync(
|
||||||
|
Snowflake id,
|
||||||
|
[FromBody] SuspendUserRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
User user = await db.ResolveUserAsync(id);
|
||||||
|
if (user.Deleted)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"This user is already deleted.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidWarningTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Id == CurrentUser!.Id)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"You can't warn yourself.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidWarningTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Report? report = null;
|
||||||
|
if (req.ReportId != null)
|
||||||
|
{
|
||||||
|
report = await db.Reports.FindAsync(req.ReportId);
|
||||||
|
if (report is not { Status: ReportStatus.Open })
|
||||||
|
{
|
||||||
|
throw new ApiError.NotFound(
|
||||||
|
"No report with that ID found, or it's already closed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuditLogEntry entry = await moderationService.ExecuteSuspensionAsync(
|
||||||
|
CurrentUser,
|
||||||
|
user,
|
||||||
|
report,
|
||||||
|
req.Reason,
|
||||||
|
req.ClearProfile
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
237
Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
Normal file
237
Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using System.Net;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers.Moderation;
|
||||||
|
|
||||||
|
[Route("/api/v2/moderation")]
|
||||||
|
public class ReportsController(
|
||||||
|
ILogger logger,
|
||||||
|
DatabaseContext db,
|
||||||
|
IClock clock,
|
||||||
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
|
UserRendererService userRenderer,
|
||||||
|
MemberRendererService memberRenderer,
|
||||||
|
ModerationRendererService moderationRenderer,
|
||||||
|
ModerationService moderationService
|
||||||
|
) : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger = logger.ForContext<ReportsController>();
|
||||||
|
|
||||||
|
private Snowflake MaxReportId() =>
|
||||||
|
Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
|
||||||
|
|
||||||
|
[HttpPost("report-user/{id}")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
public async Task<IActionResult> ReportUserAsync(
|
||||||
|
Snowflake id,
|
||||||
|
[FromBody] CreateReportRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
User target = await db.ResolveUserAsync(id);
|
||||||
|
|
||||||
|
if (target.Id == CurrentUser!.Id)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"You can't report yourself.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidReportTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Snowflake reportCutoff = MaxReportId();
|
||||||
|
if (
|
||||||
|
await db
|
||||||
|
.Reports.Where(r =>
|
||||||
|
r.ReporterId == CurrentUser!.Id
|
||||||
|
&& r.TargetUserId == target.Id
|
||||||
|
&& r.Id > reportCutoff
|
||||||
|
)
|
||||||
|
.AnyAsync()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Debug(
|
||||||
|
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
|
||||||
|
CurrentUser!.Id,
|
||||||
|
target.Id
|
||||||
|
);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Information(
|
||||||
|
"Creating report on {TargetId} by {ReporterId}",
|
||||||
|
target.Id,
|
||||||
|
CurrentUser!.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
string snapshot = JsonConvert.SerializeObject(
|
||||||
|
await userRenderer.RenderUserAsync(target, renderMembers: false)
|
||||||
|
);
|
||||||
|
|
||||||
|
var report = new Report
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ReporterId = CurrentUser.Id,
|
||||||
|
TargetUserId = target.Id,
|
||||||
|
TargetMemberId = null,
|
||||||
|
Reason = req.Reason,
|
||||||
|
TargetType = ReportTargetType.User,
|
||||||
|
TargetSnapshot = snapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Reports.Add(report);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("report-member/{id}")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
public async Task<IActionResult> ReportMemberAsync(
|
||||||
|
Snowflake id,
|
||||||
|
[FromBody] CreateReportRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Member target = await db.ResolveMemberAsync(id);
|
||||||
|
|
||||||
|
if (target.User.Id == CurrentUser!.Id)
|
||||||
|
{
|
||||||
|
throw new ApiError(
|
||||||
|
"You can't report yourself.",
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ErrorCode.InvalidReportTarget
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Snowflake reportCutoff = MaxReportId();
|
||||||
|
if (
|
||||||
|
await db
|
||||||
|
.Reports.Where(r =>
|
||||||
|
r.ReporterId == CurrentUser!.Id
|
||||||
|
&& r.TargetUserId == target.User.Id
|
||||||
|
&& r.Id > reportCutoff
|
||||||
|
)
|
||||||
|
.AnyAsync()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Debug(
|
||||||
|
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
|
||||||
|
CurrentUser!.Id,
|
||||||
|
target.User.Id
|
||||||
|
);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Information(
|
||||||
|
"Creating report on {TargetId} (member {TargetMemberId}) by {ReporterId}",
|
||||||
|
target.User.Id,
|
||||||
|
target.Id,
|
||||||
|
CurrentUser!.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
string snapshot = JsonConvert.SerializeObject(memberRenderer.RenderMember(target));
|
||||||
|
|
||||||
|
var report = new Report
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ReporterId = CurrentUser.Id,
|
||||||
|
TargetUserId = target.User.Id,
|
||||||
|
TargetMemberId = target.Id,
|
||||||
|
Reason = req.Reason,
|
||||||
|
TargetType = ReportTargetType.Member,
|
||||||
|
TargetSnapshot = snapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Reports.Add(report);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(RequireModerator = true)]
|
||||||
|
public async Task<IActionResult> GetReportsAsync(
|
||||||
|
[FromQuery] int? limit = null,
|
||||||
|
[FromQuery] Snowflake? before = null,
|
||||||
|
[FromQuery] Snowflake? after = null,
|
||||||
|
[FromQuery(Name = "by-reporter")] Snowflake? byReporter = null,
|
||||||
|
[FromQuery(Name = "by-target")] Snowflake? byTarget = null,
|
||||||
|
[FromQuery(Name = "include-closed")] bool includeClosed = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
limit = limit switch
|
||||||
|
{
|
||||||
|
> 100 => 100,
|
||||||
|
< 0 => 100,
|
||||||
|
null => 100,
|
||||||
|
_ => limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
IQueryable<Report> query = db
|
||||||
|
.Reports.Include(r => r.Reporter)
|
||||||
|
.Include(r => r.TargetUser)
|
||||||
|
.Include(r => r.TargetMember);
|
||||||
|
|
||||||
|
if (byTarget != null && await db.Users.AnyAsync(u => u.Id == byTarget.Value))
|
||||||
|
query = query.Where(r => r.TargetUserId == byTarget.Value);
|
||||||
|
|
||||||
|
if (byReporter != null && await db.Users.AnyAsync(u => u.Id == byReporter.Value))
|
||||||
|
query = query.Where(r => r.ReporterId == byReporter.Value);
|
||||||
|
|
||||||
|
if (before != null)
|
||||||
|
query = query.Where(r => r.Id < before.Value).OrderByDescending(r => r.Id);
|
||||||
|
else if (after != null)
|
||||||
|
query = query.Where(r => r.Id > after.Value).OrderBy(r => r.Id);
|
||||||
|
else
|
||||||
|
query = query.OrderByDescending(r => r.Id);
|
||||||
|
|
||||||
|
if (!includeClosed)
|
||||||
|
query = query.Where(r => r.Status == ReportStatus.Open);
|
||||||
|
|
||||||
|
List<Report> reports = await query.Take(limit!.Value).ToListAsync();
|
||||||
|
|
||||||
|
return Ok(reports.Select(moderationRenderer.RenderReport));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("reports/{id}/ignore")]
|
||||||
|
[Limit(RequireModerator = true)]
|
||||||
|
public async Task<IActionResult> IgnoreReportAsync(
|
||||||
|
Snowflake id,
|
||||||
|
[FromBody] IgnoreReportRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Report? report = await db.Reports.FindAsync(id);
|
||||||
|
if (report == null)
|
||||||
|
throw new ApiError.NotFound("No report with that ID found.");
|
||||||
|
if (report.Status != ReportStatus.Open)
|
||||||
|
throw new ApiError.BadRequest("That report has already been handled.");
|
||||||
|
|
||||||
|
AuditLogEntry entry = await moderationService.IgnoreReportAsync(
|
||||||
|
CurrentUser!,
|
||||||
|
report,
|
||||||
|
req.Reason
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
|
||||||
|
}
|
||||||
|
}
|
52
Foxnouns.Backend/Controllers/NotificationsController.cs
Normal file
52
Foxnouns.Backend/Controllers/NotificationsController.cs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
[Route("/api/v2/notifications")]
|
||||||
|
public class NotificationsController(
|
||||||
|
DatabaseContext db,
|
||||||
|
ModerationRendererService moderationRenderer,
|
||||||
|
IClock clock
|
||||||
|
) : ApiControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(UsableBySuspendedUsers = true)]
|
||||||
|
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
|
||||||
|
{
|
||||||
|
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
|
||||||
|
if (!all)
|
||||||
|
query = query.Where(n => n.AcknowledgedAt == null);
|
||||||
|
|
||||||
|
List<Notification> notifications = await query.OrderByDescending(n => n.Id).ToListAsync();
|
||||||
|
|
||||||
|
return Ok(notifications.Select(moderationRenderer.RenderNotification));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id}/ack")]
|
||||||
|
[Authorize("user.moderation")]
|
||||||
|
[Limit(UsableBySuspendedUsers = true)]
|
||||||
|
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
|
||||||
|
{
|
||||||
|
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
|
||||||
|
n.TargetId == CurrentUser!.Id && n.Id == id
|
||||||
|
);
|
||||||
|
if (notification == null)
|
||||||
|
throw new ApiError.NotFound("Notification not found.");
|
||||||
|
|
||||||
|
if (notification.AcknowledgedAt != null)
|
||||||
|
return Ok(moderationRenderer.RenderNotification(notification));
|
||||||
|
|
||||||
|
notification.AcknowledgedAt = clock.GetCurrentInstant();
|
||||||
|
db.Update(notification);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(moderationRenderer.RenderNotification(notification));
|
||||||
|
}
|
||||||
|
}
|
|
@ -71,6 +71,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
public DbSet<UserFlag> UserFlags { get; init; } = null!;
|
public DbSet<UserFlag> UserFlags { get; init; } = null!;
|
||||||
public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
|
public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
|
||||||
|
|
||||||
|
public DbSet<Report> Reports { get; init; } = null!;
|
||||||
|
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
|
||||||
|
public DbSet<Notification> Notifications { get; init; } = null!;
|
||||||
|
|
||||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||||
{
|
{
|
||||||
// Snowflakes are stored as longs
|
// Snowflakes are stored as longs
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241217010207_AddReports")]
|
||||||
|
public partial class AddReports : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
target_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
message = table.Column<string>(type: "text", nullable: true),
|
||||||
|
localization_key = table.Column<string>(type: "text", nullable: true),
|
||||||
|
localization_params = table.Column<Dictionary<string, string>>(
|
||||||
|
type: "hstore",
|
||||||
|
nullable: false
|
||||||
|
),
|
||||||
|
acknowledged_at = table.Column<Instant>(
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true
|
||||||
|
),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_notifications", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_notifications_users_target_id",
|
||||||
|
column: x => x.target_id,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "reports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
reporter_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
target_user_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
target_member_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
reason = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
target_type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
target_snapshot = table.Column<string>(type: "text", nullable: true),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_reports", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_reports_members_target_member_id",
|
||||||
|
column: x => x.target_member_id,
|
||||||
|
principalTable: "members",
|
||||||
|
principalColumn: "id"
|
||||||
|
);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_reports_users_reporter_id",
|
||||||
|
column: x => x.reporter_id,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_reports_users_target_user_id",
|
||||||
|
column: x => x.target_user_id,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "audit_log",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
moderator_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
moderator_username = table.Column<string>(type: "text", nullable: false),
|
||||||
|
target_user_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
target_username = table.Column<string>(type: "text", nullable: true),
|
||||||
|
target_member_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
target_member_name = table.Column<string>(type: "text", nullable: true),
|
||||||
|
report_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
reason = table.Column<string>(type: "text", nullable: true),
|
||||||
|
cleared_fields = table.Column<string[]>(type: "text[]", nullable: true),
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_audit_log", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_audit_log_reports_report_id",
|
||||||
|
column: x => x.report_id,
|
||||||
|
principalTable: "reports",
|
||||||
|
principalColumn: "id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_audit_log_report_id",
|
||||||
|
table: "audit_log",
|
||||||
|
column: "report_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_notifications_target_id",
|
||||||
|
table: "notifications",
|
||||||
|
column: "target_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_reports_reporter_id",
|
||||||
|
table: "reports",
|
||||||
|
column: "reporter_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_reports_target_member_id",
|
||||||
|
table: "reports",
|
||||||
|
column: "target_member_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_reports_target_user_id",
|
||||||
|
table: "reports",
|
||||||
|
column: "target_user_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(name: "audit_log");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(name: "notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(name: "reports");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase().OldAnnotation("Npgsql:PostgresExtension:hstore", ",,");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
.HasAnnotation("ProductVersion", "9.0.0")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
|
||||||
|
@ -61,6 +62,62 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("applications", (string)null);
|
b.ToTable("applications", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("ClearedFields")
|
||||||
|
.HasColumnType("text[]")
|
||||||
|
.HasColumnName("cleared_fields");
|
||||||
|
|
||||||
|
b.Property<long>("ModeratorId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("moderator_id");
|
||||||
|
|
||||||
|
b.Property<string>("ModeratorUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("moderator_username");
|
||||||
|
|
||||||
|
b.Property<string>("Reason")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("reason");
|
||||||
|
|
||||||
|
b.Property<long?>("ReportId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("report_id");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetMemberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("target_member_id");
|
||||||
|
|
||||||
|
b.Property<string>("TargetMemberName")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("target_member_name");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetUserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("target_user_id");
|
||||||
|
|
||||||
|
b.Property<string>("TargetUsername")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("target_username");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_audit_log");
|
||||||
|
|
||||||
|
b.HasIndex("ReportId")
|
||||||
|
.HasDatabaseName("ix_audit_log_report_id");
|
||||||
|
|
||||||
|
b.ToTable("audit_log", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -270,6 +327,45 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("member_flags", (string)null);
|
b.ToTable("member_flags", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Instant?>("AcknowledgedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("acknowledged_at");
|
||||||
|
|
||||||
|
b.Property<string>("LocalizationKey")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("localization_key");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, string>>("LocalizationParams")
|
||||||
|
.HasColumnType("hstore")
|
||||||
|
.HasColumnName("localization_params");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("message");
|
||||||
|
|
||||||
|
b.Property<long>("TargetId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("target_id");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_notifications");
|
||||||
|
|
||||||
|
b.HasIndex("TargetId")
|
||||||
|
.HasDatabaseName("ix_notifications_target_id");
|
||||||
|
|
||||||
|
b.ToTable("notifications", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -302,6 +398,55 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("pride_flags", (string)null);
|
b.ToTable("pride_flags", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<int>("Reason")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("reason");
|
||||||
|
|
||||||
|
b.Property<long>("ReporterId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("reporter_id");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<long?>("TargetMemberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("target_member_id");
|
||||||
|
|
||||||
|
b.Property<string>("TargetSnapshot")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("target_snapshot");
|
||||||
|
|
||||||
|
b.Property<int>("TargetType")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("target_type");
|
||||||
|
|
||||||
|
b.Property<long>("TargetUserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("target_user_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_reports");
|
||||||
|
|
||||||
|
b.HasIndex("ReporterId")
|
||||||
|
.HasDatabaseName("ix_reports_reporter_id");
|
||||||
|
|
||||||
|
b.HasIndex("TargetMemberId")
|
||||||
|
.HasDatabaseName("ix_reports_target_member_id");
|
||||||
|
|
||||||
|
b.HasIndex("TargetUserId")
|
||||||
|
.HasDatabaseName("ix_reports_target_user_id");
|
||||||
|
|
||||||
|
b.ToTable("reports", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -522,6 +667,16 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.ToTable("user_flags", (string)null);
|
b.ToTable("user_flags", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReportId")
|
||||||
|
.HasConstraintName("fk_audit_log_reports_report_id");
|
||||||
|
|
||||||
|
b.Navigation("Report");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
|
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
|
||||||
|
@ -584,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.Navigation("PrideFlag");
|
b.Navigation("PrideFlag");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TargetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_notifications_users_target_id");
|
||||||
|
|
||||||
|
b.Navigation("Target");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
|
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
|
||||||
|
@ -594,6 +761,34 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_pride_flags_users_user_id");
|
.HasConstraintName("fk_pride_flags_users_user_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ReporterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_reports_users_reporter_id");
|
||||||
|
|
||||||
|
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TargetMemberId")
|
||||||
|
.HasConstraintName("fk_reports_members_target_member_id");
|
||||||
|
|
||||||
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TargetUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_reports_users_target_user_id");
|
||||||
|
|
||||||
|
b.Navigation("Reporter");
|
||||||
|
|
||||||
|
b.Navigation("TargetMember");
|
||||||
|
|
||||||
|
b.Navigation("TargetUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
|
||||||
|
|
43
Foxnouns.Backend/Database/Models/AuditLogEntry.cs
Normal file
43
Foxnouns.Backend/Database/Models/AuditLogEntry.cs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
|
public class AuditLogEntry : BaseModel
|
||||||
|
{
|
||||||
|
public Snowflake ModeratorId { get; init; }
|
||||||
|
public string ModeratorUsername { get; init; } = string.Empty;
|
||||||
|
public Snowflake? TargetUserId { get; init; }
|
||||||
|
public string? TargetUsername { get; init; }
|
||||||
|
public Snowflake? TargetMemberId { get; init; }
|
||||||
|
public string? TargetMemberName { get; init; }
|
||||||
|
public Snowflake? ReportId { get; init; }
|
||||||
|
public Report? Report { get; init; }
|
||||||
|
|
||||||
|
public AuditLogEntryType Type { get; init; }
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
public string[]? ClearedFields { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
public enum AuditLogEntryType
|
||||||
|
{
|
||||||
|
IgnoreReport,
|
||||||
|
WarnUser,
|
||||||
|
WarnUserAndClearProfile,
|
||||||
|
SuspendUser,
|
||||||
|
}
|
41
Foxnouns.Backend/Database/Models/Notification.cs
Normal file
41
Foxnouns.Backend/Database/Models/Notification.cs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
|
public class Notification : BaseModel
|
||||||
|
{
|
||||||
|
public Snowflake TargetId { get; init; }
|
||||||
|
public User Target { get; init; } = null!;
|
||||||
|
|
||||||
|
public NotificationType Type { get; init; }
|
||||||
|
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public string? LocalizationKey { get; init; }
|
||||||
|
public Dictionary<string, string> LocalizationParams { get; init; } = [];
|
||||||
|
|
||||||
|
public Instant? AcknowledgedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
public enum NotificationType
|
||||||
|
{
|
||||||
|
Notice,
|
||||||
|
Warning,
|
||||||
|
Suspension,
|
||||||
|
}
|
73
Foxnouns.Backend/Database/Models/Report.cs
Normal file
73
Foxnouns.Backend/Database/Models/Report.cs
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
|
public class Report : BaseModel
|
||||||
|
{
|
||||||
|
public Snowflake ReporterId { get; init; }
|
||||||
|
public User Reporter { get; init; } = null!;
|
||||||
|
public Snowflake TargetUserId { get; init; }
|
||||||
|
public User TargetUser { get; init; } = null!;
|
||||||
|
|
||||||
|
public Snowflake? TargetMemberId { get; init; }
|
||||||
|
public Member? TargetMember { get; init; }
|
||||||
|
|
||||||
|
public ReportStatus Status { get; set; }
|
||||||
|
public ReportReason Reason { get; init; }
|
||||||
|
|
||||||
|
public ReportTargetType TargetType { get; init; }
|
||||||
|
public string? TargetSnapshot { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
public enum ReportTargetType
|
||||||
|
{
|
||||||
|
User,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
public enum ReportStatus
|
||||||
|
{
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
|
public enum ReportReason
|
||||||
|
{
|
||||||
|
Totalitarianism,
|
||||||
|
HateSpeech,
|
||||||
|
Racism,
|
||||||
|
Homophobia,
|
||||||
|
Transphobia,
|
||||||
|
Queerphobia,
|
||||||
|
Exclusionism,
|
||||||
|
Sexism,
|
||||||
|
Ableism,
|
||||||
|
ChildPornography,
|
||||||
|
PedophiliaAdvocacy,
|
||||||
|
Harassment,
|
||||||
|
Impersonation,
|
||||||
|
Doxxing,
|
||||||
|
EncouragingSelfHarm,
|
||||||
|
Spam,
|
||||||
|
Trolling,
|
||||||
|
Advertisement,
|
||||||
|
CopyrightViolation,
|
||||||
|
}
|
84
Foxnouns.Backend/Dto/Moderation.cs
Normal file
84
Foxnouns.Backend/Dto/Moderation.cs
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record ReportResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
PartialUser Reporter,
|
||||||
|
PartialUser TargetUser,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
PartialMember? TargetMember,
|
||||||
|
ReportStatus Status,
|
||||||
|
ReportReason Reason,
|
||||||
|
ReportTargetType TargetType,
|
||||||
|
JObject? Snapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AuditLogResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
AuditLogEntity Moderator,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
AuditLogEntity? TargetUser,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
AuditLogEntity? TargetMember,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
|
||||||
|
AuditLogEntryType Type,
|
||||||
|
string? Reason,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
|
||||||
|
);
|
||||||
|
|
||||||
|
public record NotificationResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
NotificationType Type,
|
||||||
|
string? Message,
|
||||||
|
string? LocalizationKey,
|
||||||
|
Dictionary<string, string> LocalizationParams,
|
||||||
|
bool Acknowledged
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AuditLogEntity(Snowflake Id, string Username);
|
||||||
|
|
||||||
|
public record CreateReportRequest(ReportReason Reason);
|
||||||
|
|
||||||
|
public record IgnoreReportRequest(string? Reason = null);
|
||||||
|
|
||||||
|
public record WarnUserRequest(
|
||||||
|
string Reason,
|
||||||
|
FieldsToClear[]? ClearFields = null,
|
||||||
|
Snowflake? MemberId = null,
|
||||||
|
Snowflake? ReportId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
|
||||||
|
|
||||||
|
public enum FieldsToClear
|
||||||
|
{
|
||||||
|
DisplayName,
|
||||||
|
Avatar,
|
||||||
|
Bio,
|
||||||
|
Links,
|
||||||
|
Names,
|
||||||
|
Pronouns,
|
||||||
|
Fields,
|
||||||
|
Flags,
|
||||||
|
CustomPreferences,
|
||||||
|
}
|
|
@ -47,7 +47,9 @@ public record UserResponse(
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Suspended,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
|
||||||
);
|
);
|
||||||
|
|
||||||
public record AuthMethodResponse(
|
public record AuthMethodResponse(
|
||||||
|
|
|
@ -166,6 +166,8 @@ public enum ErrorCode
|
||||||
MemberNotFound,
|
MemberNotFound,
|
||||||
AccountAlreadyLinked,
|
AccountAlreadyLinked,
|
||||||
LastAuthMethod,
|
LastAuthMethod,
|
||||||
|
InvalidReportTarget,
|
||||||
|
InvalidWarningTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ValidationError
|
public class ValidationError
|
||||||
|
|
|
@ -113,6 +113,8 @@ public static class WebApplicationExtensions
|
||||||
.AddSingleton<EmailRateLimiter>()
|
.AddSingleton<EmailRateLimiter>()
|
||||||
.AddScoped<UserRendererService>()
|
.AddScoped<UserRendererService>()
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
|
.AddScoped<ModerationRendererService>()
|
||||||
|
.AddScoped<ModerationService>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
.AddScoped<KeyCacheService>()
|
||||||
.AddScoped<RemoteAuthService>()
|
.AddScoped<RemoteAuthService>()
|
||||||
|
@ -139,11 +141,13 @@ public static class WebApplicationExtensions
|
||||||
services
|
services
|
||||||
.AddScoped<ErrorHandlerMiddleware>()
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
.AddScoped<AuthenticationMiddleware>()
|
.AddScoped<AuthenticationMiddleware>()
|
||||||
|
.AddScoped<LimitMiddleware>()
|
||||||
.AddScoped<AuthorizationMiddleware>();
|
.AddScoped<AuthorizationMiddleware>();
|
||||||
|
|
||||||
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) =>
|
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) =>
|
||||||
app.UseMiddleware<ErrorHandlerMiddleware>()
|
app.UseMiddleware<ErrorHandlerMiddleware>()
|
||||||
.UseMiddleware<AuthenticationMiddleware>()
|
.UseMiddleware<AuthenticationMiddleware>()
|
||||||
|
.UseMiddleware<LimitMiddleware>()
|
||||||
.UseMiddleware<AuthorizationMiddleware>();
|
.UseMiddleware<AuthorizationMiddleware>();
|
||||||
|
|
||||||
public static async Task Initialize(this WebApplication app, string[] args)
|
public static async Task Initialize(this WebApplication app, string[] args)
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="1.2.55" />
|
<PackageReference Include="Scalar.AspNetCore" Version="1.2.55"/>
|
||||||
<PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/>
|
<PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/>
|
||||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||||
|
|
|
@ -22,17 +22,16 @@ public class AuthorizationMiddleware : IMiddleware
|
||||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
{
|
{
|
||||||
Endpoint? endpoint = ctx.GetEndpoint();
|
Endpoint? endpoint = ctx.GetEndpoint();
|
||||||
AuthorizeAttribute? authorizeAttribute =
|
AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||||
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
|
||||||
LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
|
|
||||||
|
|
||||||
if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0)
|
if (attribute == null || attribute.Scopes.Length == 0)
|
||||||
{
|
{
|
||||||
await next(ctx);
|
await next(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Token? token = ctx.GetToken();
|
Token? token = ctx.GetToken();
|
||||||
|
|
||||||
if (token == null)
|
if (token == null)
|
||||||
{
|
{
|
||||||
throw new ApiError.Unauthorized(
|
throw new ApiError.Unauthorized(
|
||||||
|
@ -41,40 +40,15 @@ public class AuthorizationMiddleware : IMiddleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users who got suspended by a moderator can still access *some* endpoints.
|
if (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
|
||||||
if (
|
|
||||||
token.User.Deleted
|
|
||||||
&& (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
authorizeAttribute.Scopes.Length > 0
|
|
||||||
&& authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
throw new ApiError.Forbidden(
|
throw new ApiError.Forbidden(
|
||||||
"This endpoint requires ungranted scopes.",
|
"This endpoint requires ungranted scopes.",
|
||||||
authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
attribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
||||||
ErrorCode.MissingScopes
|
ErrorCode.MissingScopes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin)
|
|
||||||
{
|
|
||||||
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
limitAttribute?.RequireModerator == true
|
|
||||||
&& token.User.Role is not (UserRole.Admin or UserRole.Moderator)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await next(ctx);
|
await next(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,11 +58,3 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute
|
||||||
{
|
{
|
||||||
public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
|
public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
|
||||||
public class LimitAttribute : Attribute
|
|
||||||
{
|
|
||||||
public bool UsableBySuspendedUsers { get; init; }
|
|
||||||
public bool RequireAdmin { get; init; }
|
|
||||||
public bool RequireModerator { get; init; }
|
|
||||||
}
|
|
||||||
|
|
68
Foxnouns.Backend/Middleware/LimitMiddleware.cs
Normal file
68
Foxnouns.Backend/Middleware/LimitMiddleware.cs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
||||||
|
public class LimitMiddleware : IMiddleware
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
|
{
|
||||||
|
Endpoint? endpoint = ctx.GetEndpoint();
|
||||||
|
LimitAttribute? attribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
|
||||||
|
|
||||||
|
Token? token = ctx.GetToken();
|
||||||
|
|
||||||
|
if (attribute == null)
|
||||||
|
{
|
||||||
|
// Check for authorize attribute
|
||||||
|
// If it exists, and the user is deleted, throw an error.
|
||||||
|
if (
|
||||||
|
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>() != null
|
||||||
|
&& token?.User.Deleted == true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token?.User.Deleted == true && !attribute.UsableBySuspendedUsers)
|
||||||
|
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
|
||||||
|
|
||||||
|
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
|
||||||
|
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
||||||
|
|
||||||
|
if (
|
||||||
|
attribute.RequireModerator
|
||||||
|
&& token?.User.Role is not (UserRole.Admin or UserRole.Moderator)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public class LimitAttribute : Attribute
|
||||||
|
{
|
||||||
|
public bool UsableBySuspendedUsers { get; init; }
|
||||||
|
public bool RequireAdmin { get; init; }
|
||||||
|
public bool RequireModerator { get; init; }
|
||||||
|
}
|
72
Foxnouns.Backend/Services/ModerationRendererService.cs
Normal file
72
Foxnouns.Backend/Services/ModerationRendererService.cs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
public class ModerationRendererService(
|
||||||
|
UserRendererService userRenderer,
|
||||||
|
MemberRendererService memberRenderer
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public ReportResponse RenderReport(Report report)
|
||||||
|
{
|
||||||
|
return new ReportResponse(
|
||||||
|
report.Id,
|
||||||
|
userRenderer.RenderPartialUser(report.Reporter),
|
||||||
|
userRenderer.RenderPartialUser(report.TargetUser),
|
||||||
|
report.TargetMemberId != null
|
||||||
|
? memberRenderer.RenderPartialMember(report.TargetMember!)
|
||||||
|
: null,
|
||||||
|
report.Status,
|
||||||
|
report.Reason,
|
||||||
|
report.TargetType,
|
||||||
|
report.TargetSnapshot != null
|
||||||
|
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
|
||||||
|
{
|
||||||
|
return new AuditLogResponse(
|
||||||
|
Id: entry.Id,
|
||||||
|
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
|
||||||
|
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
|
||||||
|
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
|
||||||
|
ReportId: entry.ReportId,
|
||||||
|
Type: entry.Type,
|
||||||
|
Reason: entry.Reason,
|
||||||
|
ClearedFields: entry.ClearedFields
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotificationResponse RenderNotification(Notification notification) =>
|
||||||
|
new(
|
||||||
|
notification.Id,
|
||||||
|
notification.Type,
|
||||||
|
notification.Message,
|
||||||
|
notification.LocalizationKey,
|
||||||
|
notification.LocalizationParams,
|
||||||
|
notification.AcknowledgedAt != null
|
||||||
|
);
|
||||||
|
|
||||||
|
private static AuditLogEntity? ToEntity(Snowflake? id, string? username) =>
|
||||||
|
id != null && username != null ? new AuditLogEntity(id.Value, username) : null;
|
||||||
|
}
|
292
Foxnouns.Backend/Services/ModerationService.cs
Normal file
292
Foxnouns.Backend/Services/ModerationService.cs
Normal file
|
@ -0,0 +1,292 @@
|
||||||
|
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
using Coravel.Queuing.Interfaces;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
using Foxnouns.Backend.Jobs;
|
||||||
|
using Humanizer;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
public class ModerationService(
|
||||||
|
ILogger logger,
|
||||||
|
DatabaseContext db,
|
||||||
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
|
IQueue queue,
|
||||||
|
IClock clock
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger = logger.ForContext<ModerationService>();
|
||||||
|
|
||||||
|
public async Task<AuditLogEntry> IgnoreReportAsync(
|
||||||
|
User moderator,
|
||||||
|
Report report,
|
||||||
|
string? reason = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Moderator {ModeratorId} is ignoring report {ReportId} on user {TargetId}",
|
||||||
|
moderator.Id,
|
||||||
|
report.Id,
|
||||||
|
report.TargetUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
var entry = new AuditLogEntry
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ModeratorId = moderator.Id,
|
||||||
|
ModeratorUsername = moderator.Username,
|
||||||
|
ReportId = report.Id,
|
||||||
|
Type = AuditLogEntryType.IgnoreReport,
|
||||||
|
Reason = reason,
|
||||||
|
};
|
||||||
|
db.AuditLog.Add(entry);
|
||||||
|
|
||||||
|
report.Status = ReportStatus.Closed;
|
||||||
|
db.Update(report);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
|
||||||
|
User moderator,
|
||||||
|
User target,
|
||||||
|
Report? report,
|
||||||
|
string reason,
|
||||||
|
bool clearProfile
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Moderator {ModeratorId} is suspending user {TargetId}",
|
||||||
|
moderator.Id,
|
||||||
|
target.Id
|
||||||
|
);
|
||||||
|
var entry = new AuditLogEntry
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ModeratorId = moderator.Id,
|
||||||
|
ModeratorUsername = moderator.Username,
|
||||||
|
TargetUserId = target.Id,
|
||||||
|
TargetUsername = target.Username,
|
||||||
|
ReportId = report?.Id,
|
||||||
|
Type = AuditLogEntryType.SuspendUser,
|
||||||
|
Reason = reason,
|
||||||
|
};
|
||||||
|
db.AuditLog.Add(entry);
|
||||||
|
|
||||||
|
db.Notifications.Add(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
TargetId = target.Id,
|
||||||
|
Type = NotificationType.Warning,
|
||||||
|
Message = null,
|
||||||
|
LocalizationKey = "notification.suspension",
|
||||||
|
LocalizationParams = { { "reason", reason } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
target.Deleted = true;
|
||||||
|
target.DeletedAt = clock.GetCurrentInstant();
|
||||||
|
target.DeletedBy = moderator.Id;
|
||||||
|
|
||||||
|
if (!clearProfile)
|
||||||
|
{
|
||||||
|
db.Update(target);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Information("Clearing profile of user {TargetId}", target.Id);
|
||||||
|
|
||||||
|
target.Username = $"deleted-user-{target.Id}";
|
||||||
|
target.DisplayName = null;
|
||||||
|
target.Bio = null;
|
||||||
|
target.MemberTitle = null;
|
||||||
|
target.Links = [];
|
||||||
|
target.Timezone = null;
|
||||||
|
target.Names = [];
|
||||||
|
target.Pronouns = [];
|
||||||
|
target.Fields = [];
|
||||||
|
target.CustomPreferences = [];
|
||||||
|
target.ProfileFlags = [];
|
||||||
|
|
||||||
|
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||||
|
new AvatarUpdatePayload(target.Id, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: also clear member profiles?
|
||||||
|
|
||||||
|
db.Update(target);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditLogEntry> ExecuteWarningAsync(
|
||||||
|
User moderator,
|
||||||
|
User targetUser,
|
||||||
|
Member? targetMember,
|
||||||
|
Report? report,
|
||||||
|
string reason,
|
||||||
|
FieldsToClear[]? fieldsToClear
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Moderator {ModeratorId} is warning user {TargetId} (member {TargetMemberId})",
|
||||||
|
moderator.Id,
|
||||||
|
targetUser.Id,
|
||||||
|
targetMember?.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
string[]? fields = fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)).ToArray();
|
||||||
|
|
||||||
|
var entry = new AuditLogEntry
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ModeratorId = moderator.Id,
|
||||||
|
ModeratorUsername = moderator.Username,
|
||||||
|
TargetUserId = targetUser.Id,
|
||||||
|
TargetUsername = targetUser.Username,
|
||||||
|
TargetMemberId = targetMember?.Id,
|
||||||
|
TargetMemberName = targetMember?.Name,
|
||||||
|
ReportId = report?.Id,
|
||||||
|
Type =
|
||||||
|
fields != null
|
||||||
|
? AuditLogEntryType.WarnUserAndClearProfile
|
||||||
|
: AuditLogEntryType.WarnUser,
|
||||||
|
Reason = reason,
|
||||||
|
ClearedFields = fields,
|
||||||
|
};
|
||||||
|
db.AuditLog.Add(entry);
|
||||||
|
|
||||||
|
db.Notifications.Add(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
TargetId = targetUser.Id,
|
||||||
|
Type = NotificationType.Warning,
|
||||||
|
Message = null,
|
||||||
|
LocalizationKey =
|
||||||
|
fieldsToClear != null
|
||||||
|
? "notification.warning-cleared-fields"
|
||||||
|
: "notification.warning",
|
||||||
|
LocalizationParams =
|
||||||
|
{
|
||||||
|
{ "reason", reason },
|
||||||
|
{
|
||||||
|
"clearedFields",
|
||||||
|
string.Join(
|
||||||
|
"\n",
|
||||||
|
fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)) ?? []
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targetMember != null && fieldsToClear != null)
|
||||||
|
{
|
||||||
|
foreach (FieldsToClear field in fieldsToClear)
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case FieldsToClear.DisplayName:
|
||||||
|
targetMember.DisplayName = null;
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Avatar:
|
||||||
|
queue.QueueInvocableWithPayload<
|
||||||
|
MemberAvatarUpdateInvocable,
|
||||||
|
AvatarUpdatePayload
|
||||||
|
>(new AvatarUpdatePayload(targetMember.Id, null));
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Bio:
|
||||||
|
targetMember.Bio = null;
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Links:
|
||||||
|
targetMember.Links = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Names:
|
||||||
|
targetMember.Names = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Pronouns:
|
||||||
|
targetMember.Pronouns = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Fields:
|
||||||
|
targetMember.Fields = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Flags:
|
||||||
|
targetMember.ProfileFlags = [];
|
||||||
|
break;
|
||||||
|
// custom preferences can't be cleared on member-scoped warnings
|
||||||
|
case FieldsToClear.CustomPreferences:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Update(targetMember);
|
||||||
|
}
|
||||||
|
else if (fieldsToClear != null)
|
||||||
|
{
|
||||||
|
foreach (FieldsToClear field in fieldsToClear)
|
||||||
|
{
|
||||||
|
switch (field)
|
||||||
|
{
|
||||||
|
case FieldsToClear.DisplayName:
|
||||||
|
targetUser.DisplayName = null;
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Avatar:
|
||||||
|
queue.QueueInvocableWithPayload<
|
||||||
|
UserAvatarUpdateInvocable,
|
||||||
|
AvatarUpdatePayload
|
||||||
|
>(new AvatarUpdatePayload(targetUser.Id, null));
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Bio:
|
||||||
|
targetUser.Bio = null;
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Links:
|
||||||
|
targetUser.Links = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Names:
|
||||||
|
targetUser.Names = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Pronouns:
|
||||||
|
targetUser.Pronouns = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Fields:
|
||||||
|
targetUser.Fields = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.Flags:
|
||||||
|
targetUser.ProfileFlags = [];
|
||||||
|
break;
|
||||||
|
case FieldsToClear.CustomPreferences:
|
||||||
|
targetUser.CustomPreferences = [];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Update(targetUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
|
@ -114,7 +114,9 @@ public class UserRendererService(
|
||||||
tokenHidden ? user.ListHidden : null,
|
tokenHidden ? user.ListHidden : null,
|
||||||
tokenHidden ? user.LastActive : null,
|
tokenHidden ? user.LastActive : null,
|
||||||
tokenHidden ? user.LastSidReroll : null,
|
tokenHidden ? user.LastSidReroll : null,
|
||||||
tokenHidden ? user.Timezone ?? "<none>" : null
|
tokenHidden ? user.Timezone ?? "<none>" : null,
|
||||||
|
tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null,
|
||||||
|
tokenHidden ? user.Deleted : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ public static class AuthUtils
|
||||||
"user.read_flags",
|
"user.read_flags",
|
||||||
"user.create_flags",
|
"user.create_flags",
|
||||||
"user.update_flags",
|
"user.update_flags",
|
||||||
|
"user.moderation",
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly string[] MemberScopes =
|
public static readonly string[] MemberScopes =
|
||||||
|
|
|
@ -26,6 +26,8 @@ export type MeUser = UserWithMembers & {
|
||||||
last_active: string;
|
last_active: string;
|
||||||
last_sid_reroll: string;
|
last_sid_reroll: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
suspended: boolean;
|
||||||
|
deleted: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserWithMembers = User & { members: PartialMember[] | null };
|
export type UserWithMembers = User & { members: PartialMember[] | null };
|
||||||
|
|
|
@ -9,17 +9,32 @@
|
||||||
NavItem,
|
NavItem,
|
||||||
} from "@sveltestrap/sveltestrap";
|
} from "@sveltestrap/sveltestrap";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import type { User, Meta } from "$api/models/index";
|
import type { Meta, MeUser } from "$api/models/index";
|
||||||
import Logo from "$components/Logo.svelte";
|
import Logo from "$components/Logo.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
type Props = { user: User | null; meta: Meta };
|
type Props = { user: MeUser | null; meta: Meta };
|
||||||
let { user, meta }: Props = $props();
|
let { user, meta }: Props = $props();
|
||||||
|
|
||||||
let isOpen = $state(true);
|
let isOpen = $state(true);
|
||||||
const toggleMenu = () => (isOpen = !isOpen);
|
const toggleMenu = () => (isOpen = !isOpen);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if user && user.deleted}
|
||||||
|
<div class="deleted-alert text-center py-3 mb-2 px-2">
|
||||||
|
{#if user.suspended}
|
||||||
|
<strong>{$t("nav.suspended-account-hint")}</strong>
|
||||||
|
<br />
|
||||||
|
<a href="/contact">{$t("nav.appeal-suspension-link")}</a>
|
||||||
|
{:else}
|
||||||
|
<strong>{$t("nav.deleted-account-hint")}</strong>
|
||||||
|
<br />
|
||||||
|
<a href="/settings/reactivate">{$t("nav.reactivate-account-link")}</a> •
|
||||||
|
<a href="/contact">{$t("nav.delete-permanently-link")}</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Navbar expand="lg" class="mb-4 mx-2">
|
<Navbar expand="lg" class="mb-4 mx-2">
|
||||||
<NavbarBrand href="/">
|
<NavbarBrand href="/">
|
||||||
<Logo />
|
<Logo />
|
||||||
|
@ -58,6 +73,11 @@
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.deleted-alert {
|
||||||
|
color: var(--bs-danger-text-emphasis);
|
||||||
|
background-color: var(--bs-danger-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
/* These exact values make it look almost identical to the SVG version, which is what we want */
|
/* These exact values make it look almost identical to the SVG version, which is what we want */
|
||||||
#beta-text {
|
#beta-text {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
|
|
|
@ -2,7 +2,12 @@
|
||||||
"hello": "Hello, {{name}}!",
|
"hello": "Hello, {{name}}!",
|
||||||
"nav": {
|
"nav": {
|
||||||
"log-in": "Log in or sign up",
|
"log-in": "Log in or sign up",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.",
|
||||||
|
"appeal-suspension-link": "I want to appeal",
|
||||||
|
"deleted-account-hint": "You have requested deletion of your account. If you want to reactivate it, click the link below.",
|
||||||
|
"reactivate-account-link": "Reactivate account",
|
||||||
|
"delete-permanently-link": "I want my account deleted permanently"
|
||||||
},
|
},
|
||||||
"avatar-tooltip": "Avatar for {{name}}",
|
"avatar-tooltip": "Avatar for {{name}}",
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|
|
@ -65,7 +65,7 @@ export const actions = {
|
||||||
try {
|
try {
|
||||||
const resp = await apiRequest<{ url: string }>(
|
const resp = await apiRequest<{ url: string }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/auth/fediverse?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
|
`/auth/fediverse?instance=${encodeURIComponent(instance)}&force-refresh=true`,
|
||||||
{ fetch, isInternal: true },
|
{ fetch, isInternal: true },
|
||||||
);
|
);
|
||||||
redirect(303, resp.url);
|
redirect(303, resp.url);
|
||||||
|
|
|
@ -24,7 +24,7 @@ export const actions = {
|
||||||
|
|
||||||
const { url } = await apiRequest<{ url: string }>(
|
const { url } = await apiRequest<{ url: string }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
|
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&force-refresh=true`,
|
||||||
{
|
{
|
||||||
isInternal: true,
|
isInternal: true,
|
||||||
fetch,
|
fetch,
|
||||||
|
|
Loading…
Reference in a new issue