Compare commits
	
		
			No commits in common. "8edbc8bf1daa6215305ca3ff010330c5156c37be" and "53006ea313a449c893e407f7804278143165b587" have entirely different histories.
		
	
	
		
			8edbc8bf1d
			...
			53006ea313
		
	
		
					 17 changed files with 13 additions and 399 deletions
				
			
		| 
						 | 
					@ -65,7 +65,7 @@ public class MembersController(
 | 
				
			||||||
        return Ok(memberRenderer.RenderMember(member, CurrentToken));
 | 
					        return Ok(memberRenderer.RenderMember(member, CurrentToken));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public const int MaxMemberCount = 1000;
 | 
					    public const int MaxMemberCount = 500;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    [HttpPost("/api/v2/users/@me/members")]
 | 
					    [HttpPost("/api/v2/users/@me/members")]
 | 
				
			||||||
    [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
 | 
					    [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -43,9 +43,7 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
 | 
				
			||||||
            _ => limit,
 | 
					            _ => limit,
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        IQueryable<AuditLogEntry> query = db
 | 
					        IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
 | 
				
			||||||
            .AuditLog.Include(e => e.Report)
 | 
					 | 
				
			||||||
            .OrderByDescending(e => e.Id);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (before != null)
 | 
					        if (before != null)
 | 
				
			||||||
            query = query.Where(e => e.Id < before.Value);
 | 
					            query = query.Where(e => e.Id < before.Value);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,96 +0,0 @@
 | 
				
			||||||
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/lookup")]
 | 
					 | 
				
			||||||
[Authorize("user.moderation")]
 | 
					 | 
				
			||||||
[Limit(RequireModerator = true)]
 | 
					 | 
				
			||||||
public class LookupController(
 | 
					 | 
				
			||||||
    DatabaseContext db,
 | 
					 | 
				
			||||||
    UserRendererService userRenderer,
 | 
					 | 
				
			||||||
    ModerationService moderationService,
 | 
					 | 
				
			||||||
    ModerationRendererService moderationRenderer
 | 
					 | 
				
			||||||
) : ApiControllerBase
 | 
					 | 
				
			||||||
{
 | 
					 | 
				
			||||||
    [HttpPost]
 | 
					 | 
				
			||||||
    public async Task<IActionResult> QueryUsersAsync(
 | 
					 | 
				
			||||||
        [FromBody] QueryUsersRequest req,
 | 
					 | 
				
			||||||
        CancellationToken ct = default
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        var query = db.Users.Select(u => new { u.Id, u.Username });
 | 
					 | 
				
			||||||
        query = req.Fuzzy
 | 
					 | 
				
			||||||
            ? query.Where(u => u.Username.Contains(req.Query))
 | 
					 | 
				
			||||||
            : query.Where(u => u.Username == req.Query);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var users = await query.OrderBy(u => u.Id).Take(100).ToListAsync(ct);
 | 
					 | 
				
			||||||
        return Ok(users);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [HttpGet("{id}")]
 | 
					 | 
				
			||||||
    public async Task<IActionResult> QueryUserAsync(Snowflake id, CancellationToken ct = default)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        User user = await db.ResolveUserAsync(id, ct);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
 | 
					 | 
				
			||||||
            CurrentUser!,
 | 
					 | 
				
			||||||
            user,
 | 
					 | 
				
			||||||
            ct
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        List<AuthMethod> authMethods = showSensitiveData
 | 
					 | 
				
			||||||
            ? await db
 | 
					 | 
				
			||||||
                .AuthMethods.Where(a => a.UserId == user.Id)
 | 
					 | 
				
			||||||
                .Include(a => a.FediverseApplication)
 | 
					 | 
				
			||||||
                .ToListAsync(ct)
 | 
					 | 
				
			||||||
            : [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Ok(
 | 
					 | 
				
			||||||
            new QueryUserResponse(
 | 
					 | 
				
			||||||
                User: await userRenderer.RenderUserAsync(
 | 
					 | 
				
			||||||
                    user,
 | 
					 | 
				
			||||||
                    renderMembers: false,
 | 
					 | 
				
			||||||
                    renderAuthMethods: false,
 | 
					 | 
				
			||||||
                    ct: ct
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                MemberListHidden: user.ListHidden,
 | 
					 | 
				
			||||||
                LastActive: user.LastActive,
 | 
					 | 
				
			||||||
                LastSidReroll: user.LastSidReroll,
 | 
					 | 
				
			||||||
                Suspended: user is { Deleted: true, DeletedBy: not null },
 | 
					 | 
				
			||||||
                Deleted: user.Deleted,
 | 
					 | 
				
			||||||
                ShowSensitiveData: showSensitiveData,
 | 
					 | 
				
			||||||
                AuthMethods: showSensitiveData
 | 
					 | 
				
			||||||
                    ? authMethods.Select(UserRendererService.RenderAuthMethod)
 | 
					 | 
				
			||||||
                    : null
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    [HttpPost("{id}/sensitive")]
 | 
					 | 
				
			||||||
    public async Task<IActionResult> QuerySensitiveUserDataAsync(
 | 
					 | 
				
			||||||
        Snowflake id,
 | 
					 | 
				
			||||||
        [FromBody] QuerySensitiveUserDataRequest req
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        User user = await db.ResolveUserAsync(id);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // Don't let mods accidentally spam the audit log
 | 
					 | 
				
			||||||
        bool alreadyAuthorized = await moderationService.ShowSensitiveDataAsync(CurrentUser!, user);
 | 
					 | 
				
			||||||
        if (alreadyAuthorized)
 | 
					 | 
				
			||||||
            return NoContent();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        AuditLogEntry entry = await moderationService.QuerySensitiveDataAsync(
 | 
					 | 
				
			||||||
            CurrentUser!,
 | 
					 | 
				
			||||||
            user,
 | 
					 | 
				
			||||||
            req.Reason
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return Ok(moderationRenderer.RenderAuditLogEntry(entry));
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -41,5 +41,4 @@ public enum AuditLogEntryType
 | 
				
			||||||
    WarnUser,
 | 
					    WarnUser,
 | 
				
			||||||
    WarnUserAndClearProfile,
 | 
					    WarnUserAndClearProfile,
 | 
				
			||||||
    SuspendUser,
 | 
					    SuspendUser,
 | 
				
			||||||
    QuerySensitiveUserData,
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,7 +18,6 @@ using Foxnouns.Backend.Database;
 | 
				
			||||||
using Foxnouns.Backend.Database.Models;
 | 
					using Foxnouns.Backend.Database.Models;
 | 
				
			||||||
using Newtonsoft.Json;
 | 
					using Newtonsoft.Json;
 | 
				
			||||||
using Newtonsoft.Json.Linq;
 | 
					using Newtonsoft.Json.Linq;
 | 
				
			||||||
using NodaTime;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Foxnouns.Backend.Dto;
 | 
					namespace Foxnouns.Backend.Dto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -42,23 +41,12 @@ public record AuditLogResponse(
 | 
				
			||||||
        AuditLogEntity? TargetUser,
 | 
					        AuditLogEntity? TargetUser,
 | 
				
			||||||
    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
 | 
					    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
 | 
				
			||||||
        AuditLogEntity? TargetMember,
 | 
					        AuditLogEntity? TargetMember,
 | 
				
			||||||
    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] PartialReport? Report,
 | 
					    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
 | 
				
			||||||
    AuditLogEntryType Type,
 | 
					    AuditLogEntryType Type,
 | 
				
			||||||
    string? Reason,
 | 
					    string? Reason,
 | 
				
			||||||
    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
 | 
					    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
public record PartialReport(
 | 
					 | 
				
			||||||
    Snowflake Id,
 | 
					 | 
				
			||||||
    Snowflake ReporterId,
 | 
					 | 
				
			||||||
    Snowflake TargetUserId,
 | 
					 | 
				
			||||||
    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
 | 
					 | 
				
			||||||
        Snowflake? TargetMemberId,
 | 
					 | 
				
			||||||
    ReportReason Reason,
 | 
					 | 
				
			||||||
    string? Context,
 | 
					 | 
				
			||||||
    ReportTargetType TargetType
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public record NotificationResponse(
 | 
					public record NotificationResponse(
 | 
				
			||||||
    Snowflake Id,
 | 
					    Snowflake Id,
 | 
				
			||||||
    NotificationType Type,
 | 
					    NotificationType Type,
 | 
				
			||||||
| 
						 | 
					@ -95,19 +83,3 @@ public enum FieldsToClear
 | 
				
			||||||
    Flags,
 | 
					    Flags,
 | 
				
			||||||
    CustomPreferences,
 | 
					    CustomPreferences,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
public record QueryUsersRequest(string Query, bool Fuzzy);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public record QueryUserResponse(
 | 
					 | 
				
			||||||
    UserResponse User,
 | 
					 | 
				
			||||||
    bool MemberListHidden,
 | 
					 | 
				
			||||||
    Instant LastActive,
 | 
					 | 
				
			||||||
    Instant LastSidReroll,
 | 
					 | 
				
			||||||
    bool Suspended,
 | 
					 | 
				
			||||||
    bool Deleted,
 | 
					 | 
				
			||||||
    bool ShowSensitiveData,
 | 
					 | 
				
			||||||
    [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
 | 
					 | 
				
			||||||
        IEnumerable<AuthMethodResponse>? AuthMethods
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
public record QuerySensitiveUserDataRequest(string Reason);
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,26 +46,12 @@ public class ModerationRendererService(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
 | 
					    public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        PartialReport? report = null;
 | 
					 | 
				
			||||||
        if (entry.Report != null)
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            report = new PartialReport(
 | 
					 | 
				
			||||||
                entry.Report.Id,
 | 
					 | 
				
			||||||
                entry.Report.ReporterId,
 | 
					 | 
				
			||||||
                entry.Report.TargetUserId,
 | 
					 | 
				
			||||||
                entry.Report.TargetMemberId,
 | 
					 | 
				
			||||||
                entry.Report.Reason,
 | 
					 | 
				
			||||||
                entry.Report.Context,
 | 
					 | 
				
			||||||
                entry.Report.TargetType
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return new AuditLogResponse(
 | 
					        return new AuditLogResponse(
 | 
				
			||||||
            Id: entry.Id,
 | 
					            Id: entry.Id,
 | 
				
			||||||
            Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
 | 
					            Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
 | 
				
			||||||
            TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
 | 
					            TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
 | 
				
			||||||
            TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
 | 
					            TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
 | 
				
			||||||
            Report: report,
 | 
					            ReportId: entry.ReportId,
 | 
				
			||||||
            Type: entry.Type,
 | 
					            Type: entry.Type,
 | 
				
			||||||
            Reason: entry.Reason,
 | 
					            Reason: entry.Reason,
 | 
				
			||||||
            ClearedFields: entry.ClearedFields
 | 
					            ClearedFields: entry.ClearedFields
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,7 +18,6 @@ using Foxnouns.Backend.Database.Models;
 | 
				
			||||||
using Foxnouns.Backend.Dto;
 | 
					using Foxnouns.Backend.Dto;
 | 
				
			||||||
using Foxnouns.Backend.Jobs;
 | 
					using Foxnouns.Backend.Jobs;
 | 
				
			||||||
using Humanizer;
 | 
					using Humanizer;
 | 
				
			||||||
using Microsoft.EntityFrameworkCore;
 | 
					 | 
				
			||||||
using NodaTime;
 | 
					using NodaTime;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
namespace Foxnouns.Backend.Services;
 | 
					namespace Foxnouns.Backend.Services;
 | 
				
			||||||
| 
						 | 
					@ -64,54 +63,6 @@ public class ModerationService(
 | 
				
			||||||
        return entry;
 | 
					        return entry;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public async Task<AuditLogEntry> QuerySensitiveDataAsync(
 | 
					 | 
				
			||||||
        User moderator,
 | 
					 | 
				
			||||||
        User target,
 | 
					 | 
				
			||||||
        string reason
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        _logger.Information(
 | 
					 | 
				
			||||||
            "Moderator {ModeratorId} is querying sensitive data for {TargetId}",
 | 
					 | 
				
			||||||
            moderator.Id,
 | 
					 | 
				
			||||||
            target.Id
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var entry = new AuditLogEntry
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            Id = snowflakeGenerator.GenerateSnowflake(),
 | 
					 | 
				
			||||||
            ModeratorId = moderator.Id,
 | 
					 | 
				
			||||||
            ModeratorUsername = moderator.Username,
 | 
					 | 
				
			||||||
            TargetUserId = target.Id,
 | 
					 | 
				
			||||||
            TargetUsername = target.Username,
 | 
					 | 
				
			||||||
            Type = AuditLogEntryType.QuerySensitiveUserData,
 | 
					 | 
				
			||||||
            Reason = reason,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
        db.AuditLog.Add(entry);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        await db.SaveChangesAsync();
 | 
					 | 
				
			||||||
        return entry;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<bool> ShowSensitiveDataAsync(
 | 
					 | 
				
			||||||
        User moderator,
 | 
					 | 
				
			||||||
        User target,
 | 
					 | 
				
			||||||
        CancellationToken ct = default
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        Snowflake cutoff = snowflakeGenerator.GenerateSnowflake(
 | 
					 | 
				
			||||||
            clock.GetCurrentInstant() - Duration.FromDays(1)
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return await db.AuditLog.AnyAsync(
 | 
					 | 
				
			||||||
            e =>
 | 
					 | 
				
			||||||
                e.ModeratorId == moderator.Id
 | 
					 | 
				
			||||||
                && e.TargetUserId == target.Id
 | 
					 | 
				
			||||||
                && e.Type == AuditLogEntryType.QuerySensitiveUserData
 | 
					 | 
				
			||||||
                && e.Id > cutoff,
 | 
					 | 
				
			||||||
            ct
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    public async Task<AuditLogEntry> ExecuteSuspensionAsync(
 | 
					    public async Task<AuditLogEntry> ExecuteSuspensionAsync(
 | 
				
			||||||
        User moderator,
 | 
					        User moderator,
 | 
				
			||||||
        User target,
 | 
					        User target,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
import type { Member } from "./member";
 | 
					import type { Member } from "./member";
 | 
				
			||||||
import type { AuthMethod, PartialMember, PartialUser, User } from "./user";
 | 
					import type { PartialMember, PartialUser, User } from "./user";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type CreateReportRequest = {
 | 
					export type CreateReportRequest = {
 | 
				
			||||||
	reason: ReportReason;
 | 
						reason: ReportReason;
 | 
				
			||||||
| 
						 | 
					@ -45,7 +45,7 @@ export type AuditLogEntry = {
 | 
				
			||||||
	moderator: AuditLogEntity;
 | 
						moderator: AuditLogEntity;
 | 
				
			||||||
	target_user?: AuditLogEntity;
 | 
						target_user?: AuditLogEntity;
 | 
				
			||||||
	target_member?: AuditLogEntity;
 | 
						target_member?: AuditLogEntity;
 | 
				
			||||||
	report?: PartialReport;
 | 
						report_id?: string;
 | 
				
			||||||
	type: AuditLogEntryType;
 | 
						type: AuditLogEntryType;
 | 
				
			||||||
	reason: string | null;
 | 
						reason: string | null;
 | 
				
			||||||
	cleared_fields?: string[];
 | 
						cleared_fields?: string[];
 | 
				
			||||||
| 
						 | 
					@ -58,25 +58,4 @@ export enum AuditLogEntryType {
 | 
				
			||||||
	WarnUser = "WARN_USER",
 | 
						WarnUser = "WARN_USER",
 | 
				
			||||||
	WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
 | 
						WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
 | 
				
			||||||
	SuspendUser = "SUSPEND_USER",
 | 
						SuspendUser = "SUSPEND_USER",
 | 
				
			||||||
	QuerySensitiveUserData = "QUERY_SENSITIVE_USER_DATA",
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
export type PartialReport = {
 | 
					 | 
				
			||||||
	id: string;
 | 
					 | 
				
			||||||
	reporter_id: string;
 | 
					 | 
				
			||||||
	target_user_id: string;
 | 
					 | 
				
			||||||
	target_member_id?: string;
 | 
					 | 
				
			||||||
	reason: ReportReason;
 | 
					 | 
				
			||||||
	context: string | null;
 | 
					 | 
				
			||||||
	target_type: "USER" | "MEMBER";
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type QueriedUser = {
 | 
					 | 
				
			||||||
	user: User;
 | 
					 | 
				
			||||||
	member_list_hidden: boolean;
 | 
					 | 
				
			||||||
	last_active: string;
 | 
					 | 
				
			||||||
	last_sid_reroll: string;
 | 
					 | 
				
			||||||
	suspended: boolean;
 | 
					 | 
				
			||||||
	deleted: boolean;
 | 
					 | 
				
			||||||
	auth_methods?: AuthMethod[];
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,6 +12,10 @@
 | 
				
			||||||
	let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
 | 
						let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<svelte:head>
 | 
				
			||||||
 | 
						<title>Audit log</title>
 | 
				
			||||||
 | 
					</svelte:head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="card my-1 p-2">
 | 
					<div class="card my-1 p-2">
 | 
				
			||||||
	<h6 class="d-flex">
 | 
						<h6 class="d-flex">
 | 
				
			||||||
		<span class="flex-grow-1">
 | 
							<span class="flex-grow-1">
 | 
				
			||||||
| 
						 | 
					@ -22,8 +26,6 @@
 | 
				
			||||||
				warned
 | 
									warned
 | 
				
			||||||
			{:else if entry.type === "SUSPEND_USER"}
 | 
								{:else if entry.type === "SUSPEND_USER"}
 | 
				
			||||||
				suspended
 | 
									suspended
 | 
				
			||||||
			{:else if entry.type === "QUERY_SENSITIVE_USER_DATA"}
 | 
					 | 
				
			||||||
				looked up sensitive data of
 | 
					 | 
				
			||||||
			{:else}
 | 
								{:else}
 | 
				
			||||||
				(unknown action <code>{entry.type}</code>)
 | 
									(unknown action <code>{entry.type}</code>)
 | 
				
			||||||
			{/if}
 | 
								{/if}
 | 
				
			||||||
| 
						 | 
					@ -37,31 +39,12 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		<small class="text-secondary">{date}</small>
 | 
							<small class="text-secondary">{date}</small>
 | 
				
			||||||
	</h6>
 | 
						</h6>
 | 
				
			||||||
 | 
					 | 
				
			||||||
	{#if entry.type === "IGNORE_REPORT"}
 | 
					 | 
				
			||||||
		{#if entry.report}
 | 
					 | 
				
			||||||
			<details>
 | 
					 | 
				
			||||||
				<summary>Report</summary>
 | 
					 | 
				
			||||||
				<ul>
 | 
					 | 
				
			||||||
					<li><strong>From:</strong> {entry.report.reporter_id}</li>
 | 
					 | 
				
			||||||
					<li><strong>Target:</strong> {entry.report.target_user_id}</li>
 | 
					 | 
				
			||||||
					<li><strong>Reason:</strong> {entry.report.reason}</li>
 | 
					 | 
				
			||||||
					{#if entry.report.context}
 | 
					 | 
				
			||||||
						<li><strong>Context:</strong> {entry.report.context}</li>
 | 
					 | 
				
			||||||
					{/if}
 | 
					 | 
				
			||||||
				</ul>
 | 
					 | 
				
			||||||
			</details>
 | 
					 | 
				
			||||||
		{:else}
 | 
					 | 
				
			||||||
			<p><em>(the ignored report has been deleted)</em></p>
 | 
					 | 
				
			||||||
		{/if}
 | 
					 | 
				
			||||||
	{/if}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	{#if reason}
 | 
						{#if reason}
 | 
				
			||||||
		<details>
 | 
							<details>
 | 
				
			||||||
			<summary>Reason</summary>
 | 
								<summary>Reason</summary>
 | 
				
			||||||
			{@html reason}
 | 
								{@html reason}
 | 
				
			||||||
		</details>
 | 
							</details>
 | 
				
			||||||
	{:else}
 | 
						{:else}
 | 
				
			||||||
		<p><em>(no reason given)</em></p>
 | 
							<em>(no reason given)</em>
 | 
				
			||||||
	{/if}
 | 
						{/if}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,8 +2,8 @@
 | 
				
			||||||
	import { t } from "$lib/i18n";
 | 
						import { t } from "$lib/i18n";
 | 
				
			||||||
	import type { AuthMethod } from "$api/models";
 | 
						import type { AuthMethod } from "$api/models";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	type Props = { method: AuthMethod; canRemove: boolean; showType?: boolean };
 | 
						type Props = { method: AuthMethod; canRemove: boolean };
 | 
				
			||||||
	let { method, canRemove, showType }: Props = $props();
 | 
						let { method, canRemove }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let name = $derived(
 | 
						let name = $derived(
 | 
				
			||||||
		method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
 | 
							method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
 | 
				
			||||||
| 
						 | 
					@ -14,9 +14,6 @@
 | 
				
			||||||
<div class="list-group-item">
 | 
					<div class="list-group-item">
 | 
				
			||||||
	<div class="row">
 | 
						<div class="row">
 | 
				
			||||||
		<div class="col">
 | 
							<div class="col">
 | 
				
			||||||
			{#if showType}
 | 
					 | 
				
			||||||
				<code>{method.type}</code>:
 | 
					 | 
				
			||||||
			{/if}
 | 
					 | 
				
			||||||
			{name}
 | 
								{name}
 | 
				
			||||||
			{#if showId}({method.remote_id}){/if}
 | 
								{#if showId}({method.remote_id}){/if}
 | 
				
			||||||
		</div>
 | 
							</div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,13 +41,6 @@
 | 
				
			||||||
				>
 | 
									>
 | 
				
			||||||
					Audit log
 | 
										Audit log
 | 
				
			||||||
				</a>
 | 
									</a>
 | 
				
			||||||
				<a
 | 
					 | 
				
			||||||
					href="/admin/lookup"
 | 
					 | 
				
			||||||
					class="list-group-item list-group-item-action"
 | 
					 | 
				
			||||||
					class:active={isActive("/admin/lookup", true)}
 | 
					 | 
				
			||||||
				>
 | 
					 | 
				
			||||||
					Lookup
 | 
					 | 
				
			||||||
				</a>
 | 
					 | 
				
			||||||
			</div>
 | 
								</div>
 | 
				
			||||||
		</div>
 | 
							</div>
 | 
				
			||||||
		<div class="col-md-9">
 | 
							<div class="col-md-9">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,10 +6,6 @@
 | 
				
			||||||
	let { data }: Props = $props();
 | 
						let { data }: Props = $props();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<svelte:head>
 | 
					 | 
				
			||||||
	<title>Admin dashboard • pronouns.cc</title>
 | 
					 | 
				
			||||||
</svelte:head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<h1>Dashboard</h1>
 | 
					<h1>Dashboard</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="row gx-3 gy-3">
 | 
					<div class="row gx-3 gy-3">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,10 +45,6 @@
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<svelte:head>
 | 
					 | 
				
			||||||
	<title>Audit log • pronouns.cc</title>
 | 
					 | 
				
			||||||
</svelte:head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<h1>Audit log</h1>
 | 
					<h1>Audit log</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="btn-group">
 | 
					<div class="btn-group">
 | 
				
			||||||
| 
						 | 
					@ -72,12 +68,6 @@
 | 
				
			||||||
			<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
 | 
								<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
 | 
				
			||||||
				Suspend user
 | 
									Suspend user
 | 
				
			||||||
			</DropdownItem>
 | 
								</DropdownItem>
 | 
				
			||||||
			<DropdownItem
 | 
					 | 
				
			||||||
				href={addTypeFilter("QuerySensitiveUserData")}
 | 
					 | 
				
			||||||
				active={data.type === "QuerySensitiveUserData"}
 | 
					 | 
				
			||||||
			>
 | 
					 | 
				
			||||||
				Query sensitive user data
 | 
					 | 
				
			||||||
			</DropdownItem>
 | 
					 | 
				
			||||||
			{#if data.type}
 | 
								{#if data.type}
 | 
				
			||||||
				<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
 | 
									<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
 | 
				
			||||||
			{/if}
 | 
								{/if}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,27 +0,0 @@
 | 
				
			||||||
import { apiRequest } from "$api";
 | 
					 | 
				
			||||||
import { redirect } from "@sveltejs/kit";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const actions = {
 | 
					 | 
				
			||||||
	default: async ({ request, fetch, cookies }) => {
 | 
					 | 
				
			||||||
		const body = await request.formData();
 | 
					 | 
				
			||||||
		const query = body.get("query") as string;
 | 
					 | 
				
			||||||
		const fuzzy = body.get("fuzzy") === "yes";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		const users = await apiRequest<Array<{ id: string; username: string }>>(
 | 
					 | 
				
			||||||
			"POST",
 | 
					 | 
				
			||||||
			"/moderation/lookup",
 | 
					 | 
				
			||||||
			{
 | 
					 | 
				
			||||||
				fetch,
 | 
					 | 
				
			||||||
				cookies,
 | 
					 | 
				
			||||||
				body: {
 | 
					 | 
				
			||||||
					query,
 | 
					 | 
				
			||||||
					fuzzy,
 | 
					 | 
				
			||||||
				},
 | 
					 | 
				
			||||||
			},
 | 
					 | 
				
			||||||
		);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if (!fuzzy && users.length > 0) redirect(303, `/admin/lookup/${users[0].id}`);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return { users };
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,33 +0,0 @@
 | 
				
			||||||
<script lang="ts">
 | 
					 | 
				
			||||||
	import type { ActionData } from "./$types";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	type Props = { form: ActionData };
 | 
					 | 
				
			||||||
	let { form }: Props = $props();
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<svelte:head>
 | 
					 | 
				
			||||||
	<title>Look up a user • pronouns.cc</title>
 | 
					 | 
				
			||||||
</svelte:head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<h1>Look up a user</h1>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<form method="POST">
 | 
					 | 
				
			||||||
	<div class="input-group w-lg-50 mb-2">
 | 
					 | 
				
			||||||
		<input type="text" class="form-control" name="query" placeholder="Query" required />
 | 
					 | 
				
			||||||
		<button class="btn btn-primary" type="submit">Search</button>
 | 
					 | 
				
			||||||
	</div>
 | 
					 | 
				
			||||||
	<div class="form-check mb-3">
 | 
					 | 
				
			||||||
		<input class="form-check-input" type="checkbox" value="yes" name="fuzzy" id="fuzzy" />
 | 
					 | 
				
			||||||
		<label class="form-check-label" for="fuzzy">Fuzzy?</label>
 | 
					 | 
				
			||||||
	</div>
 | 
					 | 
				
			||||||
</form>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<div class="list-group">
 | 
					 | 
				
			||||||
	{#each form?.users || [] as user (user.id)}
 | 
					 | 
				
			||||||
		<a href="/admin/lookup/{user.id}" class="list-group-item list-group-item-action">
 | 
					 | 
				
			||||||
			{user.username} <span class="text-secondary">({user.id})</span>
 | 
					 | 
				
			||||||
		</a>
 | 
					 | 
				
			||||||
	{:else}
 | 
					 | 
				
			||||||
		<div class="list-group-item">No results</div>
 | 
					 | 
				
			||||||
	{/each}
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,11 +0,0 @@
 | 
				
			||||||
import { apiRequest } from "$api";
 | 
					 | 
				
			||||||
import type { QueriedUser } from "$api/models/moderation";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const load = async ({ params, fetch, cookies }) => {
 | 
					 | 
				
			||||||
	const user = await apiRequest<QueriedUser>("GET", `/moderation/lookup/${params.id}`, {
 | 
					 | 
				
			||||||
		fetch,
 | 
					 | 
				
			||||||
		cookies,
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return { user };
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,63 +0,0 @@
 | 
				
			||||||
<script lang="ts">
 | 
					 | 
				
			||||||
	import { idTimestamp } from "$lib";
 | 
					 | 
				
			||||||
	import { DateTime } from "luxon";
 | 
					 | 
				
			||||||
	import type { PageData } from "./$types";
 | 
					 | 
				
			||||||
	import ProfileHeader from "$components/profile/ProfileHeader.svelte";
 | 
					 | 
				
			||||||
	import ProfileFields from "$components/profile/ProfileFields.svelte";
 | 
					 | 
				
			||||||
	import { mergePreferences } from "$api/models";
 | 
					 | 
				
			||||||
	import AuthMethodRow from "$components/settings/AuthMethodRow.svelte";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	type Props = { data: PageData };
 | 
					 | 
				
			||||||
	let { data }: Props = $props();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	let createdAt = $derived(idTimestamp(data.user.user.id));
 | 
					 | 
				
			||||||
	let lastActive = $derived(DateTime.fromISO(data.user.last_active));
 | 
					 | 
				
			||||||
	let lastSidReroll = $derived(DateTime.fromISO(data.user.last_sid_reroll));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	let authMethods = $derived.by(() => {
 | 
					 | 
				
			||||||
		if (!data.user.auth_methods) return undefined;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		return data.user.auth_methods.sort((a, b) => a.type.localeCompare(b.type));
 | 
					 | 
				
			||||||
	});
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<svelte:head>
 | 
					 | 
				
			||||||
	<title>Looking up @{data.user.user.username} • pronouns.cc</title>
 | 
					 | 
				
			||||||
</svelte:head>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<h2>Basic profile</h2>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<ProfileHeader name="@{data.user.user.username}" profile={data.user.user} />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<ProfileFields
 | 
					 | 
				
			||||||
	profile={data.user.user}
 | 
					 | 
				
			||||||
	allPreferences={mergePreferences(data.user.user.custom_preferences)}
 | 
					 | 
				
			||||||
/>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<h2>Extra information</h2>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<table class="table table-striped table-hover table-bordered">
 | 
					 | 
				
			||||||
	<tbody>
 | 
					 | 
				
			||||||
		<tr>
 | 
					 | 
				
			||||||
			<th scope="row">Created at</th>
 | 
					 | 
				
			||||||
			<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
 | 
					 | 
				
			||||||
		</tr>
 | 
					 | 
				
			||||||
		<tr>
 | 
					 | 
				
			||||||
			<th scope="row">Last active</th>
 | 
					 | 
				
			||||||
			<td>{lastActive.toLocaleString(DateTime.DATETIME_MED)}</td>
 | 
					 | 
				
			||||||
		</tr>
 | 
					 | 
				
			||||||
		<tr>
 | 
					 | 
				
			||||||
			<th scope="row">Last SID reroll</th>
 | 
					 | 
				
			||||||
			<td>{lastSidReroll.toLocaleString(DateTime.DATETIME_MED)}</td>
 | 
					 | 
				
			||||||
		</tr>
 | 
					 | 
				
			||||||
	</tbody>
 | 
					 | 
				
			||||||
</table>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{#if authMethods}
 | 
					 | 
				
			||||||
	<h2>Authentication methods</h2>
 | 
					 | 
				
			||||||
	<div class="list-group">
 | 
					 | 
				
			||||||
		{#each authMethods as method (method.id)}
 | 
					 | 
				
			||||||
			<AuthMethodRow {method} canRemove={false} showType />
 | 
					 | 
				
			||||||
		{/each}
 | 
					 | 
				
			||||||
	</div>
 | 
					 | 
				
			||||||
{/if}
 | 
					 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue