From ddbeb87f25f9b7dc73e94454e99eb5316c826526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BC=B4=EF=BC=B2?= <31824852+TetsuOtter@users.noreply.github.com> Date: Sun, 19 May 2024 02:15:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Loader=E7=B3=BB=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E7=A7=BB=E5=8B=95=E3=80=81AppLinkInfo=E3=81=AE?= =?UTF-8?q?=E4=BB=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => Loaders}/LoaderJson.Tests.cs | 0 .../{ => Loaders}/LoaderSQL.Tests.cs | 0 .../{ => Loaders}/LoaderSQL.v0.Tests.cs | 0 TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs | 50 +++++++++ TRViS.IO/{ => Loaders}/ILoader.cs | 0 TRViS.IO/{ => Loaders}/LoaderJson.cs | 0 TRViS.IO/{ => Loaders}/LoaderSQL.cs | 0 TRViS.IO/OpenFile.cs | 101 ++++++++++++++++++ TRViS.IO/RequestInfo/AppLinkInfo.cs | 32 ++++++ TRViS.IO/Utils/UrlSafeBase64.cs | 38 +++++++ TRViS.IO/{ => Utils}/Utils.cs | 2 +- 11 files changed, 222 insertions(+), 1 deletion(-) rename TRViS.IO.Tests/{ => Loaders}/LoaderJson.Tests.cs (100%) rename TRViS.IO.Tests/{ => Loaders}/LoaderSQL.Tests.cs (100%) rename TRViS.IO.Tests/{ => Loaders}/LoaderSQL.v0.Tests.cs (100%) create mode 100644 TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs rename TRViS.IO/{ => Loaders}/ILoader.cs (100%) rename TRViS.IO/{ => Loaders}/LoaderJson.cs (100%) rename TRViS.IO/{ => Loaders}/LoaderSQL.cs (100%) create mode 100644 TRViS.IO/OpenFile.cs create mode 100644 TRViS.IO/RequestInfo/AppLinkInfo.cs create mode 100644 TRViS.IO/Utils/UrlSafeBase64.cs rename TRViS.IO/{ => Utils}/Utils.cs (90%) diff --git a/TRViS.IO.Tests/LoaderJson.Tests.cs b/TRViS.IO.Tests/Loaders/LoaderJson.Tests.cs similarity index 100% rename from TRViS.IO.Tests/LoaderJson.Tests.cs rename to TRViS.IO.Tests/Loaders/LoaderJson.Tests.cs diff --git a/TRViS.IO.Tests/LoaderSQL.Tests.cs b/TRViS.IO.Tests/Loaders/LoaderSQL.Tests.cs similarity index 100% rename from TRViS.IO.Tests/LoaderSQL.Tests.cs rename to TRViS.IO.Tests/Loaders/LoaderSQL.Tests.cs diff --git a/TRViS.IO.Tests/LoaderSQL.v0.Tests.cs b/TRViS.IO.Tests/Loaders/LoaderSQL.v0.Tests.cs similarity index 100% rename from TRViS.IO.Tests/LoaderSQL.v0.Tests.cs rename to TRViS.IO.Tests/Loaders/LoaderSQL.v0.Tests.cs diff --git a/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs b/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs new file mode 100644 index 00000000..4d5489c1 --- /dev/null +++ b/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs @@ -0,0 +1,50 @@ +using System.Reflection; + +using TRViS.IO.Models.DB; +using TRViS.IO.RequestInfo; + +namespace TRViS.IO.Tests; + +public class OpenFileTests +{ + [OneTimeSetUp] + public async Task SetUp() + { + } + + [Test] + public void OnlyJsonPath() + { + AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis:///app/open/json?path=https://example.com/db.json"); + AppLinkInfo expected = new( + AppLinkInfo.FileType.Json, + AppLinkInfo.CompressionType.None, + AppLinkInfo.EncryptionType.None, + new Uri("https://example.com/db.json"), + null, + null, + null, + null, + null + ); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void OnlySqlitePath() + { + AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis:///app/open/sqlite?path=https://example.com/trvis.db"); + AppLinkInfo expected = new( + AppLinkInfo.FileType.Sqlite, + AppLinkInfo.CompressionType.None, + AppLinkInfo.EncryptionType.None, + new Uri("https://example.com/trvis.db"), + null, + null, + null, + null, + null + ); + Assert.That(actual, Is.EqualTo(expected)); + } +} diff --git a/TRViS.IO/ILoader.cs b/TRViS.IO/Loaders/ILoader.cs similarity index 100% rename from TRViS.IO/ILoader.cs rename to TRViS.IO/Loaders/ILoader.cs diff --git a/TRViS.IO/LoaderJson.cs b/TRViS.IO/Loaders/LoaderJson.cs similarity index 100% rename from TRViS.IO/LoaderJson.cs rename to TRViS.IO/Loaders/LoaderJson.cs diff --git a/TRViS.IO/LoaderSQL.cs b/TRViS.IO/Loaders/LoaderSQL.cs similarity index 100% rename from TRViS.IO/LoaderSQL.cs rename to TRViS.IO/Loaders/LoaderSQL.cs diff --git a/TRViS.IO/OpenFile.cs b/TRViS.IO/OpenFile.cs new file mode 100644 index 00000000..6717653e --- /dev/null +++ b/TRViS.IO/OpenFile.cs @@ -0,0 +1,101 @@ +using System.Collections.Specialized; +using System.Web; +using TRViS.IO.RequestInfo; + +namespace TRViS.IO; + +public static class OpenFile +{ + // public static Task OpenAppLinkAsync( + // string appLink, + // CancellationToken token + // ) + // { + // AppLinkInfo appLinkInfo = IdentifyAppLinkInfo(appLink); + // return OpenAppLinkAsync(appLinkInfo, token); + // } + + // public static Task OpenAppLinkAsync( + // AppLinkInfo appLinkInfo, + // CancellationToken token + // ) + // { + // } + + const string OPEN_FILE_JSON = "/open/json"; + const string OPEN_FILE_SQLITE = "/open/sqlite"; + public static AppLinkInfo IdentifyAppLinkInfo( + string appLink + ) + { + // Scheme部分はチェックしない + Uri uri = new(appLink); + string path = uri.LocalPath; + AppLinkInfo.FileType fileType = path switch + { + OPEN_FILE_JSON => AppLinkInfo.FileType.Json, + OPEN_FILE_SQLITE => AppLinkInfo.FileType.Sqlite, + _ => AppLinkInfo.FileType.Unknown, + }; + if (fileType == AppLinkInfo.FileType.Unknown) + { + throw new ArgumentException("Unknown file type"); + } + + if (string.IsNullOrEmpty(uri.Query)) + { + throw new ArgumentException("Query is empty"); + } + + NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); + string? versionQuery = queryParams["ver"]; + Version version = string.IsNullOrEmpty(versionQuery) ? new(1,0) : new(versionQuery); + + AppLinkInfo.CompressionType compressionType = queryParams["cmp"] switch + { + null or "" or "none" => AppLinkInfo.CompressionType.None, + "gzip" => AppLinkInfo.CompressionType.Gzip, + _ => throw new ArgumentException("Unknown compression type"), + }; + + AppLinkInfo.EncryptionType encryptionType = queryParams["enc"] switch + { + null or "" or "none" => AppLinkInfo.EncryptionType.None, + _ => throw new ArgumentException("Unknown encryption type"), + }; + + string? resourceUriQuery = queryParams["path"]; + string? dataQuery = queryParams["data"]; + string? decryptionKeyQuery = queryParams["key"]; + if (encryptionType != AppLinkInfo.EncryptionType.None &&string.IsNullOrEmpty(decryptionKeyQuery)) + { + throw new ArgumentException("DecryptionKey is required when EncryptionType is not None"); + } + + if (string.IsNullOrEmpty(resourceUriQuery) && string.IsNullOrEmpty(dataQuery)) + { + throw new ArgumentException("At least one of ResourceUri or Data must be set"); + } + + string? realtimeServiceUriQuery = queryParams["rts"]; + string? realtimeServiceToken = queryParams["rtk"]; + string? realtimeServiceVersion = queryParams["rtv"]; + + Uri? resourceUri = string.IsNullOrEmpty(resourceUriQuery) ? null : new Uri(resourceUriQuery); + byte[]? content = string.IsNullOrEmpty(dataQuery) ? null : Utils.UrlSafeBase64Decode(dataQuery); + byte[]? decryptionKey = string.IsNullOrEmpty(decryptionKeyQuery) ? null : Utils.UrlSafeBase64Decode(decryptionKeyQuery); + Uri? realtimeServiceUri = string.IsNullOrEmpty(realtimeServiceUriQuery) ? null : new Uri(realtimeServiceUriQuery); + + return new AppLinkInfo( + fileType, + compressionType, + encryptionType, + resourceUri, + content, + decryptionKey, + realtimeServiceUri, + realtimeServiceToken, + realtimeServiceVersion + ); + } +} diff --git a/TRViS.IO/RequestInfo/AppLinkInfo.cs b/TRViS.IO/RequestInfo/AppLinkInfo.cs new file mode 100644 index 00000000..4bbf345a --- /dev/null +++ b/TRViS.IO/RequestInfo/AppLinkInfo.cs @@ -0,0 +1,32 @@ +namespace TRViS.IO.RequestInfo; + +public record AppLinkInfo( + AppLinkInfo.FileType FileTypeInfo, + AppLinkInfo.CompressionType CompressionTypeInfo, + AppLinkInfo.EncryptionType EncryptionTypeInfo, + Uri? ResourceUri, + byte[]? Content, + byte[]? DecryptionKey, + Uri? RealtimeServiceUri, + string? RealtimeServiceToken, + string? RealtimeServiceVersion +) +{ + public enum FileType + { + Unknown, + Sqlite, + Json, + }; + + public enum CompressionType + { + None, + Gzip, + }; + + public enum EncryptionType + { + None, + }; +} diff --git a/TRViS.IO/Utils/UrlSafeBase64.cs b/TRViS.IO/Utils/UrlSafeBase64.cs new file mode 100644 index 00000000..ae6f397e --- /dev/null +++ b/TRViS.IO/Utils/UrlSafeBase64.cs @@ -0,0 +1,38 @@ +using System.Text; + +namespace TRViS.IO; + +public static partial class Utils +{ + public static string UrlSafeBase64Encode(byte[] input) + { + return Convert.ToBase64String(input) + .Replace('+', '-') + .Replace('/', '_') + .Replace("=", ""); + } + public static string UrlSafeBase64Encode(string input) + { + return UrlSafeBase64Encode(Encoding.UTF8.GetBytes(input)); + } + + public static byte[] UrlSafeBase64Decode(string input) + { + string incoming = input.Replace('-', '+').Replace('_', '/'); + switch (input.Length % 4) + { + case 2: + incoming += "=="; + break; + case 3: + incoming += "="; + break; + } + return Convert.FromBase64String(incoming); + } + + public static string UrlSafeBase64DecodeToString(string input) + { + return Encoding.UTF8.GetString(UrlSafeBase64Decode(input)); + } +} diff --git a/TRViS.IO/Utils.cs b/TRViS.IO/Utils/Utils.cs similarity index 90% rename from TRViS.IO/Utils.cs rename to TRViS.IO/Utils/Utils.cs index bf166466..80019348 100644 --- a/TRViS.IO/Utils.cs +++ b/TRViS.IO/Utils/Utils.cs @@ -1,6 +1,6 @@ namespace TRViS.IO; -internal static class Utils +public static partial class Utils { public static bool IsArrayEquals(T[]? arr1, T[]? arr2, IEqualityComparer? comparer = null) { From 6f0276278fd00ad08e4b26cc889b8a0d080640c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BC=B4=EF=BC=B2?= <31824852+TetsuOtter@users.noreply.github.com> Date: Sun, 19 May 2024 20:01:48 +0900 Subject: [PATCH 2/4] =?UTF-8?q?IdentifyAppLinkInfo=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs | 81 +++++++++++++------ TRViS.IO/OpenFile.cs | 23 ++++-- TRViS.IO/RequestInfo/AppLinkInfo.cs | 18 ++--- 3 files changed, 84 insertions(+), 38 deletions(-) diff --git a/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs b/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs index 4d5489c1..ac30d9e0 100644 --- a/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs +++ b/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs @@ -7,25 +7,16 @@ namespace TRViS.IO.Tests; public class OpenFileTests { - [OneTimeSetUp] - public async Task SetUp() - { - } - + static readonly Version expectedDefaultVersion = new(1, 0); + const string EMPTY_JSON_BASE64 = "e30K"; [Test] public void OnlyJsonPath() { - AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis:///app/open/json?path=https://example.com/db.json"); + AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis://app/open/json?path=https://example.com/db.json"); AppLinkInfo expected = new( AppLinkInfo.FileType.Json, - AppLinkInfo.CompressionType.None, - AppLinkInfo.EncryptionType.None, - new Uri("https://example.com/db.json"), - null, - null, - null, - null, - null + expectedDefaultVersion, + ResourceUri: new Uri("https://example.com/db.json") ); Assert.That(actual, Is.EqualTo(expected)); } @@ -33,18 +24,62 @@ public void OnlyJsonPath() [Test] public void OnlySqlitePath() { - AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis:///app/open/sqlite?path=https://example.com/trvis.db"); + AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis://app/open/sqlite?path=https://example.com/trvis.db"); AppLinkInfo expected = new( AppLinkInfo.FileType.Sqlite, - AppLinkInfo.CompressionType.None, - AppLinkInfo.EncryptionType.None, - new Uri("https://example.com/trvis.db"), - null, - null, - null, - null, - null + expectedDefaultVersion, + ResourceUri: new Uri("https://example.com/trvis.db") ); Assert.That(actual, Is.EqualTo(expected)); } + + [Test] + public void EmptyLink() + => Assert.Multiple(() => + { + Assert.That(() => OpenFile.IdentifyAppLinkInfo(""), Throws.Exception.TypeOf()); + Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/"), Throws.ArgumentException); + Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/"), Throws.ArgumentException); + }); + + [Test] + public void UnknownFileType() + => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/a"), Throws.ArgumentException); + + [Test] + public void WithoutQuery() + => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/json"), Throws.ArgumentException); + + [Test] + public void VersionTest() + { + Assert.That( + OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=&data={EMPTY_JSON_BASE64}").Version, + Is.EqualTo(expectedDefaultVersion), + "version empty => will be default" + ); + Assert.That( + OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=0.1&data={EMPTY_JSON_BASE64}").Version, + Is.EqualTo(new Version(0, 1)), + "version 0.1" + ); + Assert.That( + () => OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=2.0&data={EMPTY_JSON_BASE64}"), + Throws.ArgumentException, + "Unsupported version" + ); + } + + [Test] + public void WithoutPathAndData() + => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/json?path="), Throws.ArgumentException); + + [Test] + public void Path_WithoutScheme() + { + string appLink = "trvis://app/open/json?path=/abc/def"; + Uri? actual = OpenFile.IdentifyAppLinkInfo(appLink).ResourceUri; + Assert.That(actual, Is.Not.Null); + Assert.That(actual?.ToString(), Is.EqualTo("file:///abc/def")); + } } diff --git a/TRViS.IO/OpenFile.cs b/TRViS.IO/OpenFile.cs index 6717653e..8b6a0767 100644 --- a/TRViS.IO/OpenFile.cs +++ b/TRViS.IO/OpenFile.cs @@ -6,6 +6,7 @@ namespace TRViS.IO; public static class OpenFile { + static readonly Version supportedMaxVersion = new(1, 0); // public static Task OpenAppLinkAsync( // string appLink, // CancellationToken token @@ -30,14 +31,19 @@ string appLink { // Scheme部分はチェックしない Uri uri = new(appLink); + if (uri.Host != "app") + { + throw new ArgumentException("host is not `app`"); + } + string path = uri.LocalPath; - AppLinkInfo.FileType fileType = path switch + AppLinkInfo.FileType? fileType = path switch { OPEN_FILE_JSON => AppLinkInfo.FileType.Json, OPEN_FILE_SQLITE => AppLinkInfo.FileType.Sqlite, - _ => AppLinkInfo.FileType.Unknown, + _ => null, }; - if (fileType == AppLinkInfo.FileType.Unknown) + if (fileType is null) { throw new ArgumentException("Unknown file type"); } @@ -50,6 +56,9 @@ string appLink NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); string? versionQuery = queryParams["ver"]; Version version = string.IsNullOrEmpty(versionQuery) ? new(1,0) : new(versionQuery); + if (supportedMaxVersion < version) { + throw new ArgumentException("Unsupported version"); + } AppLinkInfo.CompressionType compressionType = queryParams["cmp"] switch { @@ -67,7 +76,7 @@ string appLink string? resourceUriQuery = queryParams["path"]; string? dataQuery = queryParams["data"]; string? decryptionKeyQuery = queryParams["key"]; - if (encryptionType != AppLinkInfo.EncryptionType.None &&string.IsNullOrEmpty(decryptionKeyQuery)) + if (encryptionType != AppLinkInfo.EncryptionType.None && string.IsNullOrEmpty(decryptionKeyQuery)) { throw new ArgumentException("DecryptionKey is required when EncryptionType is not None"); } @@ -79,7 +88,8 @@ string appLink string? realtimeServiceUriQuery = queryParams["rts"]; string? realtimeServiceToken = queryParams["rtk"]; - string? realtimeServiceVersion = queryParams["rtv"]; + string? realtimeServiceVersionQuery = queryParams["rtv"]; + Version? realtimeServiceVersion = string.IsNullOrEmpty(realtimeServiceVersionQuery) ? null : new(realtimeServiceVersionQuery); Uri? resourceUri = string.IsNullOrEmpty(resourceUriQuery) ? null : new Uri(resourceUriQuery); byte[]? content = string.IsNullOrEmpty(dataQuery) ? null : Utils.UrlSafeBase64Decode(dataQuery); @@ -87,7 +97,8 @@ string appLink Uri? realtimeServiceUri = string.IsNullOrEmpty(realtimeServiceUriQuery) ? null : new Uri(realtimeServiceUriQuery); return new AppLinkInfo( - fileType, + fileType.Value, + version, compressionType, encryptionType, resourceUri, diff --git a/TRViS.IO/RequestInfo/AppLinkInfo.cs b/TRViS.IO/RequestInfo/AppLinkInfo.cs index 4bbf345a..e2bdb468 100644 --- a/TRViS.IO/RequestInfo/AppLinkInfo.cs +++ b/TRViS.IO/RequestInfo/AppLinkInfo.cs @@ -2,19 +2,19 @@ namespace TRViS.IO.RequestInfo; public record AppLinkInfo( AppLinkInfo.FileType FileTypeInfo, - AppLinkInfo.CompressionType CompressionTypeInfo, - AppLinkInfo.EncryptionType EncryptionTypeInfo, - Uri? ResourceUri, - byte[]? Content, - byte[]? DecryptionKey, - Uri? RealtimeServiceUri, - string? RealtimeServiceToken, - string? RealtimeServiceVersion + Version Version, + AppLinkInfo.CompressionType CompressionTypeInfo = AppLinkInfo.CompressionType.None, + AppLinkInfo.EncryptionType EncryptionTypeInfo = AppLinkInfo.EncryptionType.None, + Uri? ResourceUri = null, + byte[]? Content = null, + byte[]? DecryptionKey = null, + Uri? RealtimeServiceUri = null, + string? RealtimeServiceToken = null, + Version? RealtimeServiceVersion = null ) { public enum FileType { - Unknown, Sqlite, Json, }; From 8d96bd353f782410e116d0b88594cff9d46d84b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BC=B4=EF=BC=B2?= <31824852+TetsuOtter@users.noreply.github.com> Date: Sun, 19 May 2024 20:05:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?AppLink=E8=A7=A3=E6=9E=90=E7=B3=BB=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=82=92AppLinkInfo=E5=81=B4=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceInfo/AppLinkInfo.Tests.cs | 80 ++++++++++++++++ TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs | 85 ---------------- TRViS.IO/OpenFile.cs | 92 ------------------ TRViS.IO/RequestInfo/AppLinkInfo.cs | 96 +++++++++++++++++++ 4 files changed, 176 insertions(+), 177 deletions(-) create mode 100644 TRViS.IO.Tests/ResourceInfo/AppLinkInfo.Tests.cs delete mode 100644 TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs diff --git a/TRViS.IO.Tests/ResourceInfo/AppLinkInfo.Tests.cs b/TRViS.IO.Tests/ResourceInfo/AppLinkInfo.Tests.cs new file mode 100644 index 00000000..c2c7ebd1 --- /dev/null +++ b/TRViS.IO.Tests/ResourceInfo/AppLinkInfo.Tests.cs @@ -0,0 +1,80 @@ +namespace TRViS.IO.RequestInfo.Tests; + +public class AppLinkInfoTests +{ + static readonly Version expectedDefaultVersion = new(1, 0); + const string EMPTY_JSON_BASE64 = "e30K"; + [Test] + public void OnlyJsonPath() + { + AppLinkInfo actual = AppLinkInfo.FromAppLink("trvis://app/open/json?path=https://example.com/db.json"); + AppLinkInfo expected = new( + AppLinkInfo.FileType.Json, + expectedDefaultVersion, + ResourceUri: new Uri("https://example.com/db.json") + ); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void OnlySqlitePath() + { + AppLinkInfo actual = AppLinkInfo.FromAppLink("trvis://app/open/sqlite?path=https://example.com/trvis.db"); + AppLinkInfo expected = new( + AppLinkInfo.FileType.Sqlite, + expectedDefaultVersion, + ResourceUri: new Uri("https://example.com/trvis.db") + ); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void EmptyLink() + => Assert.Multiple(() => + { + Assert.That(() => AppLinkInfo.FromAppLink(""), Throws.Exception.TypeOf()); + Assert.That(() => AppLinkInfo.FromAppLink("trvis://app/"), Throws.ArgumentException); + Assert.That(() => AppLinkInfo.FromAppLink("trvis://app/open/"), Throws.ArgumentException); + }); + + [Test] + public void UnknownFileType() + => Assert.That(() => AppLinkInfo.FromAppLink("trvis://app/open/a"), Throws.ArgumentException); + + [Test] + public void WithoutQuery() + => Assert.That(() => AppLinkInfo.FromAppLink("trvis://app/open/json"), Throws.ArgumentException); + + [Test] + public void VersionTest() + { + Assert.That( + AppLinkInfo.FromAppLink($"trvis://app/open/json?ver=&data={EMPTY_JSON_BASE64}").Version, + Is.EqualTo(expectedDefaultVersion), + "version empty => will be default" + ); + Assert.That( + AppLinkInfo.FromAppLink($"trvis://app/open/json?ver=0.1&data={EMPTY_JSON_BASE64}").Version, + Is.EqualTo(new Version(0, 1)), + "version 0.1" + ); + Assert.That( + () => AppLinkInfo.FromAppLink($"trvis://app/open/json?ver=2.0&data={EMPTY_JSON_BASE64}"), + Throws.ArgumentException, + "Unsupported version" + ); + } + + [Test] + public void WithoutPathAndData() + => Assert.That(() => AppLinkInfo.FromAppLink("trvis://app/open/json?path="), Throws.ArgumentException); + + [Test] + public void Path_WithoutScheme() + { + string appLink = "trvis://app/open/json?path=/abc/def"; + Uri? actual = AppLinkInfo.FromAppLink(appLink).ResourceUri; + Assert.That(actual, Is.Not.Null); + Assert.That(actual?.ToString(), Is.EqualTo("file:///abc/def")); + } +} diff --git a/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs b/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs deleted file mode 100644 index ac30d9e0..00000000 --- a/TRViS.IO.Tests/ResourceInfo/OpenFile.Tests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Reflection; - -using TRViS.IO.Models.DB; -using TRViS.IO.RequestInfo; - -namespace TRViS.IO.Tests; - -public class OpenFileTests -{ - static readonly Version expectedDefaultVersion = new(1, 0); - const string EMPTY_JSON_BASE64 = "e30K"; - [Test] - public void OnlyJsonPath() - { - AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis://app/open/json?path=https://example.com/db.json"); - AppLinkInfo expected = new( - AppLinkInfo.FileType.Json, - expectedDefaultVersion, - ResourceUri: new Uri("https://example.com/db.json") - ); - Assert.That(actual, Is.EqualTo(expected)); - } - - [Test] - public void OnlySqlitePath() - { - AppLinkInfo actual = OpenFile.IdentifyAppLinkInfo("trvis://app/open/sqlite?path=https://example.com/trvis.db"); - AppLinkInfo expected = new( - AppLinkInfo.FileType.Sqlite, - expectedDefaultVersion, - ResourceUri: new Uri("https://example.com/trvis.db") - ); - Assert.That(actual, Is.EqualTo(expected)); - } - - [Test] - public void EmptyLink() - => Assert.Multiple(() => - { - Assert.That(() => OpenFile.IdentifyAppLinkInfo(""), Throws.Exception.TypeOf()); - Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/"), Throws.ArgumentException); - Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/"), Throws.ArgumentException); - }); - - [Test] - public void UnknownFileType() - => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/a"), Throws.ArgumentException); - - [Test] - public void WithoutQuery() - => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/json"), Throws.ArgumentException); - - [Test] - public void VersionTest() - { - Assert.That( - OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=&data={EMPTY_JSON_BASE64}").Version, - Is.EqualTo(expectedDefaultVersion), - "version empty => will be default" - ); - Assert.That( - OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=0.1&data={EMPTY_JSON_BASE64}").Version, - Is.EqualTo(new Version(0, 1)), - "version 0.1" - ); - Assert.That( - () => OpenFile.IdentifyAppLinkInfo($"trvis://app/open/json?ver=2.0&data={EMPTY_JSON_BASE64}"), - Throws.ArgumentException, - "Unsupported version" - ); - } - - [Test] - public void WithoutPathAndData() - => Assert.That(() => OpenFile.IdentifyAppLinkInfo("trvis://app/open/json?path="), Throws.ArgumentException); - - [Test] - public void Path_WithoutScheme() - { - string appLink = "trvis://app/open/json?path=/abc/def"; - Uri? actual = OpenFile.IdentifyAppLinkInfo(appLink).ResourceUri; - Assert.That(actual, Is.Not.Null); - Assert.That(actual?.ToString(), Is.EqualTo("file:///abc/def")); - } -} diff --git a/TRViS.IO/OpenFile.cs b/TRViS.IO/OpenFile.cs index 8b6a0767..aeb0b2b0 100644 --- a/TRViS.IO/OpenFile.cs +++ b/TRViS.IO/OpenFile.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using System.Web; -using TRViS.IO.RequestInfo; - namespace TRViS.IO; public static class OpenFile { - static readonly Version supportedMaxVersion = new(1, 0); // public static Task OpenAppLinkAsync( // string appLink, // CancellationToken token @@ -22,91 +17,4 @@ public static class OpenFile // ) // { // } - - const string OPEN_FILE_JSON = "/open/json"; - const string OPEN_FILE_SQLITE = "/open/sqlite"; - public static AppLinkInfo IdentifyAppLinkInfo( - string appLink - ) - { - // Scheme部分はチェックしない - Uri uri = new(appLink); - if (uri.Host != "app") - { - throw new ArgumentException("host is not `app`"); - } - - string path = uri.LocalPath; - AppLinkInfo.FileType? fileType = path switch - { - OPEN_FILE_JSON => AppLinkInfo.FileType.Json, - OPEN_FILE_SQLITE => AppLinkInfo.FileType.Sqlite, - _ => null, - }; - if (fileType is null) - { - throw new ArgumentException("Unknown file type"); - } - - if (string.IsNullOrEmpty(uri.Query)) - { - throw new ArgumentException("Query is empty"); - } - - NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); - string? versionQuery = queryParams["ver"]; - Version version = string.IsNullOrEmpty(versionQuery) ? new(1,0) : new(versionQuery); - if (supportedMaxVersion < version) { - throw new ArgumentException("Unsupported version"); - } - - AppLinkInfo.CompressionType compressionType = queryParams["cmp"] switch - { - null or "" or "none" => AppLinkInfo.CompressionType.None, - "gzip" => AppLinkInfo.CompressionType.Gzip, - _ => throw new ArgumentException("Unknown compression type"), - }; - - AppLinkInfo.EncryptionType encryptionType = queryParams["enc"] switch - { - null or "" or "none" => AppLinkInfo.EncryptionType.None, - _ => throw new ArgumentException("Unknown encryption type"), - }; - - string? resourceUriQuery = queryParams["path"]; - string? dataQuery = queryParams["data"]; - string? decryptionKeyQuery = queryParams["key"]; - if (encryptionType != AppLinkInfo.EncryptionType.None && string.IsNullOrEmpty(decryptionKeyQuery)) - { - throw new ArgumentException("DecryptionKey is required when EncryptionType is not None"); - } - - if (string.IsNullOrEmpty(resourceUriQuery) && string.IsNullOrEmpty(dataQuery)) - { - throw new ArgumentException("At least one of ResourceUri or Data must be set"); - } - - string? realtimeServiceUriQuery = queryParams["rts"]; - string? realtimeServiceToken = queryParams["rtk"]; - string? realtimeServiceVersionQuery = queryParams["rtv"]; - Version? realtimeServiceVersion = string.IsNullOrEmpty(realtimeServiceVersionQuery) ? null : new(realtimeServiceVersionQuery); - - Uri? resourceUri = string.IsNullOrEmpty(resourceUriQuery) ? null : new Uri(resourceUriQuery); - byte[]? content = string.IsNullOrEmpty(dataQuery) ? null : Utils.UrlSafeBase64Decode(dataQuery); - byte[]? decryptionKey = string.IsNullOrEmpty(decryptionKeyQuery) ? null : Utils.UrlSafeBase64Decode(decryptionKeyQuery); - Uri? realtimeServiceUri = string.IsNullOrEmpty(realtimeServiceUriQuery) ? null : new Uri(realtimeServiceUriQuery); - - return new AppLinkInfo( - fileType.Value, - version, - compressionType, - encryptionType, - resourceUri, - content, - decryptionKey, - realtimeServiceUri, - realtimeServiceToken, - realtimeServiceVersion - ); - } } diff --git a/TRViS.IO/RequestInfo/AppLinkInfo.cs b/TRViS.IO/RequestInfo/AppLinkInfo.cs index e2bdb468..3f04bbaf 100644 --- a/TRViS.IO/RequestInfo/AppLinkInfo.cs +++ b/TRViS.IO/RequestInfo/AppLinkInfo.cs @@ -1,3 +1,6 @@ +using System.Collections.Specialized; +using System.Web; + namespace TRViS.IO.RequestInfo; public record AppLinkInfo( @@ -29,4 +32,97 @@ public enum EncryptionType { None, }; + + static readonly Version supportedMaxVersion = new(1, 0); + const string OPEN_FILE_JSON = "/open/json"; + const string OPEN_FILE_SQLITE = "/open/sqlite"; + + public static AppLinkInfo FromAppLink( + string appLink + ) + => AppLinkInfo.FromAppLink(new Uri(appLink)); + + public static AppLinkInfo FromAppLink( + Uri uri + ) + { + // Scheme部分はチェックしない + if (uri.Host != "app") + { + throw new ArgumentException("host is not `app`"); + } + + string path = uri.LocalPath; + AppLinkInfo.FileType? fileType = path switch + { + OPEN_FILE_JSON => AppLinkInfo.FileType.Json, + OPEN_FILE_SQLITE => AppLinkInfo.FileType.Sqlite, + _ => null, + }; + if (fileType is null) + { + throw new ArgumentException("Unknown file type"); + } + + if (string.IsNullOrEmpty(uri.Query)) + { + throw new ArgumentException("Query is empty"); + } + + NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); + string? versionQuery = queryParams["ver"]; + Version version = string.IsNullOrEmpty(versionQuery) ? new(1,0) : new(versionQuery); + if (supportedMaxVersion < version) { + throw new ArgumentException("Unsupported version"); + } + + AppLinkInfo.CompressionType compressionType = queryParams["cmp"] switch + { + null or "" or "none" => AppLinkInfo.CompressionType.None, + "gzip" => AppLinkInfo.CompressionType.Gzip, + _ => throw new ArgumentException("Unknown compression type"), + }; + + AppLinkInfo.EncryptionType encryptionType = queryParams["enc"] switch + { + null or "" or "none" => AppLinkInfo.EncryptionType.None, + _ => throw new ArgumentException("Unknown encryption type"), + }; + + string? resourceUriQuery = queryParams["path"]; + string? dataQuery = queryParams["data"]; + string? decryptionKeyQuery = queryParams["key"]; + if (encryptionType != AppLinkInfo.EncryptionType.None && string.IsNullOrEmpty(decryptionKeyQuery)) + { + throw new ArgumentException("DecryptionKey is required when EncryptionType is not None"); + } + + if (string.IsNullOrEmpty(resourceUriQuery) && string.IsNullOrEmpty(dataQuery)) + { + throw new ArgumentException("At least one of ResourceUri or Data must be set"); + } + + string? realtimeServiceUriQuery = queryParams["rts"]; + string? realtimeServiceToken = queryParams["rtk"]; + string? realtimeServiceVersionQuery = queryParams["rtv"]; + Version? realtimeServiceVersion = string.IsNullOrEmpty(realtimeServiceVersionQuery) ? null : new(realtimeServiceVersionQuery); + + Uri? resourceUri = string.IsNullOrEmpty(resourceUriQuery) ? null : new Uri(resourceUriQuery); + byte[]? content = string.IsNullOrEmpty(dataQuery) ? null : Utils.UrlSafeBase64Decode(dataQuery); + byte[]? decryptionKey = string.IsNullOrEmpty(decryptionKeyQuery) ? null : Utils.UrlSafeBase64Decode(decryptionKeyQuery); + Uri? realtimeServiceUri = string.IsNullOrEmpty(realtimeServiceUriQuery) ? null : new Uri(realtimeServiceUriQuery); + + return new AppLinkInfo( + fileType.Value, + version, + compressionType, + encryptionType, + resourceUri, + content, + decryptionKey, + realtimeServiceUri, + realtimeServiceToken, + realtimeServiceVersion + ); + } } From 9b349eae0400e0452eefdaba74c49521622070f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BC=B4=EF=BC=B2?= <31824852+TetsuOtter@users.noreply.github.com> Date: Mon, 20 May 2024 00:41:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?AppLink=E3=81=AE=E3=83=8F=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=B0=E3=82=92TRViS.IO=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E5=81=B4=E3=81=AB=E5=AF=84?= =?UTF-8?q?=E3=81=9B=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TRViS.IO/OpenFile.cs | 170 ++++++- TRViS.IO/TRViS.IO.csproj | 1 + TRViS/App.xaml.cs | 10 +- TRViS/Platforms/iOS/AppDelegate.cs | 2 +- TRViS/RootPages/SelectOnlineResourcePopup.cs | 12 +- TRViS/ViewModels/AppViewModel.AppLink.cs | 507 +++++++------------ 6 files changed, 344 insertions(+), 358 deletions(-) diff --git a/TRViS.IO/OpenFile.cs b/TRViS.IO/OpenFile.cs index aeb0b2b0..cf3a92c0 100644 --- a/TRViS.IO/OpenFile.cs +++ b/TRViS.IO/OpenFile.cs @@ -1,20 +1,158 @@ +using System.Net; +using TRViS.IO.RequestInfo; + namespace TRViS.IO; -public static class OpenFile +public class OpenFile(HttpClient httpClient) { - // public static Task OpenAppLinkAsync( - // string appLink, - // CancellationToken token - // ) - // { - // AppLinkInfo appLinkInfo = IdentifyAppLinkInfo(appLink); - // return OpenAppLinkAsync(appLinkInfo, token); - // } - - // public static Task OpenAppLinkAsync( - // AppLinkInfo appLinkInfo, - // CancellationToken token - // ) - // { - // } + public delegate Task CanContinueWhenResourceUriContainsIpDelegate(IPAddress ip, CancellationToken token); + public delegate Task CanContinueWhenHeadRequestSuccessDelegate(HttpResponseMessage response, CancellationToken token); + + public CanContinueWhenHeadRequestSuccessDelegate? CanContinueWhenHeadRequestSuccess { get; set; } = null; + public CanContinueWhenResourceUriContainsIpDelegate? CanContinueWhenResourceUriContainsIp { get; set; } = null; + private readonly HttpClient HttpClient = httpClient; + + public Task OpenAppLinkAsync( + string appLink, + CancellationToken token + ) + { + AppLinkInfo appLinkInfo = AppLinkInfo.FromAppLink(appLink); + return OpenAppLinkAsync( + appLinkInfo, + token + ); + } + + public Task OpenAppLinkAsync( + AppLinkInfo appLinkInfo, + CancellationToken token + ) + { + try { + if (appLinkInfo.ResourceUri is not null) { + return OpenAppLink_PathTypeAsync( + appLinkInfo, + token + ); + } + } catch (Exception e) { + // Contentがセットされている場合は、Contentでの処理を試みる + // (例外が握りつぶされてしまうため、そこは何とかしたい) + if (appLinkInfo.Content is null || appLinkInfo.Content.Length == 0) { + return Task.FromException(e); + } + } + + if (appLinkInfo.Content is not null) { + return Task.FromResult(OpenAppLink_DataType(appLinkInfo, token)); + } + + throw new ArgumentException("ResourceUri and Content are null"); + } + + private async Task OpenAppLink_PathTypeAsync( + AppLinkInfo appLinkInfo, + CancellationToken token + ) + { + if (appLinkInfo.ResourceUri is null) { + throw new ArgumentException("ResourceUri is null"); + } + + token.ThrowIfCancellationRequested(); + + Uri uri = appLinkInfo.ResourceUri; + return uri.Scheme switch + { + "file" => appLinkInfo.FileTypeInfo switch + { + AppLinkInfo.FileType.Json => await LoaderJson.InitFromFileAsync(uri.LocalPath, token), + AppLinkInfo.FileType.Sqlite => new LoaderSQL(uri.LocalPath), + _ => throw new ArgumentException("Unknown file type"), + }, + "http" or "https" => await OpenAppLink_HttpTypeAsync( + appLinkInfo, + uri, + token + ), + _ => throw new ArgumentException("Unknown scheme"), + }; + } + + private static ILoader OpenAppLink_DataType( + AppLinkInfo appLinkInfo, + CancellationToken token + ) + { + if (appLinkInfo.Content is null || appLinkInfo.Content.Length == 0) { + throw new ArgumentException("Content is null or empty"); + } + + if (appLinkInfo.FileTypeInfo != AppLinkInfo.FileType.Json) { + throw new ArgumentException("This file type is not supported"); + } + + token.ThrowIfCancellationRequested(); + + return LoaderJson.InitFromBytes(appLinkInfo.Content); + } + + private async Task OpenAppLink_HttpTypeAsync( + AppLinkInfo appLinkInfo, + Uri uri, + CancellationToken token + ) + { + if (appLinkInfo.FileTypeInfo != AppLinkInfo.FileType.Json) { + throw new ArgumentException("This file type is not supported"); + } + + bool isHostIp = uri.HostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6; + if (isHostIp + && this.CanContinueWhenResourceUriContainsIp is not null + && IPAddress.TryParse(uri.Host, out IPAddress? ip) + && !await this.CanContinueWhenResourceUriContainsIp(ip, token)) { + throw new OperationCanceledException("cancelled by CanContinueWhenResourceUriContainsIp"); + } + + token.ThrowIfCancellationRequested(); + + { + using HttpRequestMessage request = new(HttpMethod.Head, uri); + using HttpResponseMessage result = await this.HttpClient.SendAsync(request, token); + + if (!result.IsSuccessStatusCode) { + throw new HttpRequestException( + $"HEAD request to ${uri} failed with code: {result.StatusCode}", + inner: null, + statusCode: result.StatusCode + ); + } + + if (this.CanContinueWhenHeadRequestSuccess is not null + && !await this.CanContinueWhenHeadRequestSuccess(result, token)) { + throw new OperationCanceledException("cancelled by CanContinueWhenHeadRequestSuccess"); + } + } + + { + using HttpRequestMessage request = new(HttpMethod.Get, uri); + using HttpResponseMessage result = await this.HttpClient.SendAsync(request, token); + if (!result.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"GET request to ${uri} failed with code: {result.StatusCode}", + inner: null, + statusCode: result.StatusCode + ); + } + + await using Stream stream = result.Content.ReadAsStream(token); + // メソッドの先頭でJSONかチェックしているため、ここにはJSONしか来ない + ILoader loader = await LoaderJson.InitFromStreamAsync(stream, token); + + return loader; + } + } } diff --git a/TRViS.IO/TRViS.IO.csproj b/TRViS.IO/TRViS.IO.csproj index 486fd858..b05b033f 100644 --- a/TRViS.IO/TRViS.IO.csproj +++ b/TRViS.IO/TRViS.IO.csproj @@ -2,6 +2,7 @@ net6.0 + 12 enable enable diff --git a/TRViS/App.xaml.cs b/TRViS/App.xaml.cs index 4bc12038..1b300ac1 100644 --- a/TRViS/App.xaml.cs +++ b/TRViS/App.xaml.cs @@ -46,9 +46,9 @@ private void WindowOnDestroying(object? sender, EventArgs e) } } - static Uri? AppLinkUri { get; set; } + static string? AppLinkUri { get; set; } - public static void SetAppLinkUri(Uri uri) + public static void SetAppLinkUri(string uri) { logger.Info("AppLinkUri: {0}", uri); @@ -75,7 +75,7 @@ protected override void OnAppLinkRequestReceived(Uri uri) logger.Info("AppLinkUri: {0}", uri); - HandleAppLinkUriAsync(uri); + HandleAppLinkUriAsync(uri.ToString()); } protected override void OnStart() @@ -90,9 +90,9 @@ protected override void OnStart() } } - static Task HandleAppLinkUriAsync(Uri uri) + static Task HandleAppLinkUriAsync(string uri) => HandleAppLinkUriAsync(uri, CancellationToken.None); - static Task HandleAppLinkUriAsync(Uri uri, CancellationToken cancellationToken) + static Task HandleAppLinkUriAsync(string uri, CancellationToken cancellationToken) { return InstanceManager.AppViewModel.HandleAppLinkUriAsync(uri, cancellationToken).ContinueWith(t => { diff --git a/TRViS/Platforms/iOS/AppDelegate.cs b/TRViS/Platforms/iOS/AppDelegate.cs index a0f37b21..5eea6dba 100644 --- a/TRViS/Platforms/iOS/AppDelegate.cs +++ b/TRViS/Platforms/iOS/AppDelegate.cs @@ -12,7 +12,7 @@ public class AppDelegate : MauiUIApplicationDelegate public override bool OpenUrl(UIApplication application, NSUrl url, NSDictionary options) { if (!string.IsNullOrEmpty(url.AbsoluteString)) - App.SetAppLinkUri(new(url.AbsoluteString)); + App.SetAppLinkUri(url.AbsoluteString); return base.OpenUrl(application, url, options); } } diff --git a/TRViS/RootPages/SelectOnlineResourcePopup.cs b/TRViS/RootPages/SelectOnlineResourcePopup.cs index 1c650dc7..9f90a960 100644 --- a/TRViS/RootPages/SelectOnlineResourcePopup.cs +++ b/TRViS/RootPages/SelectOnlineResourcePopup.cs @@ -1,6 +1,5 @@ using CommunityToolkit.Maui.Views; - -using TRViS.ViewModels; +using TRViS.IO.RequestInfo; namespace TRViS.RootPages; @@ -30,7 +29,7 @@ public class SelectOnlineResourcePopup : Popup IsSpellCheckEnabled = false, IsTextPredictionEnabled = false, Keyboard = Keyboard.Url, - MaxLength = AppViewModel.PATH_LENGTH_MAX, + MaxLength = 1024, ReturnType = ReturnType.Go, }; @@ -163,7 +162,12 @@ private async void DoLoad() return; } - bool execResult = await InstanceManager.AppViewModel.LoadExternalFileAsync(UrlInput.Text, AppLinkType.Unknown, CancellationToken.None); + AppLinkInfo appLinkInfo = new( + AppLinkInfo.FileType.Json, + Version: new(1,0), + ResourceUri: new(UrlInput.Text) + ); + bool execResult = await InstanceManager.AppViewModel.HandleAppLinkUriAsync(appLinkInfo, CancellationToken.None); if (execResult) { await CloseAsync(); diff --git a/TRViS/ViewModels/AppViewModel.AppLink.cs b/TRViS/ViewModels/AppViewModel.AppLink.cs index 022ac0f3..39d624f9 100644 --- a/TRViS/ViewModels/AppViewModel.AppLink.cs +++ b/TRViS/ViewModels/AppViewModel.AppLink.cs @@ -1,10 +1,10 @@ -using System.Collections.Specialized; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Web; using TRViS.IO; +using TRViS.IO.RequestInfo; using TRViS.Services; namespace TRViS.ViewModels; @@ -22,387 +22,230 @@ public partial class AppViewModel private readonly List _ExternalResourceUrlHistory; public IReadOnlyList ExternalResourceUrlHistory => _ExternalResourceUrlHistory; - internal const int PATH_LENGTH_MAX = 1024; - - const string OPEN_FILE_JSON = "/open/json"; - const string OPEN_FILE_SQLITE = "/open/sqlite"; - public async Task HandleAppLinkUriAsync(Uri uri, CancellationToken token) + public async Task HandleAppLinkUriAsync(string uri, CancellationToken token) { - if (uri.Host != "app") - { - logger.Warn("Uri.Host is not `app`: {0}", uri.Host); - return; - } - AppLinkType appLinkType = uri.LocalPath switch - { - OPEN_FILE_JSON => AppLinkType.OpenFileJson, - OPEN_FILE_SQLITE => AppLinkType.OpenFileSQLite, - _ => AppLinkType.Unknown, - }; - // JSONのみ実装済み - if (appLinkType == AppLinkType.Unknown || appLinkType != AppLinkType.OpenFileJson) - { - logger.Warn("Uri.LocalPath is not valid: {0}", uri.LocalPath); - return; - } - if (string.IsNullOrEmpty(uri.Query)) - { - logger.Warn("Uri.Query is null or empty"); - return; - } - NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); - string? path = queryParams["path"]; - if (!string.IsNullOrEmpty(path)) - { - await LoadExternalFileFromUrlAsync(uri.Query, appLinkType, token); - return; - } - - string? data_UrlSafeBase64Str = queryParams["data"]; - if (!string.IsNullOrEmpty(data_UrlSafeBase64Str)) + AppLinkInfo appLinkInfo; + try { - await LoadExternalFileFromDataAsync(data_UrlSafeBase64Str, appLinkType, token); - return; + appLinkInfo = AppLinkInfo.FromAppLink(uri); } - } - static bool IsPrivateIpv4(IPAddress ip) - { - if (ip.AddressFamily != AddressFamily.InterNetwork) + catch(Exception ex) { + logger.Warn(ex, "AppLinkInfo Identify Failed"); + await Utils.DisplayAlert("Cannot Open File", "AppLinkInfo Identify Failed\n" + ex.Message, "OK"); return false; } - byte[] bytes = ip.GetAddressBytes(); - return bytes[0] switch - { - 10 => true, - 172 => 16 <= bytes[1] && bytes[1] <= 31, - 192 => bytes[1] == 168, - _ => false, - }; - } + token.ThrowIfCancellationRequested(); - static bool IsSameNetwork(byte[] remoteIp, IPAddress localIp, IPAddress subnetMask) - => IsSameNetwork(remoteIp, localIp.GetAddressBytes(), subnetMask.GetAddressBytes()); - static bool IsSameNetwork(byte[] remoteIp, byte[] localIp, byte[] subnetMask) - { - byte[] remoteNetworkAddress = remoteIp.Select((x, i) => (byte)(x & subnetMask[i])).ToArray(); - byte[] localNetworkAddress = localIp.Select((x, i) => (byte)(x & subnetMask[i])).ToArray(); - return remoteNetworkAddress.SequenceEqual(localNetworkAddress); + return await HandleAppLinkUriAsync(appLinkInfo, token); } - - public async Task LoadExternalFileFromUrlAsync(string path, AppLinkType appLinkType, CancellationToken token) + public async Task HandleAppLinkUriAsync(AppLinkInfo appLinkInfo, CancellationToken token) { - if (string.IsNullOrEmpty(path)) - { - logger.Warn("Uri.Query is not valid (query[`path`] not found): {0}", path); - await Utils.DisplayAlert("Cannot Open File", $"URL is Empty", "OK"); - return false; - } - - Uri pathUri; - try - { - pathUri = new(path); - } - catch (UriFormatException ex) - { - logger.Error(ex, "UriFormatException"); - await Utils.DisplayAlert("Cannot Open File", ex.Message, "OK"); - return false; - } - - if (pathUri.Scheme != "https" && pathUri.Scheme != "http") - { - logger.Warn("path is not valid (not HTTPS nor HTTP): {0}", path); - return false; - } - - bool openFile = await Utils.DisplayAlert("外部ファイルを開く", $"ファイル `{path}` を開きますか?", "はい", "いいえ"); - logger.Info("Uri: {0} -> openFile: {1}", path, openFile); - if (!openFile) - { - return false; + string? decodedUrl = null; + if (appLinkInfo.ResourceUri is not null && appLinkInfo.ResourceUri.Scheme is "http" or "https") + { + string path = appLinkInfo.ResourceUri.ToString(); + decodedUrl = HttpUtility.UrlDecode(path); + + bool openRemoteFileCheckResult = await Utils.DisplayAlert( + "外部ファイルを開く", + $"ファイル `{decodedUrl}` を開きますか?", + "はい", + "いいえ" + ); + logger.Info("Uri: {0} -> openFile: {1}", path, openRemoteFileCheckResult); + if (!openRemoteFileCheckResult) + { + return false; + } } - return await LoadExternalFileAsync(path, appLinkType, token); - } + token.ThrowIfCancellationRequested(); - public async Task LoadExternalFileFromDataAsync(string urlSafeBase64Str, AppLinkType appLinkType, CancellationToken token) - { - if (string.IsNullOrEmpty(urlSafeBase64Str)) + OpenFile openFile = new(InstanceManager.HttpClient) { - logger.Warn("Uri.Query is not valid (query[`data`] not found): {0}", urlSafeBase64Str); - await Utils.DisplayAlert("Cannot Open File", $"Data is Empty", "OK"); - return false; - } - - byte[] dataBytes; + CanContinueWhenResourceUriContainsIp = CanContinueWhenResourceUriContainsIpHandler, + CanContinueWhenHeadRequestSuccess = CanContinueWhenHeadRequestSuccessHandler + }; + ILoader loader; try { - dataBytes = Utils.UrlSafeBase64Decode(urlSafeBase64Str); + loader = await openFile.OpenAppLinkAsync(appLinkInfo, token); } catch (Exception ex) { - logger.Error(ex, "UrlSafeBase64Decode Failed"); - await Utils.DisplayAlert("Cannot Load File", ex.Message, "OK"); - return false; - } - - if (dataBytes.Length == 0) - { - logger.Warn("dataBytes.Length is 0"); - await Utils.DisplayAlert("Cannot Load File", $"Data is Empty", "OK"); - return false; - } + if (ex is OperationCanceledException) + { + logger.Debug(ex, "Operation Canceled"); + return false; + } - try - { - switch (appLinkType) + logger.Error(ex, "OpenAppLinkAsync Failed"); + if (appLinkInfo.ResourceUri?.HostNameType == UriHostNameType.IPv4 + && ex is TaskCanceledException + && ex.InnerException is TimeoutException) { - case AppLinkType.OpenFileJson: - Loader = LoaderJson.InitFromBytes(dataBytes); - return true; - case AppLinkType.OpenFileSQLite: - logger.Error("Not Implemented"); - await Utils.DisplayAlert("Not Implemented", "Open External SQLite file is Not Implemented", "OK"); - return false; - default: - logger.Warn("Uri.LocalPath is not valid: {0}", appLinkType); - return false; + logger.Error(ex, "Timeout Error"); + await Utils.DisplayAlert( + "接続できませんでした (Timeout)", + "接続先がパソコンの場合は、\n" + + "接続先が同じネットワークに属しているか、\n" + + "またファイアウォールの例外設定がきちんと今のネットワークに行われているか\n" + + "を確認してください。", + "OK" + ); + } + else + { + await Utils.DisplayAlert("Cannot Open File", "OpenAppLinkAsync Failed\n" + ex.Message, "OK"); } - } - catch (Exception ex) - { - logger.Error(ex, "Cannot load file"); - await Utils.DisplayAlert("Cannot Load File", ex.Message, "OK"); return false; } - } - public async Task LoadExternalFileAsync(string path, AppLinkType appLinkType, CancellationToken token) - { - if (string.IsNullOrEmpty(path)) - { - logger.Warn("path is null or empty"); - await Utils.DisplayAlert("Cannot Open File", $"File Path is Empty", "OK"); - return false; - } + ILoader? lastLoader = this.Loader; + this.Loader = loader; + logger.Info("Loader Initialized"); + lastLoader?.Dispose(); + logger.Debug("Last Loader Disposed"); - string decodedUrl = HttpUtility.UrlDecode(path); - string encodedUrl; - if (path != decodedUrl) + if (decodedUrl is not null) { - logger.Trace("path: '{0}' -> decodedUrl: '{1}'", path, decodedUrl); - encodedUrl = path; - } - else - { - #pragma warning disable SYSLIB0013 - encodedUrl = Uri.EscapeUriString(path); - #pragma warning restore SYSLIB0013 - logger.Trace("path: '{0}' -> encodedUrl: '{1}'", path, encodedUrl); - } + // pathがListに存在しない場合は、Removeは何も実行されずに終了する + _ExternalResourceUrlHistory.Remove(decodedUrl); + if (EXTERNAL_RESOURCE_URL_HISTORY_MAX <= _ExternalResourceUrlHistory.Count) + { + int removeCount = _ExternalResourceUrlHistory.Count - EXTERNAL_RESOURCE_URL_HISTORY_MAX + 1; + logger.Debug("ExternalResourceUrlHistory.Count is over EXTERNAL_RESOURCE_URL_HISTORY_MAX ({0} <= {1}) -> remove {2} items", EXTERNAL_RESOURCE_URL_HISTORY_MAX, _ExternalResourceUrlHistory.Count, removeCount); + _ExternalResourceUrlHistory.RemoveRange(0, removeCount); + } - if (PATH_LENGTH_MAX < decodedUrl.Length) - { - logger.Warn("path is too long: {0} < {1}", PATH_LENGTH_MAX, decodedUrl.Length); - await Utils.DisplayAlert("Cannot Open File", $"File Path is too long: {PATH_LENGTH_MAX} < {decodedUrl.Length}", "OK"); - return false; + _ExternalResourceUrlHistory.Add(decodedUrl); + AppPreferenceService.SetToJson(AppPreferenceKeys.ExternalResourceUrlHistory, _ExternalResourceUrlHistory); } - Uri uri = new(encodedUrl); - IPAddress? remoteIp = null; - bool isRemoteIpv4Ip = uri.HostNameType == UriHostNameType.IPv4 - && IPAddress.TryParse(uri.Host, out remoteIp); - bool isRemoteIpv4PrivateIp = isRemoteIpv4Ip && IsPrivateIpv4(remoteIp!); - logger.Trace("uri.HostNameType: {0}, uri.Host: {1}, isRemoteIpv4Ip: {2}, isRemoteIpv4PrivateIp: {3}", uri.HostNameType, uri.Host, isRemoteIpv4Ip, isRemoteIpv4PrivateIp); - if (isRemoteIpv4PrivateIp) - { - bool isSameNetwork = false; - List myIpList = []; - byte[] remoteIpAddress = remoteIp!.GetAddressBytes(); - foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces()) - { - logger.Trace("adapter: {0} ({1})", adapter.Name, adapter.Description); - foreach (UnicastIPAddressInformation unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) - { - logger.Trace(" unicastIPAddressInformation: {0} / {1}", unicastIPAddressInformation.Address, unicastIPAddressInformation.IPv4Mask); - if (unicastIPAddressInformation.Address.AddressFamily != AddressFamily.InterNetwork) - continue; + await Utils.DisplayAlert("Success!", "ファイルの読み込みが完了しました", "OK"); + return true; + } - if (!IPAddress.IsLoopback(unicastIPAddressInformation.Address)) - myIpList.Add(unicastIPAddressInformation.Address); - if (IsSameNetwork(remoteIpAddress, unicastIPAddressInformation.Address, unicastIPAddressInformation.IPv4Mask)) - { - logger.Trace(" -> isSameNetwork"); - isSameNetwork = true; - break; - } - } + static async Task CanContinueWhenResourceUriContainsIpHandler( + IPAddress remoteIp, + CancellationToken token + ) { + if (!IsPrivateIpv4(remoteIp)) { + logger.Debug( + "ipAddress: {0} is not private address -> continue", + remoteIp + ); + return true; + } + + bool isSameNetwork = false; + List myIpList = []; + byte[] remoteIpAddress = remoteIp.GetAddressBytes(); + foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + logger.Trace("adapter: {0} ({1})", adapter.Name, adapter.Description); + foreach (UnicastIPAddressInformation unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses) + { + logger.Trace(" unicastIPAddressInformation: {0} / {1}", unicastIPAddressInformation.Address, unicastIPAddressInformation.IPv4Mask); + if (unicastIPAddressInformation.Address.AddressFamily != AddressFamily.InterNetwork) + continue; - if (isSameNetwork) + if (!IPAddress.IsLoopback(unicastIPAddressInformation.Address)) + myIpList.Add(unicastIPAddressInformation.Address); + if (IsSameNetwork(remoteIpAddress, unicastIPAddressInformation.Address, unicastIPAddressInformation.IPv4Mask)) + { + logger.Trace(" -> isSameNetwork"); + isSameNetwork = true; break; + } } - if (!isSameNetwork) - { - logger.Warn("remoteIp is private but not same network"); - string myIpListStr = string.Join('\n', myIpList.Select((x, i) => $"この端末[{i}]:{x}")); - bool continueProcessing = await Utils.DisplayAlert( - "Maybe Different Network", - $"接続先と違うネットワークに属しているため、接続に失敗する可能性があります。\nこのまま接続しますか?\n接続先:{remoteIp}\n{myIpListStr}", - "続ける", - "やめる" - ); - logger.Trace("continueProcessing: {0}", continueProcessing); - if (!continueProcessing) - return false; - } + if (isSameNetwork) + return true; } - try - { - logger.Info("checking file size and type..."); - using HttpRequestMessage request = new(HttpMethod.Head, encodedUrl); - using HttpResponseMessage checkResult = await InstanceManager.HttpClient.SendAsync(request, token); - if (!checkResult.IsSuccessStatusCode) - { - logger.Warn("File Size Check Failed with status code: {0} ({1})", checkResult.StatusCode, checkResult.Content); - await Utils.DisplayAlert("Cannot Open File", $"File Size Check Failed: {checkResult.StatusCode}\n{checkResult.Content}", "OK"); - return false; - } + token.ThrowIfCancellationRequested(); -#if DEBUG - // パフォーマンスとプライバシーの理由で、ヘッダーの内容はDEBUGビルドのみ表示する - IEnumerable headerStrs = checkResult.Content.Headers.Select(x => $"{x.Key}: {string.Join(", ", x.Value)}"); - logger.Trace("ResponseHeaders: {0}", string.Join(", ", headerStrs)); -#endif + logger.Warn("remoteIp is private but not same network"); + string myIpListStr = string.Join('\n', myIpList.Select((x, i) => $"この端末[{i}]:{x}")); + bool continueProcessing = await Utils.DisplayAlert( + "Maybe Different Network", + $"接続先と違うネットワークに属しているため、接続に失敗する可能性があります。\nこのまま接続しますか?\n接続先:{remoteIp}\n{myIpListStr}", + "続ける", + "やめる" + ); + logger.Trace("continueProcessing: {0}", continueProcessing); + token.ThrowIfCancellationRequested(); + return continueProcessing; + } - if (checkResult.Content.Headers.ContentLength is not long contentLength) - { - logger.Warn("File Size Check Failed (Content-Length not set) -> check continue or not"); - bool downloadContinue = await Utils.DisplayAlert("Continue to download?", "ダウンロードするファイルのサイズが不明です。ダウンロードを継続しますか?", "続ける", "やめる"); - if (!downloadContinue) - { - logger.Info("User canceled"); - return false; - } - } - else - { - logger.Info("File Size Check Succeeded: {0} bytes", contentLength); - bool downloadContinue = await Utils.DisplayAlert("Continue to download?", $"ダウンロードするファイルのサイズは {contentLength} byte です。このファイルをダウンロードしますか?", "続ける", "やめる"); - if (!downloadContinue) - { - logger.Info("User canceled"); - return false; - } - } - } - catch (Exception ex) - { - if (isRemoteIpv4PrivateIp - && ex is TaskCanceledException - && ex.InnerException is TimeoutException) - { - logger.Error(ex, "File Size Check Failed (ToLocal && Timeout)"); - await Utils.DisplayAlert( - "接続できませんでした", - "接続先が同じネットワークに属しているか、\nまたファイアウォールの例外設定がきちんと今のネットワークに行われているかを\n確認してください。", - "OK"); - return false; - } - logger.Error(ex, "File Size Check Failed"); - await Utils.DisplayAlert("Cannot Open File", ex.Message, "OK"); + static async Task CanContinueWhenHeadRequestSuccessHandler( + HttpResponseMessage response, + CancellationToken token + ) { + logger.Info("Head Request status code: {0} ({1})", response.StatusCode); + if (response.StatusCode == HttpStatusCode.NoContent) + { + await Utils.DisplayAlert( + "Cannot Open File", + $"時刻表ファイルを確認しましたが、ファイルの中身がありませんでした。", + "OK" + ); return false; } - try - { - using HttpRequestMessage request = new(HttpMethod.Get, encodedUrl); - using HttpResponseMessage result = await InstanceManager.HttpClient.SendAsync(request, token); - if (!result.IsSuccessStatusCode) - { - logger.Warn("File Download Failed with status code: {0} ({1})", result.StatusCode, result.Content); - await Utils.DisplayAlert("Cannot Download File", $"File Download Failed: {result.StatusCode}\n{result.Content}", "OK"); - return false; - } +#if DEBUG + // パフォーマンスとプライバシーの理由で、ヘッダーの内容はDEBUGビルドのみ表示する + IEnumerable headerStrEnumerable = response.Content.Headers.Select(x => $"{x.Key}: {string.Join(", ", x.Value)}"); + logger.Trace("ResponseHeaders: {0}", string.Join(", ", headerStrEnumerable)); +#endif - if (appLinkType == AppLinkType.Unknown) - { - switch (result.Content.Headers.ContentType?.MediaType) - { - case "application/json": - appLinkType = AppLinkType.OpenFileJson; - break; - case "application/x-sqlite3": - appLinkType = AppLinkType.OpenFileSQLite; - break; - default: - string? lastPathSegment = uri.Segments.LastOrDefault(); - if (lastPathSegment?.EndsWith(".json") == true) - { - logger.Info("File Type is not valid, but file extension is `.json` -> OpenFileJson"); - appLinkType = AppLinkType.OpenFileJson; - } - else if (lastPathSegment?.EndsWith(".sqlite") == true) - { - logger.Info("File Type is not valid, but file extension is `.sqlite` -> OpenFileSQLite"); - appLinkType = AppLinkType.OpenFileSQLite; - } - else - { - logger.Warn("File Type is not valid: {0}", result.Content.Headers.ContentType?.MediaType); - await Utils.DisplayAlert("Cannot Open File", $"File Type is not valid: {result.Content.Headers.ContentType?.MediaType}", "OK"); - return false; - } - break; - } - } + if (response.Content.Headers.ContentLength is not long contentLength) + { + logger.Warn("File Size Check Failed (Content-Length not set) -> check continue or not"); + return await Utils.DisplayAlert( + "Continue to download?", + "ダウンロードするファイルのサイズが不明です。ダウンロードを継続しますか?", + "続ける", + "やめる" + ); + } - using Stream stream = result.Content.ReadAsStream(token); + logger.Info("File Size Check Succeeded: {0} bytes", contentLength); + return await Utils.DisplayAlert( + "Continue to download?", + $"ダウンロードするファイルのサイズは {contentLength} byte です。このファイルをダウンロードしますか?", + "続ける", + "やめる" + ); + } - ILoader? lastLoader = Loader; - switch (appLinkType) - { - case AppLinkType.OpenFileJson: - logger.Debug("Loading JSON File"); - Loader = await LoaderJson.InitFromStreamAsync(stream, token); - lastLoader?.Dispose(); - logger.Trace("LoaderJson Initialized"); - break; - case AppLinkType.OpenFileSQLite: - logger.Debug("Loading SQLite File"); - // 一旦ローカルに保存してから読み込む - logger.Error("Not Implemented"); - await Utils.DisplayAlert("Not Implemented", "Open External SQLite file is Not Implemented", "OK"); - logger.Trace("LoaderSQL Initialized"); - return false; - default: - logger.Warn("Uri.LocalPath is not valid: {0}", appLinkType); - return false; - } - } - catch (Exception ex) + static bool IsPrivateIpv4(IPAddress ip) + { + if (ip.AddressFamily != AddressFamily.InterNetwork) { - logger.Error(ex, "Loading File Failed"); - await Utils.DisplayAlert("Cannot Open File", ex.Message, "OK"); return false; } - // pathがListに存在しない場合は、Removeは何も実行されずに終了する - _ExternalResourceUrlHistory.Remove(decodedUrl); - if (EXTERNAL_RESOURCE_URL_HISTORY_MAX <= _ExternalResourceUrlHistory.Count) + byte[] bytes = ip.GetAddressBytes(); + return bytes[0] switch { - int removeCount = _ExternalResourceUrlHistory.Count - EXTERNAL_RESOURCE_URL_HISTORY_MAX + 1; - logger.Debug("ExternalResourceUrlHistory.Count is over EXTERNAL_RESOURCE_URL_HISTORY_MAX ({0} <= {1}) -> remove {2} items", EXTERNAL_RESOURCE_URL_HISTORY_MAX, _ExternalResourceUrlHistory.Count, removeCount); - _ExternalResourceUrlHistory.RemoveRange(0, removeCount); - } + 10 => true, + 172 => 16 <= bytes[1] && bytes[1] <= 31, + 192 => bytes[1] == 168, + _ => false, + }; + } - _ExternalResourceUrlHistory.Add(decodedUrl); - AppPreferenceService.SetToJson(AppPreferenceKeys.ExternalResourceUrlHistory, _ExternalResourceUrlHistory); - await Utils.DisplayAlert("Success!", "外部ファイルの読み込みが完了しました", "OK"); - return true; + static bool IsSameNetwork(byte[] remoteIp, IPAddress localIp, IPAddress subnetMask) + => IsSameNetwork(remoteIp, localIp.GetAddressBytes(), subnetMask.GetAddressBytes()); + static bool IsSameNetwork(byte[] remoteIp, byte[] localIp, byte[] subnetMask) + { + byte[] remoteNetworkAddress = remoteIp.Select((x, i) => (byte)(x & subnetMask[i])).ToArray(); + byte[] localNetworkAddress = localIp.Select((x, i) => (byte)(x & subnetMask[i])).ToArray(); + return remoteNetworkAddress.SequenceEqual(localNetworkAddress); } + }