feat(files): add per-channel storage quota [CI]
This commit is contained in:
1 parent
441ed0e5f0
commit
a6f078d009
19 files changed
+172
-15
No files matched your search
@@ -245,14 +245,16 @@ public sealed partial class ChannelService(
|
||||
if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey);
|
||||
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
|
||||
events.Advanced(channelId, outcome.LastSeq);
|
||||
if (outcome.NotifySettings) events.SettingsChanged(channelId);
|
||||
return outcome.Result;
|
||||
}
|
||||
|
||||
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes)
|
||||
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes, bool notifySettings = false)
|
||||
{
|
||||
public AppendResult Result { get; } = result;
|
||||
public long LastSeq { get; } = lastSeq;
|
||||
public List<string> BlobPrefixesToDelete { get; } = prefixes;
|
||||
public bool NotifySettings { get; } = notifySettings;
|
||||
}
|
||||
|
||||
private AppendOutcome AppendInTx(
|
||||
@@ -272,6 +274,10 @@ public sealed partial class ChannelService(
|
||||
var item = tx.GetItem(channelId, env.ItemId);
|
||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
|
||||
var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId);
|
||||
var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
||||
if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
bool countsTowardFileQuota = objectEnvelope?.Domain == CryptoDomain.Files && !env.IsControl;
|
||||
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (objectEnvelope is not null && env.IsFinal)
|
||||
{
|
||||
@@ -336,7 +342,11 @@ public sealed partial class ChannelService(
|
||||
if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
if (channel.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge);
|
||||
if (objectEnvelope is null && channel.Bytes + body.Length > limits.MaxChannelBytes
|
||||
|| objectEnvelope is not null && channel.Bytes + body.Length > limits.MaxWorkspaceBytes)
|
||||
throw new WebDropException(ErrorCode.TooLarge);
|
||||
if (countsTowardFileQuota && workspaceChannel!.FileBytes + body.Length > limits.MaxWorkspaceChannelBytes)
|
||||
throw new WebDropException(ErrorCode.TooLarge);
|
||||
|
||||
long seq = tx.NextSeq(channelId);
|
||||
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
|
||||
@@ -391,7 +401,16 @@ public sealed partial class ChannelService(
|
||||
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
|
||||
}
|
||||
|
||||
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes);
|
||||
if (countsTowardFileQuota)
|
||||
{
|
||||
var latestWorkspace = tx.GetWorkspace(channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var latestChannel = latestWorkspace.Channels.Find(c => c.Id == objectEnvelope!.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
latestChannel.FileBytes += body.Length;
|
||||
tx.PutWorkspace(channelId, latestWorkspace);
|
||||
}
|
||||
|
||||
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes,
|
||||
countsTowardFileQuota && env.IsFinal);
|
||||
}
|
||||
|
||||
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
|
||||
@@ -587,10 +606,23 @@ public sealed partial class ChannelService(
|
||||
});
|
||||
foreach (string prefix in prefixes) blobs.DeletePrefix(prefix);
|
||||
events.Advanced(id, seq);
|
||||
events.SettingsChanged(id);
|
||||
}
|
||||
|
||||
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == item.ItemId.ToBase64Url());
|
||||
if (objectEnvelope?.Domain == CryptoDomain.Files && (item.Flags & FrameFlags.Control) == 0)
|
||||
{
|
||||
var workspace = tx.GetWorkspace(channelId);
|
||||
var workspaceChannel = workspace?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
||||
if (workspace is not null && workspaceChannel is not null)
|
||||
{
|
||||
workspaceChannel.FileBytes = Math.Max(0, workspaceChannel.FileBytes - item.Bytes);
|
||||
tx.PutWorkspace(channelId, workspace);
|
||||
}
|
||||
}
|
||||
tx.DeleteItemRows(channelId, item.ItemId);
|
||||
tx.AddChannelBytes(channelId, -item.Bytes);
|
||||
long seq = tx.NextSeq(channelId);
|
||||
|
||||
@@ -27,6 +27,11 @@ public sealed partial class ChannelService
|
||||
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
|
||||
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
||||
foreach (var workspaceChannel in state.Channels)
|
||||
{
|
||||
var grant = grants.Find(a => a.ChannelId == workspaceChannel.Id);
|
||||
if (grant is null || !grant.FilesRead && !grant.FilesUpload) workspaceChannel.FileBytes = 0;
|
||||
}
|
||||
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
||||
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class ServerLimits
|
||||
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
|
||||
public int MaxFramesPerItem { get; set; } = 65_536;
|
||||
public long MaxChannelBytes { get; set; } = 1L << 30;
|
||||
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
||||
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
|
||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||
public int MaxSeats { get; set; } = 64;
|
||||
public int MaxVisitors { get; set; } = 10_000;
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class WorkspaceChannel
|
||||
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
|
||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
|
||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
|
||||
[JsonPropertyName("fileBytes")] public long FileBytes { get; set; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||
|
||||
Reference in new issue
Block a user