Skip to content

Commit 05409ef

Browse files
committed
fix: caching for avatars
1 parent 722d380 commit 05409ef

File tree

10 files changed

+336
-151
lines changed

10 files changed

+336
-151
lines changed

Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
2222
<PackageReference Include="System.IO.Abstractions" Version="21.1.7" />
2323
<PackageReference Include="System.IO.FileSystem" Version="4.3.0" />
24-
<PackageReference Include="System.IO.Hashing" Version="9.0.0" />
2524
</ItemGroup>
2625

2726
</Project>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.IO.Hashing;
3+
4+
namespace Intersect.Framework.IO;
5+
6+
public static class FileInfoExtensions
7+
{
8+
public static bool TryComputeChecksum(this FileInfo fileInfo, [NotNullWhen(true)] out string? checksum)
9+
{
10+
if (!fileInfo.Exists)
11+
{
12+
checksum = null;
13+
return false;
14+
}
15+
16+
Crc64 algorithm = new();
17+
18+
using var fileStream = fileInfo.OpenRead();
19+
algorithm.Append(fileStream);
20+
21+
var data = algorithm.GetHashAndReset();
22+
if (data.Length < 1)
23+
{
24+
checksum = null;
25+
return false;
26+
}
27+
28+
checksum = Convert.ToBase64String(data);
29+
if (!string.IsNullOrWhiteSpace(checksum))
30+
{
31+
return true;
32+
}
33+
34+
checksum = null;
35+
return false;
36+
37+
}
38+
}

Framework/Intersect.Framework/Intersect.Framework.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
99
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
1010
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
11+
<PackageReference Include="System.IO.Hashing" Version="9.0.1" />
1112
<PackageReference Include="System.Text.Json" Version="8.0.5" />
1213
</ItemGroup>
1314

Intersect.Server.Core/Collections/Indexing/LookupKey.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.ComponentModel;
1+
using System.ComponentModel;
22
using System.Globalization;
33
using Intersect.Server.Localization;
44
using Intersect.Utilities;
@@ -30,9 +30,35 @@ public partial struct LookupKey
3030

3131
public override string ToString()
3232
{
33+
if (IsInvalid)
34+
{
35+
return $"{{ {nameof(IsInvalid)}={true}, {nameof(Id)}={Id}, {nameof(Name)}={Name} }}";
36+
}
37+
3338
return HasId ? Id.ToString() : Name;
3439
}
3540

