Skip to content

Commit

Permalink
Re-organize folder structure and add size-limited cache
Browse files Browse the repository at this point in the history
  • Loading branch information
JonathanBout committed Nov 26, 2024
1 parent c207a68 commit 06d4dfe
Show file tree
Hide file tree
Showing 15 changed files with 229 additions and 45 deletions.
3 changes: 2 additions & 1 deletion SimpleCDN.Tests/ByteCountFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net.Sockets;
using SimpleCDN.Helpers;
using System.Net.Sockets;

namespace SimpleCDN.Tests
{
Expand Down
3 changes: 2 additions & 1 deletion SimpleCDN.Tests/CDNLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using SimpleCDN.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
Expand Down
7 changes: 0 additions & 7 deletions SimpleCDN/CDNConfiguration.cs

This file was deleted.

58 changes: 42 additions & 16 deletions SimpleCDN/CDNLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using SimpleCDN.Cache;
using SimpleCDN.Configuration;
using SimpleCDN.Helpers;
using System.IO.Compression;
using System.Net.Mime;
using System.Text;

namespace SimpleCDN
{
public record CDNFile(byte[] Content, string MediaType, DateTimeOffset LastModified);
public record CDNFile(byte[] Content, string MediaType, DateTimeOffset LastModified, bool IsCompressed);
public class CDNLoader(IWebHostEnvironment environment, IOptionsMonitor<CDNConfiguration> options)
{
private readonly IWebHostEnvironment _environment = environment;

private readonly Dictionary<string, CachedFile> _cache = new(StringComparer.OrdinalIgnoreCase);
private readonly SizeLimitedCache _cache = new(options.CurrentValue.MaxMemoryCacheSize * 1000, StringComparer.OrdinalIgnoreCase);

private readonly IOptionsMonitor<CDNConfiguration> _options = options;

Expand Down Expand Up @@ -41,7 +45,7 @@ public class CDNLoader(IWebHostEnvironment environment, IOptionsMonitor<CDNConfi
return LoadFile(fullPath, "/" + path);
}

return new CDNFile(cachedFile.Content, cachedFile.MimeType.ToContentTypeString(), cachedFile.LastModified);
return new CDNFile(cachedFile.Content, cachedFile.MimeType.ToContentTypeString(), cachedFile.LastModified, cachedFile.IsCompressed);
}

return LoadFile(fullPath, "/" + path);
Expand All @@ -59,7 +63,7 @@ public class CDNLoader(IWebHostEnvironment environment, IOptionsMonitor<CDNConfi

if (_cache.TryGetValue(info.PhysicalPath, out CachedFile? cached) && cached is not null && cached.LastModified >= info.LastModified)
{
return new CDNFile(cached.Content, cached.MimeType.ToContentTypeString(), cached.LastModified);
return new CDNFile(cached.Content, cached.MimeType.ToContentTypeString(), cached.LastModified, cached.IsCompressed);
}

if (!info.Exists)
Expand All @@ -77,10 +81,11 @@ public class CDNLoader(IWebHostEnvironment environment, IOptionsMonitor<CDNConfi
{
Content = bytes,
LastModified = info.LastModified,
MimeType = mime
MimeType = mime,
IsCompressed = false
};

return new CDNFile(bytes, mime.ToContentTypeString(), info.LastModified);
return new CDNFile(bytes, mime.ToContentTypeString(), info.LastModified, false);
}

private CDNFile? LoadFile(string absolutePath, string rootRelativePath)
Expand All @@ -102,26 +107,36 @@ public class CDNLoader(IWebHostEnvironment environment, IOptionsMonitor<CDNConfi
{
Content = bytes,
DirectoryName = absolutePath,
LastModified = lastModified,
MimeType = MimeType.HTML
};
}

} else
{
lastModified = new DateTimeOffset(File.GetLastWriteTimeUtc(absolutePath));
}

if (content.content is null) return null;

_cache[absolutePath] = file ??= new CachedFile

file ??= new CachedFile
{
Content = content.content,
LastModified = lastModified,
MimeType = content.type
};

return new CDNFile(file.Content, file.MimeType.ToContentTypeString(), file.LastModified);
// attempt to compress the file if it's not already compressed
/*if (!file.IsCompressed)
{
var contentSpan = content.content.AsSpan();
bool compressed = GZipHelpers.TryCompress(ref contentSpan);
file.Content = contentSpan.ToArray();
file.IsCompressed = compressed;
}*/

_cache[absolutePath] = file;

return new CDNFile(file.Content, file.MimeType.ToContentTypeString(), file.LastModified, file.IsCompressed);
}


Expand All @@ -133,9 +148,9 @@ private static (MimeType type, byte[]? content) TryLoadIndex(string absolutePath

foreach (var indexFile in indexes)
{
var substring = indexFile.AsSpan()[indexFile.LastIndexOf('.')..];
var substring = indexFile.AsSpan()[(indexFile.LastIndexOf('.') + 1)..];

if (substring == "html" || substring == "htm")
if (substring.SequenceEqual("html") || substring.SequenceEqual("htm"))
{
var loaded = LoadFileFromDisk(indexFile);

Expand Down Expand Up @@ -179,7 +194,19 @@ private static (MimeType type, byte[]? content) TryLoadIndex(string absolutePath

if (rootRelativePath is not "/" and not "" && directory.Parent is DirectoryInfo parent)
{
AppendRow(index, "..", "Parent Directory", -1, parent.LastWriteTimeUtc);
var lastSlashIndex = rootRelativePath.LastIndexOf('/');

string parentRootRelativePath;

if (lastSlashIndex is < 1)
{
parentRootRelativePath = "/";
} else
{
parentRootRelativePath = rootRelativePath[..lastSlashIndex];
}

AppendRow(index, parentRootRelativePath, "Parent Directory", -1, parent.LastWriteTimeUtc);
}

foreach (var subDirectory in directory.EnumerateDirectories())
Expand Down Expand Up @@ -226,13 +253,12 @@ private static (MimeType type, byte[]? content) LoadFileFromDisk(string absolute

relativePath = relativePath.TrimStart('/');



var combined = Path.Combine(DataRoot, relativePath);

var resolved = Path.GetFullPath(combined);

// if the path contained for example ../file, we obviously don't allow access
// if the path contained for example ../file and it resolves to a parent or sibling directory
// of the data root, we obviously don't allow access
if (!resolved.StartsWith(DataRoot))
{
return null;
Expand Down
3 changes: 2 additions & 1 deletion SimpleCDN/CachedFile.cs → SimpleCDN/Cache/CachedFile.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
namespace SimpleCDN
namespace SimpleCDN.Cache
{
class CachedFile
{
public bool IsCompressed { get; set; } = false;
public required byte[] Content { get; set; }
public required MimeType MimeType { get; set; }
public virtual DateTimeOffset LastModified { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace SimpleCDN
namespace SimpleCDN.Cache
{
class CachedIndexFile : CachedFile
{
Expand Down
87 changes: 87 additions & 0 deletions SimpleCDN/Cache/SizeLimitedCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using SimpleCDN.Helpers;
using System.Collections;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;

namespace SimpleCDN.Cache
{
/// <summary>
/// A cache that limits the total size of the stored values. When the size of the cache exceeds the specified limit, the oldest (least recently accessed) values are removed.
/// </summary>
/// <param name="maxSize">The maximum size of the cache, in bytes</param>
/// <param name="comparer">The string comparer to use for the internal dictionary</param>
internal class SizeLimitedCache(long maxSize, IEqualityComparer<string>? comparer)
{
public SizeLimitedCache(long maxSize) : this(maxSize, null) { }

private readonly Dictionary<string, ValueWrapper> _dictionary = new(comparer);
private readonly long _maxSize = maxSize;

public CachedFile this[string key]
{
get => GetValue(key);
set => SetValue(key, value);
}

public int Count => _dictionary.Count;

public long Size => _dictionary.Values.Sum(wrapper => wrapper.Size);

public bool TryGetValue(string key, [NotNullWhen(true)] out CachedFile? value)
{
if (_dictionary.TryGetValue(key, out ValueWrapper? valueWrapper))
{
value = valueWrapper.Value;
return value is not null;
}

value = default;

return false;
}

private void SetValue(string key, CachedFile value)
{
_dictionary[key] = new ValueWrapper(value);

var byOldest = _dictionary.OrderBy(p => p.Value.AccessedAt).AsEnumerable();

while (Size > _maxSize)
{
(var oldest, byOldest) = byOldest.RemoveFirst();

_dictionary.Remove(oldest.Key);
}
}

private CachedFile GetValue(string key)
{
if (_dictionary.TryGetValue(key, out var wrapper))
return wrapper.Value;
throw new KeyNotFoundException();
}

class ValueWrapper(CachedFile value)
{
private CachedFile _value = value;
public CachedFile Value
{
get
{
AccessedAt = Stopwatch.GetTimestamp();
return _value;
}
set
{
_value = value;
AccessedAt = Stopwatch.GetTimestamp();
}
}
public int Size => _value.Content.Length;
public long AccessedAt { get; private set; } = Stopwatch.GetTimestamp();

public static implicit operator CachedFile(ValueWrapper wrapper) => wrapper.Value;
}
}
}
14 changes: 14 additions & 0 deletions SimpleCDN/Configuration/CDNConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace SimpleCDN.Configuration
{
public class CDNConfiguration
{
/// <summary>
/// The data root path
/// </summary>
public string? DataRoot { get; set; }
/// <summary>
/// The maximum size of the in-memory cache in kB
/// </summary>
public uint MaxMemoryCacheSize { get; set; } = 500;
}
}
28 changes: 21 additions & 7 deletions SimpleCDN/Extensions.cs → SimpleCDN/Helpers/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,41 +1,55 @@
using System.Numerics;

namespace SimpleCDN
namespace SimpleCDN.Helpers
{
public static class Extensions
{
static readonly string[] sizeNames = ["", "k", "M", "G", "T"];

public static string FormatByteCount(this long number)
{
bool isNegative = false;
var isNegative = false;
if (number < 0)
{
isNegative = true;
number = -number;
}

int sizeNameIndex = 0;
var sizeNameIndex = 0;

double result = number;

for (; sizeNameIndex < sizeNames.Length - 1; sizeNameIndex++)
{
var div = result / 1000;
if (div < 1)
{
break;
}

result = div;
}

if (isNegative)
{
result = -result;
}

return $"{result:0.##}{sizeNames[sizeNameIndex]}B";
}

public static (T left, IEnumerable<T> right) RemoveFirst<T>(this IEnumerable<T> source)
{
var enumerator = source.GetEnumerator();

if (!enumerator.MoveNext())
ArgumentOutOfRangeException.ThrowIfLessThan(0, 1, nameof(source));

return (enumerator.Current, RestEnumerator(enumerator));

static IEnumerable<T> RestEnumerator(IEnumerator<T> enumerator)
{
while (enumerator.MoveNext())
{
yield return enumerator.Current;
}
}
}
}
}
29 changes: 29 additions & 0 deletions SimpleCDN/Helpers/GZipHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.IO.Compression;

namespace SimpleCDN.Helpers
{
public class GZipHelpers
{
/// <summary>
/// Compresses the data using GZip.
/// </summary>
/// <param name="data">The data to compress</param>
/// <returns><see langword="false"/> if the compressed data is not smaller than the original data. Otherwise, <see langword="true"/></returns>
public static bool TryCompress(ref Span<byte> data)
{
using var memoryStream = new MemoryStream();
using var gzipStream = new GZipStream(memoryStream, CompressionMode.Compress);
gzipStream.Write(data);
gzipStream.Flush();

if (memoryStream.Length >= data.Length)
return false;

var read = memoryStream.Read(data);

data = data[..read];

return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Net.Mime;

namespace SimpleCDN
namespace SimpleCDN.Helpers
{
internal static class MimeTypeHelpers
{
Expand Down
Loading

0 comments on commit 06d4dfe

Please sign in to comment.