Harden proxy permissions and cache behavior, streamline metadata fetching, remove obsolete rules, and validate the stable SDK with expanded regression coverage. Document backup format changes.
131 lines
5.1 KiB
C#
131 lines
5.1 KiB
C#
using System.Security.Cryptography;
|
|
using Jellyfin.Plugin.Multilang.Data;
|
|
|
|
namespace Jellyfin.Plugin.Multilang.Services.Assets;
|
|
|
|
public sealed class AssetStorageService
|
|
{
|
|
private const long MaxAssetBytes = 50L * 1024L * 1024L;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly TranslationStore _store;
|
|
|
|
public AssetStorageService(IHttpClientFactory httpClientFactory, TranslationStore store)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_store = store;
|
|
}
|
|
|
|
public async Task<string?> StoreAsync(
|
|
string itemId,
|
|
string sourceUrl,
|
|
bool localStorage,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sourceUrl))
|
|
return null;
|
|
|
|
if (!localStorage)
|
|
return sourceUrl;
|
|
|
|
var relative = BuildRelativePath(itemId, sourceUrl);
|
|
var expectedUrl = TranslationStore.LocalAssetUrlPrefix + relative.Replace('\\', '/');
|
|
var destination = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, relative));
|
|
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
|
|
if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("Resolved asset path escaped the Multilang assets directory.");
|
|
|
|
if (File.Exists(destination))
|
|
return expectedUrl;
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
await DownloadAsync(sourceUrl, destination, cancellationToken).ConfigureAwait(false);
|
|
return expectedUrl;
|
|
}
|
|
|
|
public bool TryResolveLocalAsset(string relativePath, out string fullPath, out string contentType)
|
|
{
|
|
fullPath = string.Empty;
|
|
contentType = "application/octet-stream";
|
|
var clean = (relativePath ?? string.Empty).TrimStart('/', '\\');
|
|
if (clean.Length == 0)
|
|
return false;
|
|
|
|
var candidate = Path.GetFullPath(Path.Combine(_store.AssetsDirectory, clean));
|
|
var root = Path.GetFullPath(_store.AssetsDirectory) + Path.DirectorySeparatorChar;
|
|
if (!candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
|
|
return false;
|
|
|
|
fullPath = candidate;
|
|
contentType = ContentTypeFor(Path.GetExtension(candidate));
|
|
return true;
|
|
}
|
|
|
|
private async Task DownloadAsync(string sourceUrl, string destination, CancellationToken cancellationToken)
|
|
{
|
|
var http = _httpClientFactory.CreateClient();
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUrl);
|
|
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
if (response.Content.Headers.ContentLength is > MaxAssetBytes)
|
|
throw new InvalidOperationException("Provider asset is larger than the allowed download size.");
|
|
|
|
var temp = destination + ".tmp";
|
|
var total = 0L;
|
|
await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
|
await using (var output = File.Create(temp))
|
|
{
|
|
var buffer = new byte[128 * 1024];
|
|
while (true)
|
|
{
|
|
var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
|
if (read == 0)
|
|
break;
|
|
|
|
total += read;
|
|
if (total > MaxAssetBytes)
|
|
throw new InvalidOperationException("Provider asset is larger than the allowed download size.");
|
|
|
|
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
File.Move(temp, destination, overwrite: true);
|
|
}
|
|
|
|
private static string BuildRelativePath(string itemId, string sourceUrl)
|
|
{
|
|
var extension = ExtensionFromUrl(sourceUrl);
|
|
var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(sourceUrl))).ToLowerInvariant()[..16];
|
|
return Path.Combine(SafeSegment(itemId), hash + extension);
|
|
}
|
|
|
|
private static string SafeSegment(string value)
|
|
{
|
|
var chars = value
|
|
.Select(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' ? ch : '_')
|
|
.ToArray();
|
|
var result = new string(chars).Trim('_');
|
|
return result.Length == 0 ? "unknown" : result;
|
|
}
|
|
|
|
private static string ExtensionFromUrl(string sourceUrl)
|
|
{
|
|
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out var uri))
|
|
return ".bin";
|
|
|
|
var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant();
|
|
return ext is ".jpg" or ".jpeg" or ".png" or ".webp" ? ext : ".bin";
|
|
}
|
|
|
|
private static string ContentTypeFor(string extension)
|
|
=> extension.ToLowerInvariant() switch
|
|
{
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".png" => "image/png",
|
|
".webp" => "image/webp",
|
|
".gif" => "image/gif",
|
|
_ => "application/octet-stream"
|
|
};
|
|
}
|