feat(server): owner role group, custom role colors with effects and animated avatars
This commit is contained in:
1 parent
7ef298f587
commit
bef8898cd8
8 files changed
+67
-19
No files matched your search
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
@@ -15,6 +16,8 @@ public static partial class Endpoints
|
||||
api.MapPatch("/accounts/profile", async (HttpContext ctx, ChannelService svc, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
|
||||
// 动图头像比其他请求大得多,单独放宽(全局上限 2 MB)
|
||||
if (ctx.Features.Get<IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } size) size.MaxRequestBodySize = 8 * 1024 * 1024;
|
||||
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountProfileRequest, ct);
|
||||
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
|
||||
svc.AccountProfileChanged(username);
|
||||
|
||||
@@ -22,9 +22,13 @@ public sealed partial class ChannelService
|
||||
/// <summary>补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库</summary>
|
||||
private static void EnsureDefaultRole(WorkspaceState state, long now)
|
||||
{
|
||||
if (state.RolesSeeded) return;
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now));
|
||||
state.RolesSeeded = true;
|
||||
if (!state.RolesSeeded)
|
||||
{
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.DefaultId)) state.Roles.Insert(0, WorkspaceRole.CreateDefault(now));
|
||||
state.RolesSeeded = true;
|
||||
}
|
||||
// Owner 组永远排在最前面
|
||||
if (!state.Roles.Any(r => r.Id == WorkspaceRole.OwnerId)) state.Roles.Insert(0, WorkspaceRole.CreateOwner(now));
|
||||
}
|
||||
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
|
||||
{
|
||||
@@ -112,12 +116,12 @@ public sealed partial class ChannelService
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
if (value.Length > 12 || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (value.Length > WorkspacePermission.MaxLabelLength || value.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
return value;
|
||||
}
|
||||
private static void ApplyRole(WorkspaceRole role, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions)
|
||||
{
|
||||
if (color is not null && !WorkspacePermission.Colors.Contains(color)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (color is not null && !WorkspacePermission.IsValidColor(color)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (permissions.Any(p => !WorkspacePermission.All.Contains(p))) throw new WebDropException(ErrorCode.BadRequest);
|
||||
role.Name = WorkspaceName(name); role.Prefix = RoleLabel(prefix); role.Suffix = RoleLabel(suffix); role.Color = color;
|
||||
role.Permissions = permissions.Distinct().ToList();
|
||||
@@ -134,13 +138,25 @@ public sealed partial class ChannelService
|
||||
return (state, created!);
|
||||
}
|
||||
public WorkspaceState UpdateWorkspaceRole(Id16 id, Credentials creds, string roleId, string name, string? prefix, string? suffix, string? color, IReadOnlyList<string> permissions) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => ApplyRole(FindRole(state, roleId), name, prefix, suffix, color, permissions));
|
||||
ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
// Owner 组的名称和权限固定,只有前后缀和颜色可改
|
||||
if (role.Id == WorkspaceRole.OwnerId) ApplyRole(role, role.Name, prefix, suffix, color, WorkspacePermission.All);
|
||||
else ApplyRole(role, name, prefix, suffix, color, permissions);
|
||||
});
|
||||
public WorkspaceState DeleteWorkspaceRole(Id16 id, Credentials creds, string roleId) =>
|
||||
ChangeWorkspace(id, creds, (_, state, _) => state.Roles.Remove(FindRole(state, roleId)));
|
||||
ChangeWorkspace(id, creds, (_, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
|
||||
state.Roles.Remove(role);
|
||||
});
|
||||
public WorkspaceState SetWorkspaceRoleMembers(Id16 id, Credentials creds, string roleId, IReadOnlyList<string> members) =>
|
||||
ChangeWorkspace(id, creds, (tx, state, _) =>
|
||||
{
|
||||
var role = FindRole(state, roleId);
|
||||
if (role.Id == WorkspaceRole.OwnerId) throw new WebDropException(ErrorCode.BadRequest);
|
||||
var distinct = members.Select(m => m.Trim().ToLowerInvariant()).Distinct().ToList();
|
||||
if (distinct.Count > 256) throw new WebDropException(ErrorCode.BadRequest);
|
||||
// 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入
|
||||
|
||||
@@ -36,22 +36,37 @@ public sealed class WorkspaceLocation
|
||||
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
|
||||
}
|
||||
/// <summary>身份组可授予的权限。空间主拥有全部权限,不属于任何身份组</summary>
|
||||
public static class WorkspacePermission
|
||||
public static partial class WorkspacePermission
|
||||
{
|
||||
/// <summary>移除成员、撤销邀请</summary>
|
||||
public const string RemoveMembers = "members.remove";
|
||||
/// <summary>重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主)</summary>
|
||||
public const string ManageStructure = "structure.manage";
|
||||
public static readonly IReadOnlyList<string> All = [RemoveMembers, ManageStructure];
|
||||
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple"];
|
||||
/// <summary>前后缀(支持行内 Markdown)的长度上限:界面不限制,这里只防止空间数据被撑大</summary>
|
||||
public const int MaxLabelLength = 2000;
|
||||
/// <summary>预设名,或自定义:纯色 "#rrggbb"、渐变 "g:#rrggbb,#rrggbb[,#rrggbb]"(2~5 个色标,从左到右);后面可以接动画效果 "|breathe"(呼吸) "|blink"(闪烁) "|marquee"(跑马灯,仅渐变) "|cycle"(色相循环)</summary>
|
||||
public static bool IsValidColor(string color) => Colors.Contains(color) || CustomColor().IsMatch(color);
|
||||
[System.Text.RegularExpressions.GeneratedRegex("^(#[0-9a-fA-F]{6}|g:#[0-9a-fA-F]{6}(,#[0-9a-fA-F]{6}){1,4})(\\|(breathe|blink|marquee|cycle))?$")]
|
||||
private static partial System.Text.RegularExpressions.Regex CustomColor();
|
||||
/// <summary>早期版本的预设色名,老数据里可能还有;新的界面只写自定义颜色</summary>
|
||||
public static readonly IReadOnlyList<string> Colors = ["gray", "blue", "green", "amber", "red", "purple", "sunset", "ocean", "aurora", "candy", "gold", "rainbow"];
|
||||
}
|
||||
public sealed class WorkspaceRole
|
||||
{
|
||||
/// <summary>空间主所在的「Owner 组」,每个空间都有,id 固定。成员就是空间主本人,权限固定为全部,只能改前后缀和颜色</summary>
|
||||
public const string OwnerId = "00000000-0000-4000-8000-000000000000";
|
||||
public static WorkspaceRole CreateOwner(long now) => new()
|
||||
{
|
||||
Id = OwnerId, Name = "Owner 组", Suffix = "Owner", Color = "g:#d99a1c,#f2c94c,#c27a0a", CreatedAt = now,
|
||||
Permissions = [.. WorkspacePermission.All],
|
||||
};
|
||||
|
||||
/// <summary>每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上</summary>
|
||||
public const string DefaultId = "00000000-0000-4000-8000-000000000001";
|
||||
public static WorkspaceRole CreateDefault(long now) => new()
|
||||
{
|
||||
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "blue", CreatedAt = now,
|
||||
Id = DefaultId, Name = "管理组", Suffix = "管理员", Color = "#3b6ea8", CreatedAt = now,
|
||||
Permissions = [.. WorkspacePermission.All],
|
||||
};
|
||||
|
||||
|
||||
@@ -16,14 +16,20 @@ public sealed partial class SqliteAccountStore
|
||||
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
|
||||
}
|
||||
}
|
||||
/// <summary>动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB)</summary>
|
||||
private const int MaxGifAvatarChars = 6_000_000;
|
||||
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
|
||||
{
|
||||
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (avatar is not null)
|
||||
{
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
|
||||
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/gif;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
|
||||
if (prefix is null || avatar.Length > (prefix.Contains("gif") ? MaxGifAvatarChars : 350_000) || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(avatar[prefix.Length..]);
|
||||
if (prefix.Contains("gif") && !(bytes.Length > 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F')) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
|
||||
}
|
||||
lock (_gate)
|
||||
|
||||
Reference in new issue
Block a user