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
+65
-17
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"x-backend-contract-sha256": "2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75",
|
||||
"x-backend-contract-sha256": "8e42178b1777925d0d9226bf2627fb82e637e0c89deb079d3a4d4eecd3a5fa4e",
|
||||
"$defs": {
|
||||
"AccountChannelBody": {
|
||||
"type": "object",
|
||||
|
||||
@@ -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,10 +22,14 @@ public sealed partial class ChannelService
|
||||
/// <summary>补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库</summary>
|
||||
private static void EnsureDefaultRole(WorkspaceState state, long now)
|
||||
{
|
||||
if (state.RolesSeeded) return;
|
||||
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 =>
|
||||
{
|
||||
var (space, principal) = Authenticate(tx, id, creds, Membership.Required);
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -90,9 +90,17 @@ public sealed class WorkspaceStructureTests
|
||||
Assert.Equal("管理员", role.Suffix); Assert.Equal(["alice"], role.Members);
|
||||
// 默认的「管理组」一直在;删掉自己建的不影响它,删掉默认组后也不会再自动出现
|
||||
var remaining = env.Service.DeleteWorkspaceRole(ch.Id, owner, created.Role.Id);
|
||||
Assert.Equal(WorkspaceRole.DefaultId, Assert.Single(remaining.Roles).Id);
|
||||
Assert.Empty(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles);
|
||||
Assert.Empty(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles);
|
||||
Assert.Equal([WorkspaceRole.OwnerId, WorkspaceRole.DefaultId], remaining.Roles.Select(r => r.Id));
|
||||
Assert.Equal(WorkspaceRole.OwnerId, Assert.Single(env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.DefaultId).Roles).Id);
|
||||
// Owner 组删不掉、不能加成员,但前后缀和颜色可以改,名称和权限保持不变
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId));
|
||||
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceRoleMembers(ch.Id, owner, WorkspaceRole.OwnerId, ["alice"]));
|
||||
var edited = env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "改名", "**主**", "*Owner*", "g:#ff0000,#0000ff", []).Roles.Single();
|
||||
Assert.Equal("Owner 组", edited.Name); Assert.Equal("**主**", edited.Prefix); Assert.Equal("g:#ff0000,#0000ff", edited.Color); Assert.Equal(WorkspacePermission.All, edited.Permissions);
|
||||
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#12345", []));
|
||||
Assert.Equal("g:#ff0000,#0000ff|marquee", env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "g:#ff0000,#0000ff|marquee", []).Roles.Single().Color); // 动画效果跟在颜色后面
|
||||
Assert.Throws<WebDropException>(() => env.Service.UpdateWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId, "x", null, null, "#ff0000|spin", []));
|
||||
Assert.Single(env.Service.CreateWorkspaceChannel(ch.Id, owner, "再写一次", null).Roles);
|
||||
}
|
||||
[Fact]
|
||||
public void StructureStoredBeforeRolesExistedStillLoadsWithAnEmptyRoleList()
|
||||
@@ -104,7 +112,7 @@ public sealed class WorkspaceStructureTests
|
||||
public void EverySpaceStartsWithADefaultAdminGroupThatCanHoldMembers()
|
||||
{
|
||||
var (env, ch, owner, alice, bob, _) = RoleSpace(); using var _env = env;
|
||||
var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles);
|
||||
var admin = Assert.Single(env.Service.GetWorkspace(ch.Id, owner).Roles, r => r.Id == WorkspaceRole.DefaultId);
|
||||
Assert.Equal(WorkspaceRole.DefaultId, admin.Id); Assert.Equal("管理组", admin.Name); Assert.Empty(admin.Members);
|
||||
Assert.Contains(WorkspacePermission.RemoveMembers, admin.Permissions); Assert.Contains(WorkspacePermission.ManageStructure, admin.Permissions);
|
||||
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admin.Id, ["alice"]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 2a28bf52c136caa7c0b86fc2630153cb71529e3c5d85c3b7196e9d9a04d63b75
|
||||
// backend-contract-sha256: 8e42178b1777925d0d9226bf2627fb82e637e0c89deb079d3a4d4eecd3a5fa4e
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
|
||||
Reference in new issue
Block a user