feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace dy.net.Tests.TestInfrastructure;
|
||||
|
||||
internal sealed record RecordedWebDavRequest(
|
||||
string Method,
|
||||
string RawUri,
|
||||
string DecodedPath,
|
||||
string Authorization,
|
||||
string Range,
|
||||
string Destination,
|
||||
string CacheControl);
|
||||
|
||||
internal sealed class InMemoryWebDavHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _directories = new(StringComparer.Ordinal) { "/" };
|
||||
private readonly Dictionary<string, int> _staleMetadataReads = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _staleAllReads = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, StaleLengthState> _staleLengthReads = new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryWebDavHandler(string userName, string password)
|
||||
{
|
||||
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"));
|
||||
ExpectedAuthorization = "Basic " + token;
|
||||
}
|
||||
|
||||
public string ExpectedAuthorization { get; }
|
||||
public bool SupportsRange { get; set; } = true;
|
||||
public bool IncludeContentRange { get; set; } = true;
|
||||
public HttpStatusCode ExistingDirectoryStatusCode { get; set; } = HttpStatusCode.MethodNotAllowed;
|
||||
public bool FailNextMove { get; set; }
|
||||
public int StaleMetadataReadsAfterMove { get; set; }
|
||||
public int StaleAllReadsAfterMove { get; set; }
|
||||
public int StaleLengthReadsAfterMove { get; set; }
|
||||
public List<RecordedWebDavRequest> Requests { get; } = new();
|
||||
public IReadOnlyDictionary<string, byte[]> Files => _files;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = DecodePath(request.RequestUri);
|
||||
var authorization = request.Headers.Authorization?.ToString();
|
||||
var destination = request.Headers.TryGetValues("Destination", out var destinationValues)
|
||||
? destinationValues.Single()
|
||||
: null;
|
||||
Requests.Add(new RecordedWebDavRequest(
|
||||
request.Method.Method,
|
||||
request.RequestUri.AbsoluteUri,
|
||||
path,
|
||||
authorization,
|
||||
request.Headers.Range?.ToString(),
|
||||
destination,
|
||||
request.Headers.CacheControl?.ToString()));
|
||||
|
||||
if (authorization != ExpectedAuthorization) return Response(HttpStatusCode.Unauthorized);
|
||||
|
||||
return request.Method.Method switch
|
||||
{
|
||||
"MKCOL" => CreateDirectory(path),
|
||||
"PUT" => await PutAsync(path, request, cancellationToken),
|
||||
"HEAD" => Head(path),
|
||||
"PROPFIND" => PropFind(path),
|
||||
"MOVE" => Move(path, destination),
|
||||
"GET" => Get(path, request.Headers.Range),
|
||||
"DELETE" => Delete(path),
|
||||
_ => Response(HttpStatusCode.MethodNotAllowed)
|
||||
};
|
||||
}
|
||||
|
||||
private HttpResponseMessage CreateDirectory(string path)
|
||||
{
|
||||
if (!_directories.Add(path)) return Response(ExistingDirectoryStatusCode);
|
||||
return Response(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PutAsync(string path, HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
_files[path] = await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
return Response(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
private HttpResponseMessage Head(string path)
|
||||
{
|
||||
if (ConsumeStaleRead(_staleAllReads, path) || ConsumeStaleRead(_staleMetadataReads, path))
|
||||
return Response(HttpStatusCode.NotFound);
|
||||
if (ConsumeStaleLength(path, out var staleLength)) return LengthResponse(staleLength);
|
||||
if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound);
|
||||
return LengthResponse(bytes.LongLength);
|
||||
}
|
||||
|
||||
private HttpResponseMessage PropFind(string path)
|
||||
{
|
||||
if (!_files.TryGetValue(path, out var bytes) && !_directories.Contains(path))
|
||||
return Response(HttpStatusCode.NotFound);
|
||||
|
||||
var length = bytes?.LongLength ?? 0;
|
||||
var resourceType = _directories.Contains(path)
|
||||
? "<resourcetype><collection/></resourcetype>"
|
||||
: "<resourcetype/>";
|
||||
var xml = $"<?xml version=\"1.0\"?><multistatus xmlns=\"DAV:\"><response><propstat><prop><getcontentlength>{length}</getcontentlength>{resourceType}</prop></propstat></response></multistatus>";
|
||||
var response = Response((HttpStatusCode)207);
|
||||
response.Content = new StringContent(xml, Encoding.UTF8, "application/xml");
|
||||
return response;
|
||||
}
|
||||
|
||||
private HttpResponseMessage Move(string sourcePath, string destination)
|
||||
{
|
||||
if (FailNextMove)
|
||||
{
|
||||
FailNextMove = false;
|
||||
return Response(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
if (!_files.Remove(sourcePath, out var bytes)) return Response(HttpStatusCode.NotFound);
|
||||
|
||||
var destinationPath = DecodePath(new Uri(destination, UriKind.Absolute));
|
||||
var oldLength = _files.TryGetValue(destinationPath, out var oldBytes) ? oldBytes.LongLength : 0;
|
||||
_files[destinationPath] = bytes;
|
||||
if (StaleMetadataReadsAfterMove > 0)
|
||||
_staleMetadataReads[destinationPath] = StaleMetadataReadsAfterMove;
|
||||
if (StaleAllReadsAfterMove > 0)
|
||||
_staleAllReads[destinationPath] = StaleAllReadsAfterMove;
|
||||
if (StaleLengthReadsAfterMove > 0 && oldLength > 0)
|
||||
_staleLengthReads[destinationPath] = new StaleLengthState(StaleLengthReadsAfterMove, oldLength);
|
||||
return Response(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
private HttpResponseMessage Get(string path, RangeHeaderValue range)
|
||||
{
|
||||
if (ConsumeStaleRead(_staleAllReads, path)) return Response(HttpStatusCode.NotFound);
|
||||
if (ConsumeStaleLength(path, out var staleLength))
|
||||
{
|
||||
var stale = Response(HttpStatusCode.PartialContent);
|
||||
stale.Content = new ByteArrayContent(new byte[] { 0 });
|
||||
stale.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, staleLength);
|
||||
return stale;
|
||||
}
|
||||
if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound);
|
||||
if (!SupportsRange || range == null)
|
||||
{
|
||||
var full = Response(HttpStatusCode.OK);
|
||||
full.Content = new ByteArrayContent(bytes);
|
||||
return full;
|
||||
}
|
||||
|
||||
var requested = range.Ranges.Single();
|
||||
var start = requested.From ?? 0;
|
||||
var end = Math.Min(requested.To ?? bytes.LongLength - 1, bytes.LongLength - 1);
|
||||
if (start < 0 || start >= bytes.LongLength || end < start)
|
||||
{
|
||||
var invalid = Response(HttpStatusCode.RequestedRangeNotSatisfiable);
|
||||
invalid.Content = new ByteArrayContent(Array.Empty<byte>());
|
||||
invalid.Content.Headers.ContentRange = new ContentRangeHeaderValue(bytes.LongLength);
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var content = bytes.Skip((int)start).Take((int)(end - start + 1)).ToArray();
|
||||
var partial = Response(HttpStatusCode.PartialContent);
|
||||
partial.Content = new ByteArrayContent(content);
|
||||
if (IncludeContentRange)
|
||||
partial.Content.Headers.ContentRange = new ContentRangeHeaderValue(start, end, bytes.LongLength);
|
||||
return partial;
|
||||
}
|
||||
|
||||
private HttpResponseMessage Delete(string path)
|
||||
{
|
||||
var deleted = _files.Remove(path);
|
||||
foreach (var child in _files.Keys.Where(x => IsChildOf(x, path)).ToList())
|
||||
{
|
||||
deleted |= _files.Remove(child);
|
||||
}
|
||||
foreach (var child in _directories.Where(x => x == path || IsChildOf(x, path)).ToList())
|
||||
{
|
||||
if (child == "/") continue;
|
||||
deleted |= _directories.Remove(child);
|
||||
}
|
||||
return Response(deleted ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
private static bool IsChildOf(string candidate, string parent) =>
|
||||
candidate.StartsWith(parent.TrimEnd('/') + "/", StringComparison.Ordinal);
|
||||
|
||||
private static bool ConsumeStaleRead(Dictionary<string, int> reads, string path)
|
||||
{
|
||||
if (!reads.TryGetValue(path, out var remaining) || remaining <= 0) return false;
|
||||
if (remaining == 1) reads.Remove(path);
|
||||
else reads[path] = remaining - 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ConsumeStaleLength(string path, out long length)
|
||||
{
|
||||
length = 0;
|
||||
if (!_staleLengthReads.TryGetValue(path, out var state) || state.Remaining <= 0) return false;
|
||||
length = state.Length;
|
||||
if (state.Remaining == 1) _staleLengthReads.Remove(path);
|
||||
else _staleLengthReads[path] = state with { Remaining = state.Remaining - 1 };
|
||||
return true;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage LengthResponse(long length)
|
||||
{
|
||||
var response = Response(HttpStatusCode.OK);
|
||||
response.Content = new ByteArrayContent(Array.Empty<byte>());
|
||||
response.Content.Headers.ContentLength = length;
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string DecodePath(Uri uri) => Uri.UnescapeDataString(uri.AbsolutePath);
|
||||
|
||||
private static HttpResponseMessage Response(HttpStatusCode statusCode) => new(statusCode);
|
||||
|
||||
private sealed record StaleLengthState(int Remaining, long Length);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace dy.net.Tests.TestInfrastructure;
|
||||
|
||||
internal sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
public TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "dysync-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path)) Directory.Delete(Path, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Net;
|
||||
|
||||
namespace dy.net.Tests.TestInfrastructure;
|
||||
|
||||
internal sealed class StubHttpClientFactory : IHttpClientFactory, IDisposable
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public StubHttpClientFactory(HttpMessageHandler handler, string baseAddress = null)
|
||||
{
|
||||
_client = new HttpClient(handler, true);
|
||||
if (!string.IsNullOrWhiteSpace(baseAddress))
|
||||
_client.BaseAddress = new Uri(baseAddress);
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
|
||||
public void Dispose() => _client.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class LiveHttpClientFactory : IHttpClientFactory, IDisposable
|
||||
{
|
||||
private readonly HttpClient _secureClient = new(new HttpClientHandler());
|
||||
private readonly HttpClient _insecureClient = new(new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
public HttpClient CreateClient(string name) => name == "webdav-insecure" ? _insecureClient : _secureClient;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_secureClient.Dispose();
|
||||
_insecureClient.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using dy.net.model.dto;
|
||||
using dy.net.model.entity;
|
||||
using dy.net.service;
|
||||
using dy.net.storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using SqlSugar;
|
||||
|
||||
namespace dy.net.Tests.TestInfrastructure;
|
||||
|
||||
internal sealed class WebDavTestHost : IDisposable
|
||||
{
|
||||
private readonly TemporaryDirectory _temporaryDirectory;
|
||||
private readonly IDisposable _clientFactory;
|
||||
|
||||
private WebDavTestHost(
|
||||
TemporaryDirectory temporaryDirectory,
|
||||
SqlSugarClient database,
|
||||
IDisposable clientFactory,
|
||||
WebDavSettingsService settingsService,
|
||||
WebDavMediaStorage storage,
|
||||
WebDavSettings settings)
|
||||
{
|
||||
_temporaryDirectory = temporaryDirectory;
|
||||
Database = database;
|
||||
_clientFactory = clientFactory;
|
||||
SettingsService = settingsService;
|
||||
Storage = storage;
|
||||
Settings = settings;
|
||||
}
|
||||
|
||||
public SqlSugarClient Database { get; }
|
||||
public WebDavSettingsService SettingsService { get; }
|
||||
public WebDavMediaStorage Storage { get; }
|
||||
public WebDavSettings Settings { get; }
|
||||
|
||||
public static async Task<WebDavTestHost> CreateAsync(
|
||||
IHttpClientFactory factory,
|
||||
IDisposable disposableFactory,
|
||||
WebDavTestRequest request,
|
||||
IReadOnlyList<TimeSpan> uploadVisibilityRetryDelays = null,
|
||||
IReadOnlyList<TimeSpan> finalVisibilityRetryDelays = null)
|
||||
{
|
||||
var temporaryDirectory = new TemporaryDirectory();
|
||||
try
|
||||
{
|
||||
var databasePath = Path.Combine(temporaryDirectory.Path, "settings.sqlite");
|
||||
var database = new SqlSugarClient(new ConnectionConfig
|
||||
{
|
||||
ConnectionString = $"DataSource={databasePath}",
|
||||
DbType = DbType.Sqlite,
|
||||
InitKeyType = InitKeyType.Attribute,
|
||||
IsAutoCloseConnection = true
|
||||
});
|
||||
database.CodeFirst.InitTables<WebDavSettings>();
|
||||
|
||||
var provider = DataProtectionProvider.Create(
|
||||
new DirectoryInfo(Path.Combine(temporaryDirectory.Path, "keys")),
|
||||
builder => builder.SetApplicationName("dysync.net"));
|
||||
var settingsService = new WebDavSettingsService(database, provider);
|
||||
var settings = await settingsService.BuildCandidateAsync(request);
|
||||
await settingsService.SaveAsync(settings, false, "test");
|
||||
var storage = uploadVisibilityRetryDelays == null || finalVisibilityRetryDelays == null
|
||||
? new WebDavMediaStorage(factory, settingsService)
|
||||
: new WebDavMediaStorage(factory, settingsService,
|
||||
uploadVisibilityRetryDelays, finalVisibilityRetryDelays);
|
||||
return new WebDavTestHost(temporaryDirectory, database, disposableFactory, settingsService, storage, settings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
disposableFactory.Dispose();
|
||||
temporaryDirectory.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Database.Dispose();
|
||||
_clientFactory.Dispose();
|
||||
_temporaryDirectory.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user