Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Services/Assets/AssetStorageService.cs
T
ajp_anton 096c6be8c7 Release 0.3.4: Jellyfin 12 stable support and audit fixes
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.
2026-09-09 01:18:43 +00:00

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"
};
}