diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index 7582d8f..ba57ef1 100644 --- a/contracts/api.schemas.json +++ b/contracts/api.schemas.json @@ -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", diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs index d004c91..fc2cd7e 100644 --- a/server/src/WebDrop.Api/Endpoints.Accounts.cs +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -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() 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); diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index 3462da8..9c1895b 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -22,9 +22,13 @@ public sealed partial class ChannelService /// 补上默认身份组(只补一次)。读取时只在内存里补,下一次写入才落库 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 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 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 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); // 只能把已加入空间的账号放进身份组;空间主本来就有全部权限,不必加入 diff --git a/server/src/WebDrop.Core/Workspace.cs b/server/src/WebDrop.Core/Workspace.cs index a3b4063..db05994 100644 --- a/server/src/WebDrop.Core/Workspace.cs +++ b/server/src/WebDrop.Core/Workspace.cs @@ -36,22 +36,37 @@ public sealed class WorkspaceLocation [JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; } } /// 身份组可授予的权限。空间主拥有全部权限,不属于任何身份组 -public static class WorkspacePermission +public static partial class WorkspacePermission { /// 移除成员、撤销邀请 public const string RemoveMembers = "members.remove"; /// 重命名频道、调整频道所属分组、管理分组(新建频道涉及密钥,仍只有空间主) public const string ManageStructure = "structure.manage"; public static readonly IReadOnlyList All = [RemoveMembers, ManageStructure]; - public static readonly IReadOnlyList Colors = ["gray", "blue", "green", "amber", "red", "purple"]; + /// 前后缀(支持行内 Markdown)的长度上限:界面不限制,这里只防止空间数据被撑大 + public const int MaxLabelLength = 2000; + /// 预设名,或自定义:纯色 "#rrggbb"、渐变 "g:#rrggbb,#rrggbb[,#rrggbb]"(2~5 个色标,从左到右);后面可以接动画效果 "|breathe"(呼吸) "|blink"(闪烁) "|marquee"(跑马灯,仅渐变) "|cycle"(色相循环) + 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(); + /// 早期版本的预设色名,老数据里可能还有;新的界面只写自定义颜色 + public static readonly IReadOnlyList Colors = ["gray", "blue", "green", "amber", "red", "purple", "sunset", "ocean", "aurora", "candy", "gold", "rainbow"]; } public sealed class WorkspaceRole { + /// 空间主所在的「Owner 组」,每个空间都有,id 固定。成员就是空间主本人,权限固定为全部,只能改前后缀和颜色 + 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], + }; + /// 每个空间都有的默认「管理组」,id 固定,老空间第一次读写时补上 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], }; diff --git a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs index 38d35d6..0839b7e 100644 --- a/server/src/WebDrop.Storage/SqliteAccountProfiles.cs +++ b/server/src/WebDrop.Storage/SqliteAccountProfiles.cs @@ -16,14 +16,20 @@ public sealed partial class SqliteAccountStore : new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 }; } } + /// 动图头像保留全部帧,允许比静态头像大得多(约 4.5 MB) + 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) diff --git a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs index 5a562bd..2054030 100644 Binary files a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs and b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs differ diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs index c66ed69..bffb162 100644 --- a/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs +++ b/server/tests/WebDrop.Core.Tests/WorkspaceStructureTests.cs @@ -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(() => env.Service.DeleteWorkspaceRole(ch.Id, owner, WorkspaceRole.OwnerId)); + Assert.Throws(() => 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(() => 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(() => 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"]); diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index f7e2691..d1ae676 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/core-bridge/generated/api.ts @@ -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;