Merge pull request #6955 from duplicati/feature/add-server-side-filters

Add server-side filter eval
This commit is contained in:
Kenneth Skovhede
2026-06-09 21:00:21 +02:00
committed by GitHub
4 changed files with 224 additions and 0 deletions
@@ -0,0 +1,43 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.WebserverCore.Dto.V2;
/// <summary>
/// DTO for testing filesystem filters
/// </summary>
public class TestFilterRequestDto
{
/// <summary>
/// The paths to evaluate against the filters
/// </summary>
public string[]? Paths { get; set; }
/// <summary>
/// The source paths (roots) which are unconditionally included
/// </summary>
public string[]? Sources { get; set; }
/// <summary>
/// The filter strings to evaluate
/// </summary>
public string[]? Filters { get; set; }
}
@@ -0,0 +1,78 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.WebserverCore.Dto.V2;
/// <summary>
/// DTO for the result of testing a single filesystem path against filters
/// </summary>
public record TestFilterResponseItem
{
/// <summary>
/// The path that was evaluated
/// </summary>
public string Path { get; set; } = "";
/// <summary>
/// Whether the path is included by the filters
/// </summary>
public bool Included { get; set; }
/// <summary>
/// The string representation of the filter that matched, if any
/// </summary>
public string? MatchedFilter { get; set; }
}
/// <summary>
/// DTO for the response of testing filesystem filters
/// </summary>
public record TestFilterResponseDto : ResponseEnvelope<TestFilterResponseItem[]>
{
/// <summary>
/// Creates a success response with the test results
/// </summary>
/// <param name="data">The data to include</param>
/// <returns>The response DTO</returns>
public static TestFilterResponseDto Create(TestFilterResponseItem[] data)
=> new TestFilterResponseDto()
{
Success = true,
Error = null,
StatusCode = "OK",
Data = data
};
/// <summary>
/// Creates an error response
/// </summary>
/// <param name="error">The error message</param>
/// <param name="statusCode">The status code</param>
/// <returns>The response DTO</returns>
public static TestFilterResponseDto CreateError(string error, string statusCode)
=> new TestFilterResponseDto()
{
Success = false,
Error = error,
StatusCode = statusCode,
Data = null
};
}
@@ -0,0 +1,102 @@
// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.Utility;
using Duplicati.Library.Common.IO;
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Dto.V2;
using Microsoft.AspNetCore.Mvc;
using Duplicati.Library.Interface;
namespace Duplicati.WebserverCore.Endpoints.V2;
public class TestFilters : IEndpointV2
{
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType<TestFilters>();
public static void Map(RouteGroupBuilder group)
{
group.MapPost("/filesystem/test-filter", ([FromBody] TestFilterRequestDto request) => Execute(request))
.RequireAuthorization();
}
private static TestFilterResponseDto Execute(TestFilterRequestDto request)
{
try
{
return TestFilterResponseDto.Create(Evaluate(request).ToArray());
}
catch (Exception ex)
{
Library.Logging.Log.WriteErrorMessage(LOGTAG, "TestFilters", ex, "An error occurred while testing filters");
return ex is UserInformationException uex
? TestFilterResponseDto.CreateError(uex.Message, uex.HelpID)
: TestFilterResponseDto.CreateError(ex.Message, "InternalError");
}
}
private static IEnumerable<TestFilterResponseItem> Evaluate(TestFilterRequestDto request)
{
var filter = FilterExpression.Deserialize(request.Filters ?? Array.Empty<string>());
var enumeratefilter = filter;
FilterExpression.AnalyzeFilters(filter, out var includes, out var excludes);
if (includes && !excludes)
enumeratefilter = FilterExpression.Combine(filter, new FilterExpression("*" + System.IO.Path.DirectorySeparatorChar, true));
var sourceArray = request.Sources ?? Array.Empty<string>();
var sources = new HashSet<string>(sourceArray, Utility.ClientFilenameStringComparer);
foreach (var path in request.Paths ?? Array.Empty<string>())
{
if (path == null) continue;
// If the path is exactly one of the sources, it's always included.
if (sources.Contains(path))
{
yield return new TestFilterResponseItem
{
Path = path,
Included = true,
MatchedFilter = null
};
continue;
}
// Check if the path is under any of the source folders
var isUnderSource = sourceArray
.Any(source => string.Equals(Util.AppendDirSeparator(path), Util.AppendDirSeparator(source), Utility.ClientFilenameStringComparison) ||
Utility.IsPathBelowFolder(path, source));
// Do not return paths that are not part of the source selection
if (!isUnderSource)
continue;
var isIncluded = FilterExpression.Matches(enumeratefilter!, path, out var match);
yield return new TestFilterResponseItem
{
Path = path,
Included = isIncluded,
MatchedFilter = match?.ToString()
};
}
}
}
@@ -59,6 +59,7 @@ public class SystemInfoProvider(IApplicationSettings applicationSettings, Connec
"v2:system:temp-disk-space",
"v1:subscribe:remotecontrol",
"v1:ipc:controller",
"v2:filesystem:test-filter"
// "v1:subscribe:scheduler",
];