Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/Filee.App/Services/BrowserDrags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal static class BrowserDrags
BrowserDragFormats.UniformResourceLocatorW, BrowserDragFormats.UniformResourceLocator, BrowserDragFormats.MozUrl,
BrowserDragFormats.NetscapeUrl, BrowserDragFormats.UriList, BrowserDragFormats.DownloadUrlFormat,
BrowserDragFormats.MozFilePromiseUrl, BrowserDragFormats.MozFilePromiseName, BrowserDragFormats.MacUrl,
BrowserDragFormats.MacUrlName, BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml,
BrowserDragFormats.MacUrlName, BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml, BrowserDragFormats.PlainText,
];

/// <summary>Picture data some browsers put on a drag (macOS, X11), with the extension it is saved with.</summary>
Expand All @@ -39,7 +39,8 @@ private static readonly (string Format, string Extension)[] PictureFormats =
public static bool MayOffer(IDataTransfer data) =>
data.Formats.Any(f => f.Kind == DataFormatKind.Platform
&& (BrowserDragFormats.FileGroupDescriptorW.Equals(f.Identifier, StringComparison.OrdinalIgnoreCase)
|| BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase)));
|| BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase)))
|| InlineSvg.Read(data.TryGetText()) is not null;

/// <summary>
/// The files of a drag from a browser (or another app that holds them only in memory), standing as placeholders;
Expand Down Expand Up @@ -96,7 +97,7 @@ public static bool MayOffer(IDataTransfer data) =>
files.Add(WebDropItem.FromStream(entry.Name, _ => throw new IOException("The file's contents weren't handed over.")));
}

