feat(server): owner role group, custom role colors with effects and animated avatars

This commit is contained in:
justme committed 2026-10-17 13:25:13 +08:00
1 parent 7ef298f587
commit bef8898cd8
8 files changed
+65 -17

No files matched your search

+1 -1
View File
@@ -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);
+21 -5
View File
@@ -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);
// 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入
+18 -3
View File
@@ -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 -1
View File
@@ -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;