From f2846d23981aa30629ee7230c159364a2cc22fbd Mon Sep 17 00:00:00 2001 From: Aaron Spettl Date: Mon, 22 Dec 2025 10:44:34 +0100 Subject: [PATCH 1/5] Fix escaping of file names for song URIs --- WordsLive.Core/MediaManager.cs | 4 ++-- WordsLive.Core/Songs/Storage/LocalSongStorage.cs | 7 ++++--- WordsLive.Core/Songs/Storage/SongData.cs | 2 +- WordsLive/Editor/EditorWindow.xaml.cs | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/WordsLive.Core/MediaManager.cs b/WordsLive.Core/MediaManager.cs index 3bc4319d..78e7ab17 100644 --- a/WordsLive.Core/MediaManager.cs +++ b/WordsLive.Core/MediaManager.cs @@ -239,7 +239,7 @@ public static IEnumerable LoadPortfolio(string fileName) { foreach (Media m in from i in root.Element("order").Elements("item") select (i.Attribute("mediatype").Value == "powerpraise-song" && !i.Element("file").Value.Contains('\\')) ? - LoadMediaMetadata(new Uri("song:///" + i.Element("file").Value), LoadOptions(i)) : + LoadMediaMetadata(new Uri("song:///" + Uri.EscapeDataString(i.Element("file").Value)), LoadOptions(i)) : LoadMediaMetadata(new Uri(i.Element("file").Value), LoadOptions(i))) { yield return m; @@ -250,7 +250,7 @@ public static IEnumerable LoadPortfolio(string fileName) else if (root.Attribute("version").Value == "2.2") { foreach (Media m in from i in root.Elements("item") - select MediaManager.LoadMediaMetadata(new Uri("song:///" + i.Element("file").Value), null)) + select MediaManager.LoadMediaMetadata(new Uri("song:///" + Uri.EscapeDataString(i.Element("file").Value)), null)) { yield return m; } diff --git a/WordsLive.Core/Songs/Storage/LocalSongStorage.cs b/WordsLive.Core/Songs/Storage/LocalSongStorage.cs index 8af1c1a4..89891d45 100644 --- a/WordsLive.Core/Songs/Storage/LocalSongStorage.cs +++ b/WordsLive.Core/Songs/Storage/LocalSongStorage.cs @@ -59,10 +59,10 @@ public override IEnumerable All() try { - var relativePath = new Uri(directory + Path.DirectorySeparatorChar) + var escapedRelativePath = new Uri(directory + Path.DirectorySeparatorChar) .MakeRelativeUri(new Uri(file)) .ToString(); - var song = new Song(new Uri("song:///" + relativePath), new SongUriResolver(this)); + var song = new Song(new Uri("song:///" + escapedRelativePath), new SongUriResolver(this)); data = SongData.Create(song); } catch { } @@ -162,7 +162,8 @@ public override Uri TryRewriteUri(Uri uri) if (filePath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { var relativePath = filePath.Substring(rootPath.Length).Replace(Path.DirectorySeparatorChar, '/'); - return new Uri("song:///" + relativePath); + var escapedRelativePath = Uri.EscapeDataString(relativePath); + return new Uri("song:///" + escapedRelativePath); } } diff --git a/WordsLive.Core/Songs/Storage/SongData.cs b/WordsLive.Core/Songs/Storage/SongData.cs index c70ae6d9..acd5dca4 100644 --- a/WordsLive.Core/Songs/Storage/SongData.cs +++ b/WordsLive.Core/Songs/Storage/SongData.cs @@ -128,7 +128,7 @@ public Uri Uri { get { - return new Uri("song:///" + Filename); + return new Uri("song:///" + Uri.EscapeDataString(Filename)); } } diff --git a/WordsLive/Editor/EditorWindow.xaml.cs b/WordsLive/Editor/EditorWindow.xaml.cs index 16802f47..c24a3f32 100644 --- a/WordsLive/Editor/EditorWindow.xaml.cs +++ b/WordsLive/Editor/EditorWindow.xaml.cs @@ -212,7 +212,7 @@ private void SaveSongAs(Song song) if (dlg.ShowDialog() == true) { - song.Save(new Uri("song:///" + dlg.Filename)); + song.Save(new Uri("song:///" + Uri.EscapeDataString(dlg.Filename))); } } From f45eb5be8c6eb1689f1493011d692ab9c5d72fce Mon Sep 17 00:00:00 2001 From: Aaron Spettl Date: Mon, 22 Dec 2025 13:34:48 +0100 Subject: [PATCH 2/5] Extract song URI handling into new static methods --- WordsLive.Core/MediaManager.cs | 7 +-- .../Songs/Storage/LocalSongStorage.cs | 6 +-- WordsLive.Core/Songs/Storage/SongData.cs | 4 +- WordsLive.Core/Songs/Storage/SongUri.cs | 51 +++++++++++++++++++ .../Songs/Storage/SongUriResolver.cs | 14 ++--- WordsLive.Core/WordsLive.Core.csproj | 1 + WordsLive/Editor/EditorWindow.xaml.cs | 2 +- WordsLive/MediaOrderList/MediaOrderItem.cs | 3 +- 8 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 WordsLive.Core/Songs/Storage/SongUri.cs diff --git a/WordsLive.Core/MediaManager.cs b/WordsLive.Core/MediaManager.cs index 78e7ab17..bc8603fa 100644 --- a/WordsLive.Core/MediaManager.cs +++ b/WordsLive.Core/MediaManager.cs @@ -21,6 +21,7 @@ using System.IO; using System.Linq; using System.Xml.Linq; +using WordsLive.Core.Songs.Storage; namespace WordsLive.Core { @@ -239,7 +240,7 @@ public static IEnumerable LoadPortfolio(string fileName) { foreach (Media m in from i in root.Element("order").Elements("item") select (i.Attribute("mediatype").Value == "powerpraise-song" && !i.Element("file").Value.Contains('\\')) ? - LoadMediaMetadata(new Uri("song:///" + Uri.EscapeDataString(i.Element("file").Value)), LoadOptions(i)) : + LoadMediaMetadata(SongUri.GetUri(i.Element("file").Value), LoadOptions(i)) : LoadMediaMetadata(new Uri(i.Element("file").Value), LoadOptions(i))) { yield return m; @@ -250,7 +251,7 @@ public static IEnumerable LoadPortfolio(string fileName) else if (root.Attribute("version").Value == "2.2") { foreach (Media m in from i in root.Elements("item") - select MediaManager.LoadMediaMetadata(new Uri("song:///" + Uri.EscapeDataString(i.Element("file").Value)), null)) + select MediaManager.LoadMediaMetadata(SongUri.GetUri(i.Element("file").Value), null)) { yield return m; } @@ -336,7 +337,7 @@ from m in enumerable private static string GetMediaPathFromUri(Uri uri) { if (uri.Scheme == "song") - return Uri.UnescapeDataString(uri.AbsolutePath).Substring(1); + return SongUri.GetFilename(uri); if (uri.IsFile) return uri.LocalPath; diff --git a/WordsLive.Core/Songs/Storage/LocalSongStorage.cs b/WordsLive.Core/Songs/Storage/LocalSongStorage.cs index 89891d45..692e207b 100644 --- a/WordsLive.Core/Songs/Storage/LocalSongStorage.cs +++ b/WordsLive.Core/Songs/Storage/LocalSongStorage.cs @@ -62,7 +62,8 @@ public override IEnumerable All() var escapedRelativePath = new Uri(directory + Path.DirectorySeparatorChar) .MakeRelativeUri(new Uri(file)) .ToString(); - var song = new Song(new Uri("song:///" + escapedRelativePath), new SongUriResolver(this)); + var relativePath = Uri.UnescapeDataString(escapedRelativePath); + var song = new Song(SongUri.GetUri(relativePath), new SongUriResolver(this)); data = SongData.Create(song); } catch { } @@ -162,8 +163,7 @@ public override Uri TryRewriteUri(Uri uri) if (filePath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) { var relativePath = filePath.Substring(rootPath.Length).Replace(Path.DirectorySeparatorChar, '/'); - var escapedRelativePath = Uri.EscapeDataString(relativePath); - return new Uri("song:///" + escapedRelativePath); + return SongUri.GetUri(relativePath); } } diff --git a/WordsLive.Core/Songs/Storage/SongData.cs b/WordsLive.Core/Songs/Storage/SongData.cs index acd5dca4..1cc332fc 100644 --- a/WordsLive.Core/Songs/Storage/SongData.cs +++ b/WordsLive.Core/Songs/Storage/SongData.cs @@ -128,7 +128,7 @@ public Uri Uri { get { - return new Uri("song:///" + Uri.EscapeDataString(Filename)); + return SongUri.GetUri(Filename); } } @@ -143,7 +143,7 @@ public static SongData Create(Song song) return new SongData { Title = song.Title, - Filename = Uri.UnescapeDataString(String.Join("", song.Uri.Segments.Skip(1))), + Filename = song.Uri.Scheme == "song" ? SongUri.GetFilename(song.Uri) : null, Text = song.TextWithoutChords, Translation = song.TranslationWithoutChords, Copyright = String.Join(" ", song.Copyright.Split('\n').Select(line => line.Trim())), diff --git a/WordsLive.Core/Songs/Storage/SongUri.cs b/WordsLive.Core/Songs/Storage/SongUri.cs new file mode 100644 index 00000000..d7147e7c --- /dev/null +++ b/WordsLive.Core/Songs/Storage/SongUri.cs @@ -0,0 +1,51 @@ +/* + * WordsLive - worship projection software + * Copyright (c) 2013 Patrick Reisert + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; + +namespace WordsLive.Core.Songs.Storage +{ + /// + /// Methods for handling "song://" URIs. + /// + public class SongUri + { + /// + /// Returns a "song://" URI for the given filename within the song storage folder. + /// + /// The filename. Include the subfolder when the file is in a subfolder of the song storage folder. + /// The "song://" URI with encoded special characters if needed. + public static Uri GetUri(string filename) + { + return new Uri("song:///" + Uri.EscapeDataString(filename)); + } + + /// + /// Extracts the filename within the song storage folder from a "song://" URI. + /// + /// The "song://" URI. + /// The filename relative to the song storage folder. If the file is in a subfolder, then this includes the subfolder and the used delimiter is "/". + public static string GetFilename(Uri uri) + { + if (uri.Scheme != "song") + throw new ArgumentException("uri"); + + return Uri.UnescapeDataString(uri.AbsolutePath).Substring(1); + } + } +} diff --git a/WordsLive.Core/Songs/Storage/SongUriResolver.cs b/WordsLive.Core/Songs/Storage/SongUriResolver.cs index a418f651..f406fe7f 100644 --- a/WordsLive.Core/Songs/Storage/SongUriResolver.cs +++ b/WordsLive.Core/Songs/Storage/SongUriResolver.cs @@ -61,7 +61,7 @@ public virtual Stream Get(Uri uri) { if (uri.Scheme == "song") { - return (ForceStorage ?? DataManager.Songs).Get(GetFilename(uri)).Stream; + return (ForceStorage ?? DataManager.Songs).Get(SongUri.GetFilename(uri)).Stream; } else if (uri.IsFile) @@ -85,7 +85,7 @@ public virtual async Task GetAsync(Uri uri, CancellationToken cancellati { if (uri.Scheme == "song") { - var entry = await (ForceStorage ?? DataManager.Songs).GetAsync(GetFilename(uri), cancellation); + var entry = await (ForceStorage ?? DataManager.Songs).GetAsync(SongUri.GetFilename(uri), cancellation); return entry.Stream; } else if (uri.IsFile) @@ -109,7 +109,7 @@ public virtual FileTransaction Put(Uri uri) { if (uri.Scheme == "song") { - return (ForceStorage ?? DataManager.Songs).Put(GetFilename(uri)); + return (ForceStorage ?? DataManager.Songs).Put(SongUri.GetFilename(uri)); } else if (uri.IsFile) { @@ -120,13 +120,5 @@ public virtual FileTransaction Put(Uri uri) throw new NotSupportedException(); } } - - private static string GetFilename(Uri uri) - { - if (uri.Scheme != "song") - throw new ArgumentException("uri"); - - return Uri.UnescapeDataString(uri.AbsolutePath).Substring(1); - } } } diff --git a/WordsLive.Core/WordsLive.Core.csproj b/WordsLive.Core/WordsLive.Core.csproj index 0ddf4b00..70524b57 100644 --- a/WordsLive.Core/WordsLive.Core.csproj +++ b/WordsLive.Core/WordsLive.Core.csproj @@ -82,6 +82,7 @@ + diff --git a/WordsLive/Editor/EditorWindow.xaml.cs b/WordsLive/Editor/EditorWindow.xaml.cs index c24a3f32..88541e35 100644 --- a/WordsLive/Editor/EditorWindow.xaml.cs +++ b/WordsLive/Editor/EditorWindow.xaml.cs @@ -212,7 +212,7 @@ private void SaveSongAs(Song song) if (dlg.ShowDialog() == true) { - song.Save(new Uri("song:///" + Uri.EscapeDataString(dlg.Filename))); + song.Save(SongUri.GetUri(dlg.Filename)); } } diff --git a/WordsLive/MediaOrderList/MediaOrderItem.cs b/WordsLive/MediaOrderList/MediaOrderItem.cs index 24c32b9d..7d4a1b79 100644 --- a/WordsLive/MediaOrderList/MediaOrderItem.cs +++ b/WordsLive/MediaOrderList/MediaOrderItem.cs @@ -21,6 +21,7 @@ using System.Linq; using System.Windows.Media; using WordsLive.Core; +using WordsLive.Core.Songs.Storage; namespace WordsLive.MediaOrderList { @@ -75,7 +76,7 @@ public string Path } else if (Data.Uri.Scheme == "song") { - return Uri.UnescapeDataString(Data.Uri.AbsolutePath).Substring(1); + return SongUri.GetFilename(Data.Uri); } else { From 23e2892a228b7a0380f384bd950aaa2f7c78ee1c Mon Sep 17 00:00:00 2001 From: Aaron Spettl Date: Mon, 22 Dec 2025 13:37:41 +0100 Subject: [PATCH 3/5] Escape each URI segment individually, add unit tests --- WordsLive.Core.Tests/Songs/SongUriTests.cs | 59 +++++++++++++++++++ .../WordsLive.Core.Tests.csproj | 1 + WordsLive.Core/Songs/Storage/SongUri.cs | 4 +- 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 WordsLive.Core.Tests/Songs/SongUriTests.cs diff --git a/WordsLive.Core.Tests/Songs/SongUriTests.cs b/WordsLive.Core.Tests/Songs/SongUriTests.cs new file mode 100644 index 00000000..8e3aae16 --- /dev/null +++ b/WordsLive.Core.Tests/Songs/SongUriTests.cs @@ -0,0 +1,59 @@ +/* + * WordsLive - worship projection software + * Copyright (c) 2014 Patrick Reisert + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +using System; +using WordsLive.Core.Songs.Storage; +using Xunit; + +namespace WordsLive.Core.Tests.Songs +{ + public class SongUriTests + { + public static TheoryData TestData => + new TheoryData + { + { "test.ppl", "song:///test.ppl", false }, + { "test and test.ppl", "song:///test and test.ppl", false }, + { "test+test.ppl", "song:///test%2Btest.ppl", false }, + { "test&test.ppl", "song:///test%26test.ppl", false }, + { "test (test).ppl", "song:///test %28test%29.ppl", false }, + { "test [test].ppl", "song:///test %5Btest%5D.ppl", false }, + { "#test.ppl", "song:///%23test.ppl", false }, + { "subfolder/test.ppl", "song:///subfolder/test.ppl", false }, + { "subfolder\\test.ppl", "song:///subfolder/test.ppl", true }, + }; + + [Theory] + [MemberData(nameof(TestData))] + public void GetUri(string filename, string uri, bool _) + { + Assert.Equal(uri, SongUri.GetUri(filename).ToString()); + } + + [Theory] + [MemberData(nameof(TestData))] + public void GetFilename(string filename, string uri, bool oneWayOnly) + { + if (oneWayOnly) + { + return; + } + Assert.Equal(filename.Replace("\\", "/"), SongUri.GetFilename(new Uri(uri))); + } + } +} diff --git a/WordsLive.Core.Tests/WordsLive.Core.Tests.csproj b/WordsLive.Core.Tests/WordsLive.Core.Tests.csproj index 20387c34..603c6494 100644 --- a/WordsLive.Core.Tests/WordsLive.Core.Tests.csproj +++ b/WordsLive.Core.Tests/WordsLive.Core.Tests.csproj @@ -51,6 +51,7 @@ + diff --git a/WordsLive.Core/Songs/Storage/SongUri.cs b/WordsLive.Core/Songs/Storage/SongUri.cs index d7147e7c..b71e102d 100644 --- a/WordsLive.Core/Songs/Storage/SongUri.cs +++ b/WordsLive.Core/Songs/Storage/SongUri.cs @@ -17,6 +17,7 @@ */ using System; +using System.Linq; namespace WordsLive.Core.Songs.Storage { @@ -32,7 +33,8 @@ public class SongUri /// The "song://" URI with encoded special characters if needed. public static Uri GetUri(string filename) { - return new Uri("song:///" + Uri.EscapeDataString(filename)); + var escapedFilename = string.Join("/", filename.Split('/', '\\').Select(Uri.EscapeDataString)); + return new Uri("song:///" + escapedFilename); } /// From d997938d7f066e9ab147e1d8181d9dd38b305fbc Mon Sep 17 00:00:00 2001 From: Aaron Spettl Date: Mon, 22 Dec 2025 16:42:42 +0100 Subject: [PATCH 4/5] Remove unnecessary replace now that test is skipped --- WordsLive.Core.Tests/Songs/SongUriTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordsLive.Core.Tests/Songs/SongUriTests.cs b/WordsLive.Core.Tests/Songs/SongUriTests.cs index 8e3aae16..6a49e58a 100644 --- a/WordsLive.Core.Tests/Songs/SongUriTests.cs +++ b/WordsLive.Core.Tests/Songs/SongUriTests.cs @@ -53,7 +53,7 @@ public void GetFilename(string filename, string uri, bool oneWayOnly) { return; } - Assert.Equal(filename.Replace("\\", "/"), SongUri.GetFilename(new Uri(uri))); + Assert.Equal(filename, SongUri.GetFilename(new Uri(uri))); } } } From 00d021f7a1e612ce4485851b036593806caae61a Mon Sep 17 00:00:00 2001 From: Aaron Spettl Date: Sun, 28 Dec 2025 15:18:41 +0100 Subject: [PATCH 5/5] Use original URI string for unit testing --- WordsLive.Core.Tests/Songs/SongUriTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WordsLive.Core.Tests/Songs/SongUriTests.cs b/WordsLive.Core.Tests/Songs/SongUriTests.cs index 6a49e58a..9ff71f91 100644 --- a/WordsLive.Core.Tests/Songs/SongUriTests.cs +++ b/WordsLive.Core.Tests/Songs/SongUriTests.cs @@ -28,11 +28,11 @@ public class SongUriTests new TheoryData { { "test.ppl", "song:///test.ppl", false }, - { "test and test.ppl", "song:///test and test.ppl", false }, { "test+test.ppl", "song:///test%2Btest.ppl", false }, { "test&test.ppl", "song:///test%26test.ppl", false }, - { "test (test).ppl", "song:///test %28test%29.ppl", false }, - { "test [test].ppl", "song:///test %5Btest%5D.ppl", false }, + { "test (test).ppl", "song:///test%20%28test%29.ppl", false }, + { "test [test].ppl", "song:///test%20%5Btest%5D.ppl", false }, + { "test äöüß.ppl", "song:///test%20%C3%A4%C3%B6%C3%BC%C3%9F.ppl", false }, { "#test.ppl", "song:///%23test.ppl", false }, { "subfolder/test.ppl", "song:///subfolder/test.ppl", false }, { "subfolder\\test.ppl", "song:///subfolder/test.ppl", true }, @@ -42,7 +42,7 @@ public class SongUriTests [MemberData(nameof(TestData))] public void GetUri(string filename, string uri, bool _) { - Assert.Equal(uri, SongUri.GetUri(filename).ToString()); + Assert.Equal(uri, SongUri.GetUri(filename).OriginalString); } [Theory]