41+
public static implicit operator LookupKey(Guid id) => new()
42+
{
43+
Id = id,
44+
};
45+
46+
public static implicit operator LookupKey(string name)
47+
{
48+
if (Guid.TryParse(name, out var id))
49+
{
50+
return new LookupKey
51+
{
52+
Id = id,
53+
};
54+
}
55+
56+
return new LookupKey
57+
{
58+
Name = name,
59+
};
60+
}
61+
3662
public static bool TryParse(string input, out LookupKey lookupKey)
3763
{
3864
if (Guid.TryParse(input, out var guid))

Intersect.Server.Core/Database/PlayerData/User.cs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -562,16 +562,27 @@ public static bool TryFetch(LookupKey lookupKey, [NotNullWhen(true)] out User? u
562562
return user != default;
563563
}
564564

565-
public bool TryLoadAvatarName([NotNullWhen(true)] out string? avatarName, out bool isFace)
565+
public bool TryLoadAvatarName(
566+
[NotNullWhen(true)] out Player? playerWithAvatar,
567+
[NotNullWhen(true)] out string? avatarName,
568+
out bool isFace
569+
)
566570
{
567-
(avatarName, isFace) = Players
568-
.Select(
569-
player => player.TryLoadAvatarName(out var avatarName, out var isFace)
570-
? (Name: avatarName, IsFace: isFace)
571-
: default
572-
)
573-
.FirstOrDefault(avatarInfo => !string.IsNullOrWhiteSpace(avatarInfo.Name));
574-
return !string.IsNullOrWhiteSpace(avatarName);
571+
foreach (var player in Players)
572+
{
573+
if (!player.TryLoadAvatarName(out avatarName, out isFace) || string.IsNullOrWhiteSpace(avatarName))
574+
{
575+
continue;
576+
}
577+
578+
playerWithAvatar = player;
579+
return true;
580+
}
581+
582+
avatarName = null;
583+
isFace = false;
584+
playerWithAvatar = null;
585+
return false;
575586
}
576587

577588
public static bool TryAuthenticate(string username, string password, [NotNullWhen(true)] out User? user)
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
using System.Collections.Concurrent;
2+
using Intersect.Framework.IO;
3+
using Intersect.Server.Collections.Indexing;
4+
using Intersect.Server.Entities;
5+
using Microsoft.AspNetCore.Authorization;
6+
using Microsoft.AspNetCore.Mvc;
7+
using Microsoft.AspNetCore.OutputCaching;
8+
using SixLabors.ImageSharp.Formats;
9+
10+
namespace Intersect.Server.Web.Controllers;
11+
12+
[AllowAnonymous]
13+
[Route("avatar")]
14+
[ResponseCache(CacheProfileName = nameof(AvatarController))]
15+
[OutputCache(PolicyName = nameof(AvatarController))]
16+
public class AvatarController : IntersectController
17+
{
18+
#if DEBUG
19+
private const int CacheSeconds = 30;
20+
#else
21+
private const int CacheSeconds = 900;
22+
#endif
23+
24+
public static readonly CacheProfile ResponseCacheProfile = new()
25+
{
26+
Duration = CacheSeconds,
27+
};
28+
29+
private record struct CachingResult(IActionResult? Result, string? Checksum, FileInfo? FileInfo);
30+
31+
public static readonly Action<OutputCachePolicyBuilder> OutputCachePolicy =
32+
builder => builder.Expire(TimeSpan.FromSeconds(CacheSeconds)).Tag();
33+
34+
private static readonly ConcurrentDictionary<string, Task<CachingResult>> CachingTasks = new();
35+
36+
private readonly DirectoryInfo _cacheDirectoryInfo;
37+
private readonly ILogger<AvatarController> _logger;
38+
39+
public AvatarController(ILogger<AvatarController> logger)
40+
{
41+
_cacheDirectoryInfo = new DirectoryInfo(Path.Combine(Environment.CurrentDirectory, ".cache", "avatars"));
42+
_logger = logger;
43+
}
44+
45+
[HttpGet("player/{lookupKey:LookupKey}")]
46+
public async Task<IActionResult> GetPlayerAvatarAsync(LookupKey lookupKey)
47+
{
48+
DirectoryInfo assetsDirectoryInfo = new("assets/editor/resources");
49+
if (!assetsDirectoryInfo.Exists)
50+
{
51+
return InternalServerError("Error occurred while fetching the avatar");
52+
}
53+
54+
if (lookupKey.IsInvalid)
55+
{
56+
return BadRequest($"Invalid lookup key: {lookupKey}");
57+
}
58+
59+
if (!Player.TryFetch(lookupKey, out var player))
60+
{
61+
return NotFound($"Player not found for lookup key '{lookupKey}'");
62+
}
63+
64+
if (!player.TryLoadAvatarName(out var avatarName, out var isFace))
65+
{
66+
return NotFound($"Avatar not found for player '{lookupKey}'");
67+
}
68+
69+
var (result, checksum, fileInfo) = await ResolveAvatarAsync(assetsDirectoryInfo, avatarName, isFace);
70+
if (result == null)
71+
{
72+
if (fileInfo != null)
73+
{
74+
_logger.LogWarning(
75+
"Avatar '{AvatarName}' was found in {AssetsDirectory} but failed to be loaded and should be located at {AvatarFilePath}",
76+
avatarName,
77+
assetsDirectoryInfo.FullName,
78+
fileInfo.FullName
79+
);
80+
}
81+
else
82+
{
83+
_logger.LogWarning(
84+
"Avatar '{AvatarName}' was not found in {AssetsDirectory}",
85+
avatarName,
86+
assetsDirectoryInfo.FullName
87+
);
88+
}
89+
return InternalServerError($"Error loading avatar for player '{lookupKey}'");
90+
}
91+
92+
if (!string.IsNullOrWhiteSpace(checksum))
93+
{
94+
Response.Headers.ETag = checksum;
95+
}
96+
97+
return result;
98+
}
99+
100+
[HttpGet("{lookupKey:LookupKey}")]
101+
public async Task<IActionResult> GetUserAvatarAsync(LookupKey lookupKey)
102+
{
103+
DirectoryInfo assetsDirectoryInfo = new("assets/editor/resources");
104+
if (!assetsDirectoryInfo.Exists)
105+
{
106+
return InternalServerError("Error occurred while fetching the avatar");
107+
}
108+
109+
if (lookupKey.IsInvalid)
110+
{
111+
return BadRequest($"Invalid lookup key: {lookupKey}");
112+
}
113+
114+
if (!Database.PlayerData.User.TryFetch(lookupKey, out var user))
115+
{
116+
return NotFound($"User not found for lookup key '{lookupKey}'");
117+
}
118+
119+
if (!user.TryLoadAvatarName(out _, out var avatarName, out var isFace))
120+
{
121+
return NotFound($"Avatar not found for user '{lookupKey}'");
122+
}
123+
124+
var (result, checksum, fileInfo) = await ResolveAvatarAsync(assetsDirectoryInfo, avatarName, isFace);
125+
if (result == null)
126+
{
127+
if (fileInfo != null)
128+
{
129+
_logger.LogWarning(
130+
"Avatar '{AvatarName}' was found in {AssetsDirectory} but failed to be loaded and should be located at {AvatarFilePath}",
131+
avatarName,
132+
assetsDirectoryInfo.FullName,
133+
fileInfo.FullName
134+
);
135+
}
136+
else
137+
{
138+
_logger.LogWarning(
139+
"Avatar '{AvatarName}' was not found in {AssetsDirectory}",
140+
avatarName,
141+
assetsDirectoryInfo.FullName
142+
);
143+
}
144+
return InternalServerError($"Error loading avatar for user '{lookupKey}'");
145+
}
146+
147+
if (!string.IsNullOrWhiteSpace(checksum))
148+
{
149+
Response.Headers.ETag = checksum;
150+
}
151+
152+
return result;
153+
}
154+
155+
private async Task<CachingResult> ResolveAvatarAsync(
156+
DirectoryInfo assetsDirectoryInfo,
157+
string avatarName,
158+
bool isFace
159+
)
160+
{
161+
var directoryInfos = isFace
162+
? assetsDirectoryInfo.EnumerateDirectories("faces")
163+
: assetsDirectoryInfo.EnumerateDirectories("entities");
164+
165+
var fileInfos = directoryInfos.SelectMany(di => di.EnumerateFiles()).Where(
166+
f => string.Equals(f.Name, avatarName, StringComparison.OrdinalIgnoreCase)
167+
);
168+
169+
var avatarFileInfo = fileInfos.FirstOrDefault();
170+
171+
if (avatarFileInfo == default)
172+
{
173+
return default;
174+
}
175+
176+
if (isFace)
177+
{
178+
_ = avatarFileInfo.TryComputeChecksum(out var checksum);
179+
return new CachingResult(
180+
new PhysicalFileResult(avatarFileInfo.FullName, "image/png"),
181+
checksum,
182+
avatarFileInfo
183+
);
184+
}
185+
186+
var relativeName = Path.GetRelativePath(assetsDirectoryInfo.FullName, avatarFileInfo.FullName);
187+
FileInfo cacheFileInfo = new(Path.Combine(_cacheDirectoryInfo.FullName, relativeName));
188+
189+
var taskKey = cacheFileInfo.FullName;
190+
Task<CachingResult> task;
191+
lock (CachingTasks)
192+
{
193+
task = CachingTasks.GetOrAdd(
194+
taskKey,
195+
async (_, args) =>
196+
{
197+
string? checksum;
198+
199+
var result = new PhysicalFileResult(args.cacheFileInfo.FullName, "image/png");
200+
if (args.cacheFileInfo.Exists)
201+
{
202+
args.cacheFileInfo.TryComputeChecksum(out checksum);
203+
return new CachingResult(result, checksum, args.cacheFileInfo);
204+
}
205+
206+
var cacheParentDirectoryInfo = args.cacheFileInfo.Directory;
207+
208+
if (cacheParentDirectoryInfo is { Exists: false })
209+
{
210+
cacheParentDirectoryInfo.Create();
211+
}
212+
213+
using var avatarImage = await Image.LoadAsync(new DecoderOptions(), args.avatarFileInfo.FullName);
214+
var spritesOptions = Options.Instance.Sprites;
215+
var horizontalFrames = spritesOptions.IdleFrames;
216+
var verticalFrames = spritesOptions.Directions;
217+
218+
var minSize = Math.Min(avatarImage.Width / horizontalFrames, avatarImage.Height / verticalFrames);
219+
avatarImage.Mutate(i => i.Crop(minSize, minSize));
220+
await avatarImage.SaveAsync(args.cacheFileInfo.FullName);
221+
222+
args.cacheFileInfo.TryComputeChecksum(out checksum);
223+
return new CachingResult(result, checksum, args.cacheFileInfo);
224+
},
225+
(avatarFileInfo, cacheFileInfo)
226+
);
227+
}
228+
229+
var result = await task;
230+
231+
// ReSharper disable once InconsistentlySynchronizedField
232+
_ = CachingTasks.TryRemove(taskKey, out _);
233+
234+
return result;
235+
}
236+
}

0 commit comments

Comments
 (0)