diff options
| -rw-r--r-- | Services/SecurityService.cs | 61 | ||||
| -rw-r--r-- | Todo.md | 1 | ||||
| -rw-r--r-- | Util/Extensions.cs | 33 | ||||
| -rw-r--r-- | Util/LimitedConcurrencyTaskScheduler.cs (renamed from Util.cs) | 32 | ||||
| -rw-r--r-- | Util/MemoryCache.cs | 98 |
5 files changed, 174 insertions, 51 deletions
diff --git a/Services/SecurityService.cs b/Services/SecurityService.cs index 6e5ecb8..f0ebd70 100644 --- a/Services/SecurityService.cs +++ b/Services/SecurityService.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; +using HyperBooru.Util; +using Microsoft.EntityFrameworkCore; using System.Data; namespace HyperBooru.Services; @@ -7,25 +7,34 @@ namespace HyperBooru.Services; public class SecurityService { private IDbContextFactory<HBContext> dbFactory; - private Group[] groups; - private Acl[] acls; + private MemoryCache<int, HBPrincipal> principalCache; + private MemoryCache<int, Acl> aclCache; public SecurityService(IDbContextFactory<HBContext> dbFactory) { this.dbFactory = dbFactory; - Reload(); - } - - public void Reload() { - using var db = dbFactory.CreateDbContext(); - groups = db.Groups - .Include(g => g.MemberOf) - .ToArray(); + // TODO: preload the principal cache + principalCache = new() { + MaxItems = 10_000, + MaxAge = TimeSpan.FromMinutes(10), + DataSource = (int id) => { + using var db = dbFactory.CreateDbContext(); + return db.Principals + .Include(p => p.MemberOf) + .FirstOrDefault(p => p.ObjectId == id); + } + }; - acls = db.Acls - .Include(a => a.Rules) - .ThenInclude(r => r.Principal) - .ToArray(); + aclCache = new() { + MaxItems = 1000, + MaxAge = TimeSpan.FromMinutes(10), + DataSource = (int id) => { + using var db = dbFactory.CreateDbContext(); + return db.Acls + .Include(a => a.Rules) + .FirstOrDefault(a => a.ObjectId == id); + } + }; } public IEnumerable<HBObject> Filter( @@ -46,6 +55,13 @@ public class SecurityService { T permissions) where T : Enum => Filter(objects, principal, permissions); + /// <summary> + /// Resolve the specified ACL and return a bitmask representing + /// all the permissions the specified principal has. + /// </summary> + /// <param name="acl"> + /// ACL to resolve (returns a bitmask consisting of all 1's if this field is null) + /// </param> private ulong GetPermissions(Acl? acl, HBPrincipal principal) { if(acl is null) return ulong.MaxValue; @@ -75,18 +91,25 @@ public class SecurityService { return permissions; } + /// <summary> + /// Recursively get all groups of which the specified principal + /// is a member, including implicit memberships. + /// </summary> private List<Group> GetGroupMemberShip(HBPrincipal principal) { var groups = principal.MemberOf.ToList(); while(true) { - var toAdd = this.groups - .Where(g => !groups.Contains(g)) + var toAdd = groups + .SelectMany(g => g.MemberOf) + .Select(g => g.ObjectId) + .Where(id => !groups.Select(g => g.ObjectId).Contains(id)) .ToArray(); if(toAdd.Count() == 0) break; - groups.AddRange(toAdd); + foreach(var id in toAdd) + groups.Add((Group) principalCache[id]); } return groups; @@ -30,3 +30,4 @@ - [ ] Journaled operations - [ ] Confirmation dialog before enabling NSFW mode - [ ] Upload progress bars + - [ ] Save user session tokens in DB diff --git a/Util/Extensions.cs b/Util/Extensions.cs new file mode 100644 index 0000000..bbf9a12 --- /dev/null +++ b/Util/Extensions.cs @@ -0,0 +1,33 @@ +namespace HyperBooru.Util; + +public static class Extensions { + public static readonly string[] MagnitudeOrders = new[] { + "K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q" + }; + + public static DateTime? TryParseDateTimeUtc(this string s) { + bool success = DateTime.TryParse(s, out var dateTime); + return success ? DateTime.SpecifyKind(dateTime, DateTimeKind.Utc) : null; + } + + public static string ToBytesSI(this long x) { + var exp = (int) Math.Log10(x); + var suffix = MagnitudeOrders.ElementAtOrDefault(exp / 3 - 1); + if(suffix is null) + return x.ToString(); + double n = x / Math.Pow(10, exp / 3 * 3); + return $"{Math.Round(n, 2 - (exp % 3))} {suffix}B"; + } + + public static string ToStringHumanReadable(this TimeSpan t) { + if(t.TotalMilliseconds < 1000) + return string.Format("{0:0}ms", t.TotalMilliseconds); + if(t.TotalSeconds < 60) + return string.Format("{0:0.00}s", t.TotalSeconds); + if(t.TotalMinutes < 60) + return string.Format("{0:0}m{0:0}s", t.TotalMinutes, t.Seconds); + if(t.TotalHours < 24) + return string.Format("{0:0}h{0:0}m", t.TotalHours, t.Minutes); + return string.Format("{0:0.00}d", t.TotalDays); + } +} diff --git a/Util.cs b/Util/LimitedConcurrencyTaskScheduler.cs index 6af6c81..8d5adf2 100644 --- a/Util.cs +++ b/Util/LimitedConcurrencyTaskScheduler.cs @@ -1,37 +1,5 @@ namespace HyperBooru.Util; -public static class Extensions { - public static readonly string[] MagnitudeOrders = new[] { - "K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q" - }; - - public static DateTime? TryParseDateTimeUtc(this string s) { - bool success = DateTime.TryParse(s, out var dateTime); - return success ? DateTime.SpecifyKind(dateTime, DateTimeKind.Utc) : null; - } - - public static string ToBytesSI(this long x) { - var exp = (int) Math.Log10(x); - var suffix = MagnitudeOrders.ElementAtOrDefault(exp / 3 - 1); - if(suffix is null) - return x.ToString(); - double n = x / Math.Pow(10, exp / 3 * 3); - return $"{Math.Round(n, 2 - (exp % 3))} {suffix}B"; - } - - public static string ToStringHumanReadable(this TimeSpan t) { - if(t.TotalMilliseconds < 1000) - return string.Format("{0:0}ms", t.TotalMilliseconds); - if(t.TotalSeconds < 60) - return string.Format("{0:0.00}s", t.TotalSeconds); - if(t.TotalMinutes < 60) - return string.Format("{0:0}m{0:0}s", t.TotalMinutes, t.Seconds); - if(t.TotalHours < 24) - return string.Format("{0:0}h{0:0}m", t.TotalHours, t.Minutes); - return string.Format("{0:0.00}d", t.TotalDays); - } -} - public class LimitedConcurrencyTaskScheduler : TaskScheduler { public sealed override int MaximumConcurrencyLevel => maxConcurrency; diff --git a/Util/MemoryCache.cs b/Util/MemoryCache.cs new file mode 100644 index 0000000..ba314cf --- /dev/null +++ b/Util/MemoryCache.cs @@ -0,0 +1,98 @@ +using System.Collections; + +namespace HyperBooru.Util; + +public class MemoryCache<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>> where TKey : struct { + /// <summary>Maximum number of items this cache may hold (unlimited if null)</summary> + public int? MaxItems { get; init; } + /// <summary>Maximum amount of time an item may stay in cache before it is removed (unlimited if null)</summary> + public TimeSpan? MaxAge { get; init; } + /// <summary>Function that will be called to populate the cache in the event of a cache-miss</summary> + public Func<TKey, TValue?>? DataSource { get; init; } + + private Dictionary<TKey, (DateTime createTime, TValue value)> cache = new(); + + public TValue this[TKey key] { + get => GetValue(key); + set { + Prune(); + cache[key] = (DateTime.Now, value); + } + } + + public TValue GetValue(TKey key) { + bool success = cache.TryGetValue(key, out var result); + if(success) { + if(MaxAge is null) + return result.value; + if(result.createTime > DateTime.Now - MaxAge) + return result.value; + } + + if(DataSource is null) + throw new KeyNotFoundException(); + + TValue? value = DataSource(key); + + if(value is null) + throw new KeyNotFoundException(); + + Prune(); + cache[key] = (DateTime.Now, value); + return value; + } + + public bool TryGetValue(TKey key, out TValue? value) { + try { + value = GetValue(key); + return true; + } catch(KeyNotFoundException) { + value = default; + return false; + } + } + + public bool Remove(TKey key) => cache.Remove(key); + + public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() { + DateTime? expiry = MaxAge is null ? null : DateTime.Now - MaxAge; + + foreach(var kv in cache) { + // Don't return expired cache items + if(expiry is not null) + if(kv.Value.createTime < expiry) + continue; + + yield return new KeyValuePair<TKey, TValue>(kv.Key, kv.Value.value); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private void Prune() { + DateTime? expiry = MaxAge is null ? null : DateTime.Now - MaxAge; + + // If an expiry time for cache items was + // specified, remove expired cache items. + if(expiry is not null) { + foreach(var kv in cache) { + if(kv.Value.createTime < expiry) + cache.Remove(kv.Key); + } + } + + // If this cache was created with a maximum size, + // remove elements until that size is reached. + if(MaxItems is null || cache.Count() < MaxItems) + return; + + var toRemove = cache + .OrderBy(kv => kv.Value.createTime) + .Take(cache.Count() - (int) MaxItems + 1) + .Select(kv => kv.Key) + .ToArray(); + + foreach(var key in toRemove) + cache.Remove(key); + } +} |
