631 lines
26 KiB
C#
631 lines
26 KiB
C#
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.Extensions.Caching.Memory;
|
||
using Shared;
|
||
using Shared.Engine;
|
||
using Shared.Models;
|
||
using System;
|
||
using System.Buffers;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Diagnostics;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net;
|
||
using System.Net.Http;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
|
||
namespace Lampac.Engine.Middlewares
|
||
{
|
||
public class ProxyImg
|
||
{
|
||
#region ProxyImg
|
||
static readonly Regex rexPath = new Regex("/proxyimg([^/]+)?/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||
static readonly Regex rexRsize = new Regex("/proxyimg:([0-9]+):([0-9]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||
|
||
static readonly FileSystemWatcher fileWatcher;
|
||
|
||
static readonly ConcurrentDictionary<string, int> cacheFiles = new();
|
||
|
||
public static int Stat_ContCacheFiles => cacheFiles.IsEmpty ? 0 : cacheFiles.Count;
|
||
|
||
static readonly Timer cleanupTimer;
|
||
|
||
static ProxyImg()
|
||
{
|
||
Directory.CreateDirectory("cache/img");
|
||
|
||
if (AppInit.conf.multiaccess == false)
|
||
return;
|
||
|
||
foreach (string path in Directory.EnumerateFiles("cache/img", "*"))
|
||
{
|
||
using (var handle = File.OpenHandle(path))
|
||
cacheFiles.TryAdd(Path.GetFileName(path), (int)RandomAccess.GetLength(handle));
|
||
}
|
||
|
||
fileWatcher = new FileSystemWatcher
|
||
{
|
||
Path = "cache/img",
|
||
NotifyFilter = NotifyFilters.FileName,
|
||
EnableRaisingEvents = true
|
||
};
|
||
|
||
//fileWatcher.Created += (s, e) =>
|
||
//{
|
||
// cacheFiles.TryAdd(e.Name, 0);
|
||
//};
|
||
|
||
fileWatcher.Deleted += (s, e) => { cacheFiles.TryRemove(e.Name, out _); };
|
||
|
||
cleanupTimer = new Timer(cleanup, null, TimeSpan.FromMinutes(20), TimeSpan.FromMinutes(20));
|
||
}
|
||
|
||
static void cleanup(object state)
|
||
{
|
||
try
|
||
{
|
||
foreach (string md5fileName in cacheFiles.Keys)
|
||
{
|
||
if (!File.Exists(Path.Combine("cache", "img", md5fileName)))
|
||
cacheFiles.TryRemove(md5fileName, out _);
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
public ProxyImg(RequestDelegate next) { }
|
||
#endregion
|
||
|
||
async public Task InvokeAsync(HttpContext httpContext, IMemoryCache memoryCache)
|
||
{
|
||
using (var ctsHttp = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted))
|
||
{
|
||
ctsHttp.CancelAfter(TimeSpan.FromSeconds(30));
|
||
|
||
var requestInfo = httpContext.Features.Get<RequestModel>();
|
||
|
||
var init = AppInit.conf.serverproxy.image;
|
||
bool cacheimg = init.cache && AppInit.conf.mikrotik == false;
|
||
|
||
string servPath = rexPath.Replace(httpContext.Request.Path.Value, "");
|
||
string href = servPath + httpContext.Request.QueryString.Value;
|
||
|
||
#region Проверки
|
||
if (servPath.Contains("image.tmdb.org", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
httpContext.Response.Redirect($"/tmdb/img/{Regex.Replace(href.Replace("://", ":/_/").Replace("//", "/").Replace(":/_/", "://"), "^https?://[^/]+/", "")}");
|
||
return;
|
||
}
|
||
|
||
var decryptLink = ProxyLink.Decrypt(servPath, requestInfo.IP);
|
||
|
||
if (AppInit.conf.serverproxy.encrypt || decryptLink?.uri != null)
|
||
{
|
||
href = decryptLink?.uri;
|
||
}
|
||
else
|
||
{
|
||
if (!AppInit.conf.serverproxy.enable)
|
||
{
|
||
httpContext.Response.StatusCode = 403;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(href) || !href.StartsWith("http"))
|
||
{
|
||
httpContext.Response.StatusCode = 404;
|
||
return;
|
||
}
|
||
#endregion
|
||
|
||
if (AppInit.conf.serverproxy.showOrigUri)
|
||
httpContext.Response.Headers["PX-Orig"] = href;
|
||
|
||
#region width / height
|
||
int width = 0;
|
||
int height = 0;
|
||
|
||
if (httpContext.Request.Path.Value.StartsWith("/proxyimg:", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (!cacheimg)
|
||
cacheimg = init.cache_rsize;
|
||
|
||
var gimg = rexRsize.Match(httpContext.Request.Path.Value).Groups;
|
||
width = int.Parse(gimg[1].Value);
|
||
height = int.Parse(gimg[2].Value);
|
||
}
|
||
#endregion
|
||
|
||
string md5key = CrypTo.md5($"{href}:{width}:{height}");
|
||
|
||
if (InvkEvent.IsProxyImgMd5key())
|
||
InvkEvent.ProxyImgMd5key(ref md5key, httpContext, requestInfo, decryptLink, href, width, height);
|
||
|
||
string outFile = Path.Combine("cache", "img", md5key);
|
||
|
||
string url_reserve = null;
|
||
if (href.Contains(" or "))
|
||
{
|
||
var urls = href.Split(" or ");
|
||
href = urls[0];
|
||
url_reserve = urls[1];
|
||
}
|
||
|
||
string contentType = href.Contains(".png", StringComparison.OrdinalIgnoreCase)
|
||
? "image/png"
|
||
: href.Contains(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : "image/jpeg";
|
||
|
||
if (width > 0 || height > 0)
|
||
contentType = href.Contains(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : "image/jpeg";
|
||
|
||
#region cacheFiles
|
||
if (cacheimg)
|
||
{
|
||
if (cacheFiles.ContainsKey(md5key) || (AppInit.conf.multiaccess == false && File.Exists(outFile)))
|
||
{
|
||
httpContext.Response.Headers["X-Cache-Status"] = "HIT";
|
||
httpContext.Response.ContentType = contentType;
|
||
|
||
if (AppInit.conf.serverproxy.responseContentLength && cacheFiles.ContainsKey(md5key))
|
||
httpContext.Response.ContentLength = cacheFiles[md5key];
|
||
|
||
await httpContext.Response.SendFileAsync(outFile, ctsHttp.Token).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
}
|
||
#endregion
|
||
|
||
var semaphore = cacheimg ? new SemaphorManager(outFile, ctsHttp.Token) : null;
|
||
|
||
try
|
||
{
|
||
string memKeyErrorDownload = $"ProxyImg:ErrorDownload:{href}";
|
||
if (memoryCache.TryGetValue(memKeyErrorDownload, out _))
|
||
{
|
||
httpContext.Response.Redirect(href);
|
||
return;
|
||
}
|
||
|
||
if (semaphore != null)
|
||
await semaphore.WaitAsync().ConfigureAwait(false);
|
||
|
||
#region cacheFiles
|
||
if (cacheimg)
|
||
{
|
||
if (cacheFiles.ContainsKey(md5key) || (AppInit.conf.multiaccess == false && File.Exists(outFile)))
|
||
{
|
||
httpContext.Response.Headers["X-Cache-Status"] = "HIT";
|
||
httpContext.Response.ContentType = contentType;
|
||
|
||
if (AppInit.conf.serverproxy.responseContentLength && cacheFiles.ContainsKey(md5key))
|
||
httpContext.Response.ContentLength = cacheFiles[md5key];
|
||
|
||
semaphore?.Release();
|
||
await httpContext.Response.SendFileAsync(outFile, ctsHttp.Token).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
}
|
||
#endregion
|
||
|
||
httpContext.Response.Headers["X-Cache-Status"] = cacheimg ? "MISS" : "bypass";
|
||
|
||
#region proxyManager
|
||
ProxyManager proxyManager = null;
|
||
|
||
if (decryptLink?.plugin == "posterapi" && AppInit.conf.posterApi.useproxy)
|
||
proxyManager = new ProxyManager("posterapi", AppInit.conf.posterApi);
|
||
|
||
if (proxyManager == null && init.useproxy)
|
||
proxyManager = new ProxyManager("proxyimg", init);
|
||
|
||
WebProxy proxy = proxyManager?.Get();
|
||
#endregion
|
||
|
||
if (width == 0 && height == 0)
|
||
{
|
||
#region bypass
|
||
bypass_reset:
|
||
var handler = Http.Handler(href, proxy);
|
||
|
||
var client = FrendlyHttp.MessageClient("proxyimg", handler);
|
||
|
||
var req = new HttpRequestMessage(HttpMethod.Get, href)
|
||
{
|
||
Version = HttpVersion.Version11
|
||
};
|
||
|
||
bool useDefaultHeaders = true;
|
||
if (decryptLink?.headers != null && decryptLink.headers.Count > 0 && decryptLink.headers.FirstOrDefault(i => i.name.ToLower() == "user-agent") != null)
|
||
useDefaultHeaders = false;
|
||
|
||
Http.DefaultRequestHeaders(href, req, null, null, decryptLink?.headers, useDefaultHeaders: useDefaultHeaders);
|
||
|
||
using (HttpResponseMessage response = await client.SendAsync(req, ctsHttp.Token).ConfigureAwait(false))
|
||
{
|
||
if (response.StatusCode != HttpStatusCode.OK)
|
||
{
|
||
if (url_reserve != null)
|
||
{
|
||
href = url_reserve;
|
||
url_reserve = null;
|
||
goto bypass_reset;
|
||
}
|
||
|
||
if (cacheimg)
|
||
memoryCache.Set(memKeyErrorDownload, 0, DateTime.Now.AddSeconds(5));
|
||
|
||
proxyManager?.Refresh();
|
||
httpContext.Response.Redirect(href);
|
||
return;
|
||
}
|
||
|
||
httpContext.Response.StatusCode = (int)response.StatusCode;
|
||
|
||
if (response.Content.Headers.TryGetValues("Content-Type", out var contype))
|
||
httpContext.Response.ContentType = contype?.FirstOrDefault()?.ToLowerAndTrim() ?? contentType;
|
||
else
|
||
httpContext.Response.ContentType = contentType;
|
||
|
||
if (AppInit.conf.serverproxy.responseContentLength && response.Content?.Headers?.ContentLength > 0)
|
||
{
|
||
if (!AppInit.CompressionMimeTypes.Contains(httpContext.Response.ContentType))
|
||
httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value;
|
||
}
|
||
|
||
if (cacheimg)
|
||
{
|
||
byte[] buffer = ArrayPool<byte>.Shared.Rent(PoolInvk.rentChunk);
|
||
|
||
try
|
||
{
|
||
int cacheLength = 0;
|
||
|
||
using (var cacheStream = new FileStream(outFile, FileMode.Create, FileAccess.Write, FileShare.None, PoolInvk.bufferSize))
|
||
{
|
||
using (var responseStream = await response.Content.ReadAsStreamAsync(ctsHttp.Token).ConfigureAwait(false))
|
||
{
|
||
int bytesRead;
|
||
|
||
while ((bytesRead = await responseStream.ReadAsync(buffer, ctsHttp.Token).ConfigureAwait(false)) > 0)
|
||
{
|
||
cacheLength += bytesRead;
|
||
await cacheStream.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false);
|
||
await httpContext.Response.Body.WriteAsync(buffer, 0, bytesRead, ctsHttp.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!response.Content.Headers.ContentLength.HasValue || response.Content.Headers.ContentLength.Value == cacheLength)
|
||
{
|
||
if (AppInit.conf.multiaccess)
|
||
cacheFiles[md5key] = cacheLength;
|
||
}
|
||
else
|
||
{
|
||
File.Delete(outFile);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
File.Delete(outFile);
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
ArrayPool<byte>.Shared.Return(buffer);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
await response.Content.CopyToAsync(httpContext.Response.Body, ctsHttp.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
#endregion
|
||
}
|
||
else
|
||
{
|
||
#region rsize
|
||
httpContext.Response.ContentType = contentType;
|
||
|
||
rsize_reset:
|
||
|
||
using (var inArray = PoolInvk.msm.GetStream())
|
||
{
|
||
var result = await Download(inArray, href, ctsHttp.Token, proxy: proxy, headers: decryptLink?.headers).ConfigureAwait(false);
|
||
|
||
if (!result.success)
|
||
{
|
||
if (url_reserve != null)
|
||
{
|
||
href = url_reserve;
|
||
url_reserve = null;
|
||
goto rsize_reset;
|
||
}
|
||
|
||
if (cacheimg)
|
||
memoryCache.Set(memKeyErrorDownload, 0, DateTime.Now.AddSeconds(5));
|
||
|
||
proxyManager?.Refresh();
|
||
httpContext.Response.Redirect(href);
|
||
return;
|
||
}
|
||
|
||
using (var outArray = PoolInvk.msm.GetStream())
|
||
{
|
||
bool successConvert = false;
|
||
|
||
if ((result.contentType ?? contentType) is "image/png" or "image/webp" or "image/jpeg")
|
||
{
|
||
if (AppInit.conf.imagelibrary == "NetVips")
|
||
{
|
||
successConvert = NetVipsImage(href, inArray, outArray, width, height);
|
||
}
|
||
else if (AppInit.conf.imagelibrary == "ImageMagick" && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||
{
|
||
successConvert = await ImageMagick(inArray, outArray, width, height, cacheimg ? outFile : null);
|
||
|
||
if (cacheimg)
|
||
{
|
||
if (successConvert)
|
||
{
|
||
inArray.Dispose();
|
||
outArray.Dispose();
|
||
semaphore?.Release();
|
||
|
||
if (AppInit.conf.multiaccess)
|
||
{
|
||
using (var handle = File.OpenHandle(outFile))
|
||
cacheFiles[md5key] = (int)RandomAccess.GetLength(handle);
|
||
}
|
||
|
||
await httpContext.Response.SendFileAsync(outFile, ctsHttp.Token).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
|
||
proxyManager?.Refresh();
|
||
httpContext.Response.Redirect(href);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
var resultArray = successConvert ? outArray : inArray;
|
||
resultArray.Position = 0;
|
||
|
||
if (successConvert)
|
||
proxyManager?.Success();
|
||
|
||
if (AppInit.conf.serverproxy.responseContentLength)
|
||
httpContext.Response.ContentLength = resultArray.Length;
|
||
|
||
try
|
||
{
|
||
await resultArray.CopyToAsync(httpContext.Response.Body, PoolInvk.bufferSize, ctsHttp.Token).ConfigureAwait(false);
|
||
|
||
if (cacheimg)
|
||
await TrySaveCache(resultArray, outFile, md5key);
|
||
}
|
||
catch
|
||
{
|
||
if (cacheimg)
|
||
await TrySaveCache(resultArray, outFile, md5key);
|
||
|
||
throw;
|
||
}
|
||
}
|
||
}
|
||
#endregion
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
if (semaphore != null)
|
||
semaphore.Release();
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
#region Download
|
||
async Task<(bool success, string contentType)> Download(Stream ms, string url, CancellationToken cancellationToken, List<HeadersModel> headers = null, WebProxy proxy = null)
|
||
{
|
||
try
|
||
{
|
||
var handler = Http.Handler(url, proxy);
|
||
|
||
var client = FrendlyHttp.MessageClient("base", handler);
|
||
|
||
var req = new HttpRequestMessage(HttpMethod.Get, url)
|
||
{
|
||
Version = HttpVersion.Version11
|
||
};
|
||
|
||
|
||
bool useDefaultHeaders = true;
|
||
if (headers != null && headers.Count > 0 && headers.FirstOrDefault(i => i.name.ToLowerInvariant() == "user-agent") != null)
|
||
useDefaultHeaders = false;
|
||
|
||
Http.DefaultRequestHeaders(url, req, null, null, headers, useDefaultHeaders: useDefaultHeaders);
|
||
|
||
using (HttpResponseMessage response = await client.SendAsync(req, cancellationToken).ConfigureAwait(false))
|
||
{
|
||
if (response.StatusCode != HttpStatusCode.OK)
|
||
return default;
|
||
|
||
using (HttpContent content = response.Content)
|
||
{
|
||
await content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
|
||
if (ms.Length == 0 || 1000 > ms.Length)
|
||
return default;
|
||
|
||
if (content.Headers != null)
|
||
{
|
||
if (content.Headers.ContentLength.HasValue && content.Headers.ContentLength != ms.Length)
|
||
return default;
|
||
|
||
response.Content.Headers.TryGetValues("Content-Type", out var _contentType);
|
||
|
||
ms.Position = 0;
|
||
return (true, _contentType?.FirstOrDefault()?.ToLower());
|
||
}
|
||
|
||
ms.Position = 0;
|
||
return (true, null);
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return default;
|
||
}
|
||
}
|
||
#endregion
|
||
|
||
#region TrySaveCache
|
||
async Task TrySaveCache(Stream ms, string outFile, string md5key)
|
||
{
|
||
try
|
||
{
|
||
ms.Position = 0;
|
||
|
||
using (var streamFile = File.OpenWrite(outFile))
|
||
await ms.CopyToAsync(streamFile, PoolInvk.bufferSize);
|
||
|
||
if (AppInit.conf.multiaccess)
|
||
cacheFiles[md5key] = (int)ms.Length;
|
||
}
|
||
catch { File.Delete(outFile); }
|
||
}
|
||
#endregion
|
||
|
||
#region NetVipsImage
|
||
static bool _initNetVips = false;
|
||
|
||
private bool NetVipsImage(string href, Stream inArray, Stream outArray, int width, int height)
|
||
{
|
||
if (!_initNetVips)
|
||
{
|
||
_initNetVips = true;
|
||
NetVips.Cache.Max = 0; // 0 операций в кэше
|
||
NetVips.Cache.MaxMem = 0; // 0 байт памяти под кэш
|
||
NetVips.Cache.MaxFiles = 0; // 0 файлов в файловом кэше
|
||
NetVips.Cache.Trace = false;
|
||
}
|
||
|
||
try
|
||
{
|
||
using (var image = NetVips.Image.NewFromStream(inArray, access: NetVips.Enums.Access.Sequential))
|
||
{
|
||
if ((width != 0 && image.Width > width) || (height != 0 && image.Height > height))
|
||
{
|
||
using (var res = image.ThumbnailImage(width == 0 ? image.Width : width, height == 0 ? image.Height : height, crop: NetVips.Enums.Interesting.None))
|
||
{
|
||
if (href.Contains(".png", StringComparison.OrdinalIgnoreCase))
|
||
res.PngsaveStream(outArray);
|
||
else
|
||
res.JpegsaveStream(outArray);
|
||
|
||
if (outArray.Length > 1000)
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
return false;
|
||
}
|
||
#endregion
|
||
|
||
#region ImageMagick
|
||
static string imaGikPath = null;
|
||
|
||
/// <summary>
|
||
/// apt install -y imagemagick libpng-dev libjpeg-dev libwebp-dev
|
||
/// </summary>
|
||
async static Task<bool> ImageMagick(Stream inArray, Stream outArray, int width, int height, string outputFilePath)
|
||
{
|
||
if (imaGikPath == null)
|
||
imaGikPath = File.Exists("/usr/bin/magick") ? "magick" : "convert";
|
||
|
||
string inputFilePath = getTempFileName();
|
||
|
||
bool outFileIsTemp = false;
|
||
if (outputFilePath == null)
|
||
{
|
||
outFileIsTemp = true;
|
||
outputFilePath = getTempFileName();
|
||
}
|
||
|
||
try
|
||
{
|
||
inArray.Position = 0;
|
||
|
||
using (var streamFile = File.OpenWrite(inputFilePath))
|
||
await inArray.CopyToAsync(streamFile, PoolInvk.bufferSize);
|
||
|
||
string argsize = width > 0 && height > 0 ? $"{width}x{height}" : width > 0 ? $"{width}x" : $"x{height}";
|
||
|
||
using (Process process = new Process())
|
||
{
|
||
process.StartInfo.FileName = imaGikPath;
|
||
process.StartInfo.Arguments = $"\"{inputFilePath}\" -resize {argsize} \"{outputFilePath}\"";
|
||
process.StartInfo.RedirectStandardOutput = true;
|
||
process.StartInfo.RedirectStandardError = true;
|
||
process.StartInfo.UseShellExecute = false;
|
||
process.StartInfo.CreateNoWindow = true;
|
||
|
||
process.Start();
|
||
await process.WaitForExitAsync();
|
||
|
||
if (process.ExitCode != 0)
|
||
return false;
|
||
}
|
||
|
||
if (outFileIsTemp)
|
||
{
|
||
using (var streamFile = File.OpenRead(outputFilePath))
|
||
await streamFile.CopyToAsync(outArray, PoolInvk.bufferSize);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(inputFilePath))
|
||
File.Delete(inputFilePath);
|
||
|
||
if (outFileIsTemp && File.Exists(outputFilePath))
|
||
File.Delete(outputFilePath);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
|
||
static bool? shm = null;
|
||
|
||
static string getTempFileName()
|
||
{
|
||
if (shm == null)
|
||
shm = Directory.Exists("/dev/shm");
|
||
|
||
if (shm == true)
|
||
return $"/dev/shm/{CrypTo.md5(DateTime.Now.ToFileTimeUtc().ToString())}";
|
||
|
||
return Path.GetTempFileName();
|
||
}
|
||
#endregion
|
||
}
|
||
}
|