if (files.Count == 0 && !hasAddress)
if (files.Count == 0 && !hasAddress && !texts.ContainsKey(BrowserDragFormats.PlainText))
return null;
return new BrowserDragContent { Files = files, Texts = texts, Picture = Picture(data, texts) };
}
Expand All @@ -120,6 +121,8 @@ internal static bool IsTemporary(string path)
private static Dictionary<string, string> Texts(IDataTransfer data)
{
var texts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (data.TryGetText() is { } plain)
texts[BrowserDragFormats.PlainText] = plain;
foreach (var item in data.Items)
{
foreach (var format in item.Formats)
Expand Down
5 changes: 4 additions & 1 deletion src/Filee.Core/Web/BrowserDrag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// (".html", ".php", no extension). The server's answer is checked again when downloading.
// 5. Nothing else, but the drag carries the picture's pixels (a blob: address Filee can't fetch): those, as PNG.
//
// Plain text is never read: a selected address is text, not a file.
// A complete standalone SVG text selection is a picture too. Other plain text is not a file.

using Filee.Core.Formats;

Expand Down Expand Up @@ -61,6 +61,9 @@ public static IReadOnlyList<WebDropItem> Files(BrowserDragContent content)
if (handed.Count > 0)
return handed;

if (InlineSvg.Read(content.Text(BrowserDragFormats.PlainText)) is { } svg)
return [WebDropItem.FromInlineSvg(svg)];

var picture = content.Picture is not null;
var html = content.Text(BrowserDragFormats.Html) ?? content.Text(BrowserDragFormats.WindowsHtml) ?? content.Text(BrowserDragFormats.MacHtml);
if (BrowserDragFormats.ImageOnly(html) is { } src && Address(src, image: true, Base(content)) is { } image)
Expand Down
1 change: 1 addition & 0 deletions src/Filee.Core/Web/BrowserDragFormats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static partial class BrowserDragFormats
public const string Html = "text/html";
public const string WindowsHtml = "HTML Format";
public const string MacHtml = "public.html";
public const string PlainText = "text/plain";

// FILEDESCRIPTORW: dwFlags, clsid, sizel, pointl, dwFileAttributes, three FILETIMEs, nFileSizeHigh/Low,
// cFileName[MAX_PATH] (UTF-16) = 592 bytes, after a 4-byte count.
Expand Down
103 changes: 103 additions & 0 deletions src/Filee.Core/Web/InlineSvg.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;

namespace Filee.Core.Web;

/// <summary>A complete standalone SVG selection, never HTML extraction or entity decoding.</summary>
public static partial class InlineSvg
{
public const int MaxCharacters = 4 * 1024 * 1024;
private const string SvgNamespace = "http://www.w3.org/2000/svg";

public static byte[]? Read(string? source)
{
if (string.IsNullOrWhiteSpace(source) || source.Length > MaxCharacters)
return null;
source = source.Trim();
if (!source.StartsWith("<svg", StringComparison.Ordinal))
return null;
try
{
using var input = new StringReader(source);
using var reader = XmlReader.Create(input, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = MaxCharacters,
});
var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
if (document.Root is not { } root || root.IsEmpty || root.Name.LocalName != "svg"
|| document.Nodes().Any(node => node != root && (node is not XText text || !string.IsNullOrWhiteSpace(text.Value)))
|| (root.Name.NamespaceName.Length != 0 && root.Name.NamespaceName != SvgNamespace))
return null;
// HTML selections may omit xmlns; standalone converters need the SVG namespace.
if (root.Name.NamespaceName.Length == 0)
{
foreach (var element in root.DescendantsAndSelf().Where(e => e.Name.NamespaceName.Length == 0))
element.Name = XName.Get(element.Name.LocalName, SvgNamespace);
root.SetAttributeValue("xmlns", SvgNamespace);
}
return Encoding.UTF8.GetBytes(root.ToString(SaveOptions.DisableFormatting));
}
catch (XmlException)
{
return null;
}
}

/// <summary>Selections have no base folder or page stylesheet; missing resources become job errors.</summary>
public static void EnsureSelfContained(byte[] svg)
{
var root = XElement.Parse(Encoding.UTF8.GetString(svg));
foreach (var element in root.DescendantsAndSelf())
{
foreach (var attribute in element.Attributes())
{
if (attribute.Name.LocalName == "href" && element.Name.LocalName != "a"
|| attribute.Name == XNamespace.Xml + "base")
CheckReference(attribute.Value);
CheckCss(attribute.Value);
}
if (element.Name.LocalName == "style")
CheckCss(element.Value);
}
if (root.DescendantNodes().OfType<XProcessingInstruction>().Any(p => p.Target == "xml-stylesheet"))
throw new InvalidDataException("The selected SVG requires an external stylesheet. Include its styles in the SVG.");
}

private static void CheckReference(string reference)
{
reference = reference.Trim();
if (reference.Length == 0 || reference.StartsWith('#') || reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
return;
throw new InvalidDataException("The selected SVG contains an unresolved external image, font or stylesheet. Embed the referenced resource in the SVG.");
}

private static void CheckCss(string css)
{
try
{
css = CssEscapes().Replace(css, match => match.Groups[1].Success
? new Rune(Convert.ToInt32(match.Groups[1].Value, 16)).ToString()
: match.Groups[2].Value);
css = CssComments().Replace(css, "");
if (css.Contains("@import", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("The selected SVG requires an external stylesheet. Include its styles in the SVG.");
foreach (Match match in CssUrls().Matches(css))
CheckReference(match.Groups[1].Value.Trim().Trim('\'', '"'));
}
catch (Exception ex) when (ex is RegexMatchTimeoutException or ArgumentOutOfRangeException)
{
throw new InvalidDataException("The selected SVG's resource references could not be checked.", ex);
}
}

[GeneratedRegex(@"\\([0-9a-fA-F]{1,6})\s?|\\([^\r\n])", RegexOptions.CultureInvariant, 100)]
private static partial Regex CssEscapes();
[GeneratedRegex(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.CultureInvariant, 100)]
private static partial Regex CssComments();
[GeneratedRegex(@"url\s*\(([^)]*)\)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, 100)]
private static partial Regex CssUrls();
}
8 changes: 8 additions & 0 deletions src/Filee.Core/Web/WebDrop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ public abstract Task<string> SaveAsync(string folder, WebDownloader downloader,
public static WebDropItem FromBytes(string name, byte[] bytes, string? mime = null) =>
new StreamItem(name, _ => Task.FromResult<Stream>(new MemoryStream(bytes, writable: false)), bytes.Length, mime, null);

/// <summary>A standalone SVG selection, checked for unresolved resources when its job runs.</summary>
public static WebDropItem FromInlineSvg(byte[] svg) =>
new StreamItem("image.svg", _ =>
{
InlineSvg.EnsureSelfContained(svg);
return Task.FromResult<Stream>(new MemoryStream(svg, writable: false));
}, svg.Length, "image/svg+xml", null);

/// <summary>
/// A file whose bytes the source app streams on request (a Windows virtual file). <paramref name="owner"/> is
/// disposed with the item, telling the source the transfer is over.
Expand Down
85 changes: 83 additions & 2 deletions tests/Filee.App.Tests/BrowserDropTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,88 @@ public void A_picture_dropped_on_a_donut_slice_is_saved_converted_and_only_the_c
}
}

[AvaloniaTheory]
[InlineData("png", false)]
[InlineData("pdf", false)]
[InlineData("png", true)]
[InlineData("pdf", true)]
public void Standalone_svg_drag_reaches_png_and_pdf_or_reports_missing_resources(string target, bool external)
{
TestServices.EnsureInitialized("en");
var store = AppHost.Get<UserDataStore>();
var folder = UseFolder(store, saveOnlyConverted: true);
var radial = AppHost.Get<RadialController>();
var conversions = AppHost.Get<ConversionService>();
try
{
using var data = new DataTransfer();
var drawing = external ? "<image href='missing.png' width='20' height='20'/>"
: "<rect width='20' height='20' fill='red'/>";
data.Add(DataTransferItem.CreateText("<svg width='20' height='20'>" + drawing + "</svg>"));
Assert.True(DraggedFiles.Offered(data));
radial.ShowForDrag(500, 500);
var window = radial.Window!;
var root = window.FindControl<Panel>("Root")!;
var donut = window.DonutControl;
root.RaiseEvent(new DragEventArgs(DragDrop.DragEnterEvent, data, root, new Point(5, 5), KeyModifiers.Control));
Pump();
var model = AppHost.Get<RadialViewModel>();
Assert.EndsWith(".svg", Assert.Single(model.Files));
var (at, _) = FirstSlice(donut, model, target);
var before = conversions.Jobs.Count;
root.RaiseEvent(new DragEventArgs(DragDrop.DropEvent, data, root, donut.TranslatePoint(at, root)!.Value, KeyModifiers.Control));
Assert.Equal(before + 1, conversions.Jobs.Count);
var job = WaitFor(conversions.Jobs[0]);
if (external)
{
Assert.Equal(FileState.Failed, Assert.Single(job.Files).State);
Assert.Contains("external", job.Files[0].ErrorDetail);
Assert.Empty(job.Outputs);
Assert.Empty(Directory.GetFiles(folder));
}
else
{
Assert.Equal(JobState.Completed, job.State);
var output = Assert.Single(job.Outputs);
Assert.Equal("." + target, Path.GetExtension(output));
if (target == "png")
{
using var bitmap = SkiaSharp.SKBitmap.Decode(output);
Assert.NotNull(bitmap);
Assert.Equal(SkiaSharp.SKColors.Red, bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2));
}
else
{
using var pdf = UglyToad.PdfPig.PdfDocument.Open(output);
Assert.Equal(1, pdf.NumberOfPages);
Assert.NotEmpty(pdf.GetPage(1).Paths);
}
}
}
finally
{
radial.Close(animate: false);
Reset(store, folder);
}
}

[AvaloniaTheory]
[InlineData("ordinary text")]
[InlineData("https://example.com/image.svg")]
[InlineData("<svg><rect></svg>")]
[InlineData("<div><svg></svg></div>")]
[InlineData("<svg></svg><svg></svg>")]
[InlineData("&lt;svg&gt;&lt;/svg&gt;")]
public void Other_text_does_not_offer_files_to_the_donut(string text)
{
using var data = new DataTransfer();
data.Add(DataTransferItem.CreateText(text));
Assert.False(DraggedFiles.Offered(data));
Assert.Empty(DraggedFiles.Read(data, forDrop: true).Files);
}

/// <summary>A point on a slice that converts these files, and its preset.</summary>
private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewModel model)
private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewModel model, string? target = null)
{
var center = new Point(donut.Bounds.Width / 2, donut.Bounds.Height / 2);
var radius = model.OuterRadius * 0.75;
Expand All @@ -251,7 +331,8 @@ private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewM
var angle = degrees * Math.PI / 180;
var p = new Point(center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle));
var hit = donut.HitTest(p);
if (hit.Kind == DonutHitKind.Segment && model.PresetAt(hit.Index, -1) is { } preset && donut.Converts(hit))
if (hit.Kind == DonutHitKind.Segment && model.PresetAt(hit.Index, -1) is { } preset && donut.Converts(hit)
&& (target is null || preset.TargetFormat == target))
return (p, preset.Id);
}
throw new InvalidOperationException("No slice converts a PNG.");
Expand Down
71 changes: 71 additions & 0 deletions tests/Filee.Core.Tests/InlineSvgTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Text;
using System.Xml.Linq;
using Filee.Core.Web;

namespace Filee.Core.Tests;

public class InlineSvgTests
{
[Theory]
[InlineData("<svg><rect width='20' height='20'/></svg>")]
[InlineData("<svg><rect width='20' height='20'/></svg >")]
[InlineData(" \r\n<svg xmlns='http://www.w3.org/2000/svg'><text>A &amp; B</text></svg>\t ")]
[InlineData("<svg><defs><path id='p' d='M0 0L10 10'/></defs><use href='#p'/></svg>")]
public void A_single_well_formed_svg_is_accepted(string source)
{
var bytes = InlineSvg.Read(source);
Assert.NotNull(bytes);
Assert.Equal("http://www.w3.org/2000/svg", XElement.Parse(Encoding.UTF8.GetString(bytes)).Name.NamespaceName);
InlineSvg.EnsureSelfContained(bytes);
}

[Theory]
[InlineData("ordinary text")]
[InlineData("https://example.com/image.svg")]
[InlineData("<div><svg></svg></div>")]
[InlineData("<svg></svg><svg></svg>")]
[InlineData("&lt;svg&gt;&lt;/svg&gt;")]
[InlineData("<svg><path></svg>")]
[InlineData("<svg><text>A & B</text></svg>")]
[InlineData("<svg><rect width='1' width='2'/></svg>")]
[InlineData("<svg xmlns='urn:other'></svg>")]
[InlineData("<!DOCTYPE svg [<!ENTITY x SYSTEM 'file:///secret'>]><svg>&x;</svg>")]
[InlineData("<svg></svg>trailing text")]
[InlineData("<svg></svg><!-- outside the selection -->")]
public void Unrelated_mixed_escaped_and_almost_valid_xml_are_rejected(string source) =>
Assert.Null(InlineSvg.Read(source));

[Fact]
public void Oversized_selection_is_not_parsed() =>
Assert.Null(InlineSvg.Read("<svg>" + new string(' ', InlineSvg.MaxCharacters) + "</svg>"));

[Theory]
[InlineData("<image href='missing.png'/>")]
[InlineData("<g xml:base='https://example.com/'><image href='image.png'/></g>")]
[InlineData("<use xmlns:xlink='http://www.w3.org/1999/xlink' xlink:href='other.svg#shape'/>")]
[InlineData("<style>@import 'missing.css';</style>")]
[InlineData("<style>@font-face {font-family: Icon;src:url(missing.woff2)}</style>")]
[InlineData("<rect style='fill:url(https://example.com/paint.svg#p)'/>")]
[InlineData("<style>@\\69mport 'missing.css';</style>")]
public void External_dependencies_are_reported_when_the_job_fetches_the_selection(string body)
{
var svg = InlineSvg.Read("<svg>" + body + "</svg>");
Assert.NotNull(svg); // Valid SVG still reaches the result, with a useful failure message.
var error = Assert.Throws<InvalidDataException>(() => InlineSvg.EnsureSelfContained(svg));
Assert.Contains("external", error.Message);
}

[Fact]
public void Html_is_not_used_as_a_fallback_for_selected_svg()
{
var drag = new BrowserDragContent
{
Texts = new Dictionary<string, string>
{
[BrowserDragFormats.PlainText] = "not SVG",
[BrowserDragFormats.Html] = "<svg><rect width='20' height='20'/></svg>"
}
};
Assert.Empty(BrowserDrag.Files(drag));
}
}
Loading