for *some reason*, coravel locks a persistent job queue behind a paywall. this means that if the server ever crashes, all pending jobs are lost. this is... not good, so we're switching to hangfire for that instead. coravel is still used for emails, though. BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore storage for hangfire doesn't work well enough, unfortunately.
316 lines
11 KiB
C#
316 lines
11 KiB
C#
// 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 EntityFramework.Exceptions.Common;
|
|
using Foxnouns.Backend.Database;
|
|
using Foxnouns.Backend.Database.Models;
|
|
using Foxnouns.Backend.Dto;
|
|
using Foxnouns.Backend.Extensions;
|
|
using Foxnouns.Backend.Jobs;
|
|
using Foxnouns.Backend.Middleware;
|
|
using Foxnouns.Backend.Services;
|
|
using Foxnouns.Backend.Utils;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
using NodaTime;
|
|
using XidNet;
|
|
|
|
namespace Foxnouns.Backend.Controllers;
|
|
|
|
[Route("/api/v2/users/{userRef}/members")]
|
|
public class MembersController(
|
|
ILogger logger,
|
|
DatabaseContext db,
|
|
MemberRendererService memberRenderer,
|
|
ISnowflakeGenerator snowflakeGenerator,
|
|
ObjectStorageService objectStorageService,
|
|
IClock clock,
|
|
ValidationService validationService,
|
|
Config config
|
|
) : ApiControllerBase
|
|
{
|
|
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
|
|
|
[HttpGet]
|
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
|
[Limit(UsableByDeletedUsers = true)]
|
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
|
{
|
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
|
return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
|
|
}
|
|
|
|
[HttpGet("{memberRef}")]
|
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
|
[Limit(UsableByDeletedUsers = true)]
|
|
public async Task<IActionResult> GetMemberAsync(
|
|
string userRef,
|
|
string memberRef,
|
|
CancellationToken ct = default
|
|
)
|
|
{
|
|
Member member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
|
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
|
}
|
|
|
|
[HttpPost("/api/v2/users/@me/members")]
|
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
|
[Authorize("member.create")]
|
|
public async Task<IActionResult> CreateMemberAsync(
|
|
[FromBody] CreateMemberRequest req,
|
|
CancellationToken ct = default
|
|
)
|
|
{
|
|
ValidationUtils.Validate(
|
|
[
|
|
("name", validationService.ValidateMemberName(req.Name)),
|
|
("display_name", validationService.ValidateDisplayName(req.DisplayName)),
|
|
("bio", validationService.ValidateBio(req.Bio)),
|
|
("avatar", validationService.ValidateAvatar(req.Avatar)),
|
|
.. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
|
|
.. validationService.ValidateFieldEntries(
|
|
req.Names?.ToArray(),
|
|
CurrentUser!.CustomPreferences,
|
|
"names"
|
|
),
|
|
.. validationService.ValidatePronouns(
|
|
req.Pronouns?.ToArray(),
|
|
CurrentUser!.CustomPreferences
|
|
),
|
|
.. validationService.ValidateLinks(req.Links),
|
|
]
|
|
);
|
|
|
|
int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
|
if (memberCount >= config.Limits.MaxMemberCount)
|
|
throw new ApiError.BadRequest("Maximum number of members reached");
|
|
|
|
var member = new Member
|
|
{
|
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
|
LegacyId = Xid.NewXid().ToString(),
|
|
User = CurrentUser!,
|
|
Name = req.Name,
|
|
DisplayName = req.DisplayName,
|
|
Bio = req.Bio,
|
|
Links = req.Links ?? [],
|
|
Fields = req.Fields ?? [],
|
|
Names = req.Names ?? [],
|
|
Pronouns = req.Pronouns ?? [],
|
|
Unlisted = req.Unlisted ?? false,
|
|
Sid = null!,
|
|
};
|
|
db.Add(member);
|
|
|
|
_logger.Debug(
|
|
"Creating member {MemberName} ({Id}) for {UserId}",
|
|
member.Name,
|
|
member.Id,
|
|
CurrentUser!.Id
|
|
);
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
catch (UniqueConstraintException)
|
|
{
|
|
_logger.Debug("Could not create member {Id} due to name conflict", member.Id);
|
|
throw new ApiError.BadRequest(
|
|
"A member with that name already exists",
|
|
"name",
|
|
req.Name
|
|
);
|
|
}
|
|
|
|
if (req.Avatar != null)
|
|
{
|
|
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
|
|
}
|
|
|
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
|
}
|
|
|
|
[HttpPatch("/api/v2/users/@me/members/{memberRef}")]
|
|
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
|
|
[Authorize("member.update")]
|
|
public async Task<IActionResult> UpdateMemberAsync(
|
|
string memberRef,
|
|
[FromBody] UpdateMemberRequest req
|
|
)
|
|
{
|
|
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
|
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
|
var errors = new List<(string, ValidationError?)>();
|
|
|
|
// We might add extra validations for names later down the line.
|
|
// These should only take effect when a member's name is changed, not on other changes.
|
|
if (req.Name != null && req.Name != member.Name)
|
|
{
|
|
errors.Add(("name", validationService.ValidateMemberName(req.Name)));
|
|
member.Name = req.Name;
|
|
}
|
|
|
|
if (req.HasProperty(nameof(req.DisplayName)))
|
|
{
|
|
errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
|
|
member.DisplayName = req.DisplayName;
|
|
}
|
|
|
|
if (req.HasProperty(nameof(req.Bio)))
|
|
{
|
|
errors.Add(("bio", validationService.ValidateBio(req.Bio)));
|
|
member.Bio = req.Bio;
|
|
}
|
|
|
|
if (req.HasProperty(nameof(req.Links)))
|
|
{
|
|
errors.AddRange(validationService.ValidateLinks(req.Links));
|
|
member.Links = req.Links ?? [];
|
|
}
|
|
|
|
if (req.HasProperty(nameof(req.Unlisted)))
|
|
member.Unlisted = req.Unlisted ?? false;
|
|
|
|
if (req.Names != null)
|
|
{
|
|
errors.AddRange(
|
|
validationService.ValidateFieldEntries(
|
|
req.Names,
|
|
CurrentUser!.CustomPreferences,
|
|
"names"
|
|
)
|
|
);
|
|
member.Names = req.Names.ToList();
|
|
}
|
|
|
|
if (req.Pronouns != null)
|
|
{
|
|
errors.AddRange(
|
|
validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
|
|
);
|
|
member.Pronouns = req.Pronouns.ToList();
|
|
}
|
|
|
|
if (req.Fields != null)
|
|
{
|
|
errors.AddRange(
|
|
validationService.ValidateFields(
|
|
req.Fields.ToList(),
|
|
CurrentUser!.CustomPreferences
|
|
)
|
|
);
|
|
member.Fields = req.Fields.ToList();
|
|
}
|
|
|
|
if (req.Flags != null)
|
|
{
|
|
ValidationError? flagError = await db.SetMemberFlagsAsync(
|
|
CurrentUser!.Id,
|
|
member.Id,
|
|
req.Flags
|
|
);
|
|
if (flagError != null)
|
|
errors.Add(("flags", flagError));
|
|
}
|
|
|
|
if (req.HasProperty(nameof(req.Avatar)))
|
|
errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
|
|
|
|
ValidationUtils.Validate(errors);
|
|
// This is fired off regardless of whether the transaction is committed
|
|
// (atomic operations are hard when combined with background jobs)
|
|
// so it's in a separate block to the validation above.
|
|
if (req.HasProperty(nameof(req.Avatar)))
|
|
{
|
|
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
|
|
}
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync();
|
|
}
|
|
catch (UniqueConstraintException)
|
|
{
|
|
_logger.Debug(
|
|
"Could not update member {Id} due to name conflict ({CurrentName} / {NewName})",
|
|
member.Id,
|
|
member.Name,
|
|
req.Name
|
|
);
|
|
throw new ApiError.BadRequest(
|
|
"A member with that name already exists",
|
|
"name",
|
|
req.Name
|
|
);
|
|
}
|
|
|
|
await tx.CommitAsync();
|
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
|
}
|
|
|
|
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
|
[Authorize("member.update")]
|
|
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
|
{
|
|
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
|
int deleteCount = await db
|
|
.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
|
|
.ExecuteDeleteAsync();
|
|
if (deleteCount == 0)
|
|
{
|
|
_logger.Warning(
|
|
"Successfully resolved member {Id} but could not delete them",
|
|
member.Id
|
|
);
|
|
return NoContent();
|
|
}
|
|
|
|
if (member.Avatar != null)
|
|
await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
|
[Authorize("member.update")]
|
|
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
|
|
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
|
{
|
|
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
|
|
|
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
|
if (CurrentUser!.LastSidReroll > minTimeAgo)
|
|
throw new ApiError.BadRequest("Cannot reroll short ID yet");
|
|
|
|
// Using ExecuteUpdateAsync here as the new short ID is generated by the database
|
|
await db
|
|
.Members.Where(m => m.Id == member.Id)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid()));
|
|
|
|
await db
|
|
.Users.Where(u => u.Id == CurrentUser.Id)
|
|
.ExecuteUpdateAsync(s =>
|
|
s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
|
|
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
|
);
|
|
|
|
// Fetch the new sid then pass that to RenderMember
|
|
string newSid = await db
|
|
.Members.Where(m => m.Id == member.Id)
|
|
.Select(m => m.Sid)
|
|
.FirstAsync();
|
|
return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid));
|
|
}
|
|
}
|