Add UnitTests and made code testable with DI
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NSP/@EntryIndexedValue">NSP</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PFS/@EntryIndexedValue">PFS</s:String></wpf:ResourceDictionary>
|
||||
@@ -3,28 +3,52 @@
|
||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CTinfoilVibeServer_005CTinfoilVibeServer_005Clibhac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAbstractWritableArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F22ddbc5388b085f3bf3bfcf15c58a6938df55f53cc27a762c1e0d3e974da87_003FAbstractWritableArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAppContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F574233347f5bab7d6529a8c4744fb8a213de24439a69fd5c4316ea962144_003FAppContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArgumentNullException_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F79b4b5d07e8c5ac3de172b75667c6bded8e1fa6f42f36d8f6dc2f9e3d568_003FArgumentNullException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAssert_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F182ec85d25b779c7118bca2890ca95ae8dd32868c52272faee4a3a304f624b_003FAssert_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConcurrentDictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d7a996932951dc929c6c3855f98f0c58cae8ac5b0d0bbf223f97c6388b3b61f_003FConcurrentDictionary_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExecutionContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F53b0d531c06faf86bf2ae111a9a2dbce4c52a9153feb9966ade60289c71bf52_003FExecutionContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExpressionExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd785b86024a6dfc7a0de13179d2769fe20f85a33e39e7bfc8dfffba6a44a44_003FExpressionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileInfo_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F85a5735906a3a06f39a1422a28e0353e3e317f2d923dcc5731fef07dd436f9_003FFileInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEnumerator_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F326755fc341d349c24999b4c209d69fbe4317313d563859abe51f4ded75c97b_003FFileSystemEnumerator_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemEventArgs_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F36b5c2553dd4c6dccec26adf9d5ab4ff493763447f46751b6775ba38a832_003FFileSystemEventArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F771dac726bcbf51f1839c45bef2a78c4d834c4bc5420482d9cc3c38eb97535_003FFileSystemWatcher_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileSystemWatcher_002EWin32_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F52fdc6a26ac6d933b95a413e4fb7bc90d22adb6b85734e5eb08036ab03a_003FFileSystemWatcher_002EWin32_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3f31e7e8aa33de883c2ccfa62a9c81bfc246c36e825b489476f9472032e512_003FFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFuture_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb3575a2f41d7c2dbfaa36e866b8a361e11dd7223ff82bc574c1d5d4b7522f735_003FFuture_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashSet_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9f36f47319b32af838b2a5d575986ff83918e428931b1ed44ad662b6bc8a_003FHashSet_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpResponseMessage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4cfeb8b377bc81e1fbb5f7d7a02492cb6ac23e88c8c9d7155944f0716f3d4b_003FHttpResponseMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonConverterOfT_002EReadCore_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fa7e99f3da3fc9d80c1949ce87d548d992a745092d1c720f44952dbbd144437f_003FJsonConverterOfT_002EReadCore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5ef7f3c9db445621211faddebc3c1c9bb48a942f1b8cba4caa2501466f85f_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializer_002EWrite_002EString_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd49cb1d7ce3d716547551bbff70eb2084ebe04c491a927531363631f3e46330_003FJsonSerializer_002EWrite_002EString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALazy_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fafcc64c3432daf776b0c75429fdb3c6b938b876c6daf5d81eee485f119428_003FLazy_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AManualResetEventSlim_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff620c361a4199e3e4ceb5b7d542ea2b32cb76c577fb91a2bc16d15c49879ab8_003FManualResetEventSlim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryCacheExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feabafafbeed23dab31f6a8184bdb1fe5a4c2237f39efe53885612b2ccdd3cd_003FMemoryCacheExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMemoryMarshal_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F948494fffd27a44b5c55f2f85972545a315f73df6c6278f9e692cbbad86b0_003FMemoryMarshal_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F69ba5438e03a0e3a5d2f133f37405f9e32f9e0abb1fbe0a8cb7145514ff85f_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMock_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F337268366a89be4dda7d47b37063fd21841972237539397997c82c4f924b_003FMock_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APath_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcf5011822fd54235e86fdc54ee3baa876b4876e5549223ffeadca5607e59f6af_003FPath_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceInvoker_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F117275599da316d4e45c62326f3097e45f8b5b17d8fe17bbcf9ca86b0819b16_003FResourceInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARuntimeHelpers_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F54c7fa9daf3ab775e3ebabc3f3948a6e2a484886fa1196f813b98e17e36aa49_003FRuntimeHelpers_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASafeFileHandle_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6ef8e7d549f6bfb5b9eafaaa0ff48d8cbfc564f32be26323b5f60e63aef4dd1_003FSafeFileHandle_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProviderServiceExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F621526284d3e4868b98948aff942206525763e57e194a27623b66c384466_003FServiceProviderServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce37be1a06b16c6faa02038d2cc477dd3bca5b217ceeb41c5f2ad45c1bf9_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASharpSevenZipExtractor_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb6d4f34f86579010d492ef5548eca93df749a4e4634ba9c3dee6fbd6c6d74_003FSharpSevenZipExtractor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATaskAwaitAdapter_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd3c187e0b88b1a56f2518fb9f4b5ee5ed6e9116d93a507969a932c16801033_003FTaskAwaitAdapter_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATask_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8789586b575d44528c2aa18853f6eb96f3e8f8d3f4fa7eb9d6a56b54b49d7_003FTask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6354a7b35d7821629924d3676acd7e67a6f7f94343e0e66ec439aa2bd6ed5_003FThrowHelper_002Ecs_002Fz_003A3_002D2/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc7102cd0ffb8973777e61b1942c3fffac7e14016a511d055c3adf73ff91748_003FThrowHelper_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Feb62aa945e5eb84a647a452d1924d7a935f01a6cec552d6fc7ab82a065b9ab8_003FThrowHelper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AThrowHelper_002ESerialization_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6f4af9413b11943bfdd164b58f2c3faa476fb6ffee3bb2d63ea7882b139c1_003FThrowHelper_002ESerialization_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AType_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cde391207de75962d7bacb899ca2bd3985c86911b152d185b58999a422bf0_003FType_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AZipArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F39ececcf144e1f9c884152723ed93931cd232485eaf2824bf5beb526f1f321b_003FZipArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
|
||||
</AssemblyExplorer></s:String></wpf:ResourceDictionary>
|
||||
</AssemblyExplorer></s:String>
|
||||
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=03775624_002D513c_002D49dc_002Db839_002De7eafc50ef7f/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String>
|
||||
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=ba03b870_002Df09c_002D4f54_002D88ad_002D0910534b40f1/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution #2" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||
<Solution />
|
||||
</SessionState></s:String></wpf:ResourceDictionary>
|
||||
@@ -1,53 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using TinfoilVibeServer.Models;
|
||||
using LibHac.Common;
|
||||
using LibHac.Common.Keys;
|
||||
using TinfoilVibeServer.Services;
|
||||
using TinfoilVibeServer.Utilities;
|
||||
|
||||
namespace TinfoilVibeServer.Authentication;
|
||||
|
||||
public interface IAuthStore
|
||||
{
|
||||
void Dispose();
|
||||
bool TryValidate(string username,
|
||||
string password,
|
||||
int? uid,
|
||||
string ip,
|
||||
out string? error);
|
||||
|
||||
int IncrementFailed(string username, string ip);
|
||||
bool IsIPBlacklisted(string ipAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds authentication configuration and runtime state.
|
||||
/// It watches credentials.json for changes and updates the in‑memory
|
||||
/// user list (including the Verified flag) on the fly.
|
||||
/// </summary>
|
||||
public sealed class AuthStore : IDisposable
|
||||
public class AuthStore : IDisposable, IAuthStore
|
||||
{
|
||||
private readonly ILogger<AuthStore> _logger;
|
||||
public readonly AuthSettings Settings;
|
||||
private readonly ConfigManager _configManager;
|
||||
|
||||
public readonly ConcurrentDictionary<string, Credential> Credentials = new();
|
||||
public readonly ConcurrentDictionary<string, List<int>> Fingerprints = new();
|
||||
public readonly ConcurrentDictionary<string, int> FailedAttempts = new();
|
||||
public readonly HashSet<string> BlacklistIPs = new();
|
||||
private readonly HashSet<string> BlacklistIPs = new();
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly FileSystemWatcher _credentialsWatcher;
|
||||
|
||||
public AuthStore(ILogger<AuthStore> logger)
|
||||
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager)
|
||||
{
|
||||
_logger = logger;
|
||||
Settings = new AuthSettings(
|
||||
"credentials.json",
|
||||
"fingerprints.json",
|
||||
"blacklist.json",
|
||||
5
|
||||
);
|
||||
|
||||
_configManager = configManager;
|
||||
|
||||
LoadAll();
|
||||
|
||||
var directoryName = Path.GetDirectoryName(Settings.CredentialsFile);
|
||||
var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
|
||||
_credentialsWatcher = new FileSystemWatcher
|
||||
{
|
||||
Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory,
|
||||
Filter = Path.GetFileName(Settings.CredentialsFile),
|
||||
Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
||||
};
|
||||
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
||||
@@ -63,33 +65,45 @@ public sealed class AuthStore : IDisposable
|
||||
|
||||
private void LoadAll()
|
||||
{
|
||||
_logger.LogInformation("Loading authentication data from {File}", Settings.CredentialsFile);
|
||||
_logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
|
||||
// credentials
|
||||
if (File.Exists(Settings.CredentialsFile))
|
||||
if (File.Exists(_configManager.Settings.CredentialsFile))
|
||||
{
|
||||
var txt = File.ReadAllText(Settings.CredentialsFile);
|
||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||
foreach (var kv in dict)
|
||||
Credentials[kv.Key] = kv.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.CredentialsFile)));
|
||||
}
|
||||
|
||||
// fingerprints
|
||||
if (File.Exists(Settings.FingerprintsFile))
|
||||
if (File.Exists(_configManager.Settings.FingerprintsFile))
|
||||
{
|
||||
var txt = File.ReadAllText(Settings.FingerprintsFile);
|
||||
var txt = File.ReadAllText(_configManager.Settings.FingerprintsFile);
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, List<int>>>(txt)!;
|
||||
foreach (var kv in dict)
|
||||
Fingerprints[kv.Key] = kv.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.FingerprintsFile)));
|
||||
}
|
||||
|
||||
// blacklist
|
||||
if (File.Exists(Settings.BlacklistFile))
|
||||
if (File.Exists(_configManager.Settings.BlacklistFile))
|
||||
{
|
||||
var txt = File.ReadAllText(Settings.BlacklistFile);
|
||||
var txt = File.ReadAllText(_configManager.Settings.BlacklistFile);
|
||||
var arr = JsonSerializer.Deserialize<string[]>(txt)!;
|
||||
foreach (var ip in arr)
|
||||
BlacklistIPs.Add(ip);
|
||||
}
|
||||
else
|
||||
{
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(Path.GetFullPath(_configManager.Settings.BlacklistFile)));
|
||||
}
|
||||
_logger.LogInformation("Loaded {UserCount} users, {FpCount} fingerprints, {IpCount} IPs",
|
||||
Credentials.Count, Fingerprints.Count, BlacklistIPs.Count);
|
||||
}
|
||||
@@ -110,15 +124,15 @@ public sealed class AuthStore : IDisposable
|
||||
|
||||
private void ReloadCredentials()
|
||||
{
|
||||
if (!File.Exists(Settings.CredentialsFile))
|
||||
if (!File.Exists(_configManager.Settings.CredentialsFile))
|
||||
{
|
||||
_logger.LogError("Credentials file {File} does not exist", Settings.CredentialsFile);
|
||||
_logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var txt = File.ReadAllText(Settings.CredentialsFile);
|
||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
||||
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
||||
|
||||
lock (_sync)
|
||||
@@ -150,7 +164,7 @@ public sealed class AuthStore : IDisposable
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reload credentials from {File}", Settings.CredentialsFile);
|
||||
_logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
|
||||
// ignore – malformed JSON or IO error – keep old state
|
||||
}
|
||||
}
|
||||
@@ -159,8 +173,6 @@ public sealed class AuthStore : IDisposable
|
||||
|
||||
#region Authentication logic
|
||||
|
||||
public bool IsBlacklisted(string ip) => BlacklistIPs.Contains(ip);
|
||||
|
||||
public bool TryValidate(string username,
|
||||
string password,
|
||||
int? uid,
|
||||
@@ -231,18 +243,25 @@ public sealed class AuthStore : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void IncrementFailed(string username, string ip)
|
||||
public int IncrementFailed(string username, string ip)
|
||||
{
|
||||
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
||||
FailedAttempts[username] = newCount;
|
||||
lock (_sync)
|
||||
{
|
||||
FailedAttempts[username] = newCount;
|
||||
}
|
||||
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
||||
|
||||
if (newCount < Settings.MaxFailedAttempts) return;
|
||||
if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount;
|
||||
|
||||
BlacklistIPs.Add(ip);
|
||||
PersistBlacklist();
|
||||
FailedAttempts[username] = 0;
|
||||
lock (_sync)
|
||||
{
|
||||
FailedAttempts[username] = 0;
|
||||
}
|
||||
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
||||
return newCount;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -262,19 +281,37 @@ public sealed class AuthStore : IDisposable
|
||||
private void PersistCredentials()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(Settings.CredentialsFile, json);
|
||||
File.WriteAllText(_configManager.Settings.CredentialsFile, json);
|
||||
}
|
||||
|
||||
private void PersistFingerprints()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(Settings.FingerprintsFile, json);
|
||||
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
||||
}
|
||||
|
||||
private void PersistBlacklist()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(Settings.BlacklistFile, json);
|
||||
File.WriteAllText(_configManager.Settings.BlacklistFile, json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Blacklist helpers
|
||||
public bool IsIPBlacklisted(string ipAddress)
|
||||
{
|
||||
return BlacklistIPs.Contains(ipAddress);
|
||||
}
|
||||
|
||||
public bool UnbanIp(string ipAddress)
|
||||
{
|
||||
return BlacklistIPs.Remove(ipAddress);
|
||||
}
|
||||
|
||||
public bool BlacklistActive()
|
||||
{
|
||||
return BlacklistIPs.Count > 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -37,6 +37,10 @@ public sealed class IndexController : ControllerBase
|
||||
/// </summary>
|
||||
public IActionResult Index()
|
||||
{
|
||||
if (HttpContext.Request.Headers.CacheControl == "no-cache")
|
||||
{
|
||||
_snapshotService.RebuildSnapshot();
|
||||
}
|
||||
var index = _indexBuilderService.Build();
|
||||
|
||||
return Ok(index);
|
||||
@@ -69,7 +73,7 @@ public sealed class IndexController : ControllerBase
|
||||
var titleId = match.Groups["id"].Value.ToUpperInvariant();
|
||||
|
||||
// ---- 2️⃣ Find the file that contains this TitleId ------------
|
||||
var entry = _snapshotService.GetSnapshot()
|
||||
var entry = _snapshotService.GetSnapshot().Files
|
||||
.FirstOrDefault(e => e.Title?.TitleId == titleId);
|
||||
|
||||
if (entry == null)
|
||||
|
||||
@@ -15,13 +15,13 @@ public sealed class BasicAuthMiddleware
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, AuthStore store, ILogger<BasicAuthMiddleware> logger)
|
||||
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
|
||||
{
|
||||
logger.LogInformation("Incoming request from {IP} – {Method} {Path}", context.Connection.RemoteIpAddress, context.Request.Method, context.Request.Path);
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
// 1) IP blacklist
|
||||
if (store.IsBlacklisted(ip))
|
||||
if (store.IsIPBlacklisted(ip))
|
||||
{
|
||||
logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
|
||||
@@ -7,8 +7,6 @@ public sealed record AppSettings(
|
||||
string[] RootDirectories,
|
||||
string[] WhitelistExtensions,
|
||||
string[] RomExtensions,
|
||||
string SnapshotFile,
|
||||
string SnapshotBackupFile,
|
||||
string CredentialsFile,
|
||||
string FingerprintsFile,
|
||||
string BlacklistFile,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace TinfoilVibeServer.Models;
|
||||
|
||||
public sealed class SnapshotOptions : INotifyPropertyChanged
|
||||
{
|
||||
private List<string> _rootDirectories = new();
|
||||
public List<string> RootDirectories
|
||||
{
|
||||
get => _rootDirectories;
|
||||
set
|
||||
{
|
||||
if (_rootDirectories != value)
|
||||
{
|
||||
_rootDirectories = value;
|
||||
OnPropertyChanged(nameof(RootDirectories));
|
||||
}
|
||||
}
|
||||
}
|
||||
private List<string> _whitelistExtensions = new();
|
||||
public List<string> WhitelistExtensions
|
||||
{
|
||||
get => _whitelistExtensions;
|
||||
set
|
||||
{
|
||||
if (_whitelistExtensions != value)
|
||||
{
|
||||
_whitelistExtensions = value;
|
||||
OnPropertyChanged(nameof(_whitelistExtensions));
|
||||
}
|
||||
}
|
||||
}
|
||||
private List<string> _romExtensions = new();
|
||||
public List<string> RomExtensions
|
||||
{
|
||||
get => _romExtensions;
|
||||
set
|
||||
{
|
||||
if (_romExtensions != value)
|
||||
{
|
||||
_romExtensions = value;
|
||||
OnPropertyChanged(nameof(_romExtensions));
|
||||
}
|
||||
}
|
||||
}
|
||||
private TimeSpan _cacheTtl = TimeSpan.FromHours(1);
|
||||
public TimeSpan CacheTtl
|
||||
{
|
||||
get => _cacheTtl;
|
||||
set
|
||||
{
|
||||
if (_cacheTtl != value)
|
||||
{
|
||||
_cacheTtl = value;
|
||||
OnPropertyChanged(nameof(CacheTtl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _snapshotFile = "snapshot.json";
|
||||
|
||||
public string SnapshotFile
|
||||
{
|
||||
get => _snapshotFile;
|
||||
set
|
||||
{
|
||||
if (string.Equals(_snapshotFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
|
||||
_snapshotFile = value;
|
||||
OnPropertyChanged(nameof(SnapshotFile));
|
||||
}
|
||||
}
|
||||
|
||||
private string _snapshotBackupFile = "snapshot.bak";
|
||||
public string SnapshotBackupFile
|
||||
{
|
||||
get => _snapshotBackupFile;
|
||||
set
|
||||
{
|
||||
if (string.Equals(_snapshotBackupFile,value, StringComparison.InvariantCultureIgnoreCase)) return;
|
||||
_snapshotBackupFile = value;
|
||||
OnPropertyChanged(nameof(SnapshotBackupFile));
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
private void OnPropertyChanged(string propertyName) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TinfoilVibeServer.Authentication;
|
||||
using TinfoilVibeServer.Middleware;
|
||||
using TinfoilVibeServer.Services;
|
||||
@@ -13,19 +14,20 @@ builder.Logging.AddDebug();
|
||||
// -------------------------------------------------------------------
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.Configure<TitleDbOptions>(builder.Configuration.GetSection("TitleDb"));
|
||||
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
||||
builder.Services.Configure<SnapshotOptions>(builder.Configuration.GetSection("Snapshot"));
|
||||
builder.Services.AddSingleton<ConfigManager>();
|
||||
builder.Services.AddSingleton<NSPExtractor>(sp =>
|
||||
builder.Services.AddSingleton<INSPExtractor, NSPExtractor>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<ConfigManager>();
|
||||
var logger = sp.GetRequiredService<ILogger<NSPExtractor>>();
|
||||
var logger = sp.GetRequiredService<ILogger<INSPExtractor>>();
|
||||
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
|
||||
return new NSPExtractor(keySet, logger);
|
||||
});
|
||||
builder.Services.AddSingleton<ISnapshotService, SnapshotService>();
|
||||
builder.Services.AddSingleton<AuthStore>(sp =>
|
||||
new AuthStore(sp.GetRequiredService<ILogger<AuthStore>>()));
|
||||
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
||||
builder.Services.AddSingleton<TitleDatabaseService>();
|
||||
builder.Services.AddSingleton<ArchiveHandler>();
|
||||
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
||||
builder.Services.AddSingleton<IndexBuilderService>();
|
||||
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
||||
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
||||
@@ -45,9 +47,9 @@ app.MapControllers(); // routes the /index.json & /download endpoints
|
||||
|
||||
|
||||
app.MapGet("/debug", () => new SnapshotService(
|
||||
app.Services.GetRequiredService<ConfigManager>(),
|
||||
app.Services.GetRequiredService<NSPExtractor>(),
|
||||
app.Services.GetRequiredService<ArchiveHandler>(),
|
||||
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
||||
app.Services.GetRequiredService<INSPExtractor>(),
|
||||
app.Services.GetRequiredService<IArchiveHandler>(),
|
||||
app.Services.GetRequiredService<ILogger<SnapshotService>>())
|
||||
.GetSnapshot());
|
||||
app.Lifetime.ApplicationStarted.Register(() =>
|
||||
|
||||
@@ -8,17 +8,25 @@ using ZipArchive = SharpCompress.Archives.Zip.ZipArchive;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
|
||||
public interface IArchiveHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
||||
/// </summary>
|
||||
NcaMetadataWithHash? TryExtractTitleInfo(string filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to open a file as an archive and look for an embedded NSP/XCI.
|
||||
/// The extractor is injected so that the hash of the first stream can be accessed
|
||||
/// while the file is being read.
|
||||
/// </summary>
|
||||
public sealed class ArchiveHandler
|
||||
public sealed class ArchiveHandler : IArchiveHandler
|
||||
{
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
private readonly ILogger<ArchiveHandler> _logger;
|
||||
|
||||
public ArchiveHandler(NSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
|
||||
public ArchiveHandler(INSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
|
||||
{
|
||||
_nspExtractor = nspExtractor;
|
||||
_logger = logger;
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace TinfoilVibeServer.Services;
|
||||
/// Reads the JSON config file on startup, watches it for changes, and also
|
||||
/// loads the KeySet from the file specified in the config.
|
||||
/// </summary>
|
||||
public sealed class ConfigManager
|
||||
public class ConfigManager
|
||||
{
|
||||
public AppSettings Settings { get; private set; }
|
||||
|
||||
@@ -42,8 +42,6 @@ public sealed class ConfigManager
|
||||
RootDirectories: Array.Empty<string>(),
|
||||
WhitelistExtensions: Array.Empty<string>(),
|
||||
RomExtensions: Array.Empty<string>(),
|
||||
SnapshotFile: "index.tfl",
|
||||
SnapshotBackupFile: "snapshot.bin",
|
||||
CredentialsFile: "credentials.json",
|
||||
FingerprintsFile: "fingerprints.json",
|
||||
BlacklistFile: "blacklist.json",
|
||||
|
||||
@@ -41,18 +41,23 @@ public sealed class IndexBuilderService: IHostedService
|
||||
// 1️⃣ Load cache if it exists
|
||||
var cached = LoadCache();
|
||||
var snapshot = _snapshotService.GetSnapshot();
|
||||
if (string.IsNullOrEmpty(snapshot.Hash))
|
||||
{
|
||||
_snapshotService.BuildSnapshot();
|
||||
snapshot = _snapshotService.GetSnapshot();
|
||||
}
|
||||
|
||||
// 2️⃣ Re‑build only if the snapshot hash changed
|
||||
string snapshotHash = ComputeSnapshotHash(snapshot);
|
||||
string snapshotHash = ComputeSnapshotHash(snapshot.Files);
|
||||
if (cached != null && cached.SnapshotHash == snapshotHash)
|
||||
{
|
||||
_logger.LogInformation("Using cached index (snapshot hash={Hash}", snapshotHash);
|
||||
return cached.Index;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Count);
|
||||
_logger.LogInformation("Building index (snapshot size={Count})", snapshot.Files.Count);
|
||||
// 3️⃣ Build new index from snapshot entries
|
||||
var files = snapshot
|
||||
var files = snapshot.Files
|
||||
.Where(e => e.Title != null)
|
||||
.Select(e =>
|
||||
{
|
||||
@@ -63,7 +68,11 @@ public sealed class IndexBuilderService: IHostedService
|
||||
|
||||
var vProcessed = e.Title.Version * 0x10000;
|
||||
var patchOrApp = e.Title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
|
||||
var url = $"{name}[{titleId}][{vProcessed:X}][{patchOrApp}].nsp";
|
||||
if (e.Title.ContentMetaType == ContentMetaType.Patch)
|
||||
{
|
||||
name = _titleDb.TryGetTitle(e.Title.ApplicationTitle, out var appTitle) ? appTitle.Name : "Unknown";
|
||||
}
|
||||
var url = $"{name}[{titleId}][{vProcessed}][{patchOrApp}].nsp";
|
||||
|
||||
return new FileDto(url, e.Size);
|
||||
})
|
||||
|
||||
@@ -18,15 +18,30 @@ using TinfoilVibeServer.Models;
|
||||
|
||||
namespace TinfoilVibeServer.Services
|
||||
{
|
||||
public interface INSPExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Public convenience wrapper that opens the file on disk.
|
||||
/// </summary>
|
||||
NcaMetadataWithHash? ExtractFromFile(string filePath);
|
||||
|
||||
/// <summary>
|
||||
/// Core implementation – works on any seekable stream that contains a full NSP/XCI container.
|
||||
/// </summary>
|
||||
NcaMetadataWithHash? ExtractFromStream(Stream stream);
|
||||
|
||||
string ExtractHashFromStream(Stream nspStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream.
|
||||
/// </summary>
|
||||
public sealed class NSPExtractor
|
||||
public sealed class NSPExtractor : INSPExtractor
|
||||
{
|
||||
private readonly KeySet _keySet;
|
||||
private readonly ILogger<NSPExtractor> _logger;
|
||||
private readonly ILogger<INSPExtractor> _logger;
|
||||
|
||||
public NSPExtractor(KeySet keySet, ILogger<NSPExtractor> logger)
|
||||
public NSPExtractor(KeySet keySet, ILogger<INSPExtractor> logger)
|
||||
{
|
||||
_keySet = keySet;
|
||||
_logger = logger;
|
||||
@@ -84,16 +99,16 @@ namespace TinfoilVibeServer.Services
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(ncaStream);
|
||||
|
||||
var contentMetaType = GetMetaDataType(nca);
|
||||
var (contentMetaType,applicationTitle) = GetMetaDataType(nca);
|
||||
if (contentMetaType != null)
|
||||
return new NcaMetadataWithHash(titleId, version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
||||
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), version, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
||||
}
|
||||
|
||||
return null; // no meta NCA found
|
||||
}
|
||||
private static ContentMetaType? GetMetaDataType(Nca nca)
|
||||
private static (ContentMetaType?,ulong) GetMetaDataType(Nca nca)
|
||||
{
|
||||
if (nca.Header.ContentType != NcaContentType.Meta) return null;
|
||||
if (nca.Header.ContentType != NcaContentType.Meta) return (null,0);
|
||||
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
|
||||
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
|
||||
{
|
||||
@@ -105,10 +120,11 @@ namespace TinfoilVibeServer.Services
|
||||
using var asStream = nacpFile.AsStream();
|
||||
|
||||
var cnmt = new Cnmt(asStream);
|
||||
return cnmt.Type;
|
||||
var applicationTitle = cnmt.ApplicationTitleId;
|
||||
return (cnmt.Type,applicationTitle);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (null,0);
|
||||
}
|
||||
/// <summary>
|
||||
/// Quick sanity check that the stream looks like a PFS0 file system.
|
||||
@@ -186,14 +202,16 @@ namespace TinfoilVibeServer.Services
|
||||
public sealed class NcaMetadataWithHash
|
||||
{
|
||||
public string TitleId { get; }
|
||||
public string ApplicationTitle { get; set; }
|
||||
public int Version { get; }
|
||||
|
||||
public ContentMetaType ContentMetaType { get; set; }
|
||||
public string Hash { get; }
|
||||
|
||||
public NcaMetadataWithHash(string titleId, int version, ContentMetaType contentMetaType, string hash)
|
||||
public NcaMetadataWithHash(string titleId, string applicationTitle, int version, ContentMetaType contentMetaType, string hash)
|
||||
{
|
||||
TitleId = titleId;
|
||||
ApplicationTitle = applicationTitle;
|
||||
Version = version;
|
||||
ContentMetaType = contentMetaType;
|
||||
Hash = hash;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TinfoilVibeServer.Models;
|
||||
using TinfoilVibeServer.Utilities;
|
||||
|
||||
namespace TinfoilVibeServer.Services;
|
||||
public interface ISnapshotService
|
||||
{
|
||||
event EventHandler SnapshotRebuilt; // raised after a rebuild
|
||||
void RebuildSnapshot();
|
||||
IReadOnlyList<FileEntry> GetSnapshot();
|
||||
SnapshotService.ROMSnapshot GetSnapshot();
|
||||
void BuildSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -17,38 +20,54 @@ public interface ISnapshotService
|
||||
/// </summary>
|
||||
public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
{
|
||||
private readonly ConfigManager _config;
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
private readonly ArchiveHandler _archiveHandler;
|
||||
private readonly SnapshotOptions _options;
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
private readonly IArchiveHandler _archiveHandler;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly string _jsonPath;
|
||||
private readonly string _snapshotPath;
|
||||
private readonly List<FileSystemWatcher> _watchers = new();
|
||||
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
|
||||
private string? _currentSnapshotHash;
|
||||
private readonly Timer _debounceTimer;
|
||||
public event EventHandler? SnapshotRebuilt;
|
||||
|
||||
public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger<SnapshotService> logger)
|
||||
public SnapshotService(
|
||||
IOptionsMonitor<SnapshotOptions> options,
|
||||
INSPExtractor nspExtractor,
|
||||
IArchiveHandler archiveHandler,
|
||||
ILogger<SnapshotService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_options = options.CurrentValue;
|
||||
_nspExtractor = nspExtractor;
|
||||
_archiveHandler = archiveHandler;
|
||||
_logger = logger;
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
|
||||
|
||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
|
||||
if (!File.Exists(_jsonPath))
|
||||
{
|
||||
File.WriteAllText(_jsonPath, "[]");
|
||||
}
|
||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath));
|
||||
// 1️⃣ Register for *property* changes
|
||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
||||
|
||||
BuildSnapshot(); // initial scan
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
|
||||
_debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
foreach (var path in _config.Settings.RootDirectories)
|
||||
foreach (var path in _options.RootDirectories)
|
||||
{
|
||||
InitializeFileSystemWatcher(path);
|
||||
}
|
||||
|
||||
|
||||
_config.OnChange += cfg =>
|
||||
}
|
||||
// --------- Private helpers ---------
|
||||
private void OnOptionsChanged(string propertyName)
|
||||
{
|
||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
||||
{
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
|
||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
||||
foreach (var watcher in fileSystemWatchers)
|
||||
{
|
||||
watcher.EnableRaisingEvents = false;
|
||||
@@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
_watchers.Remove(watcher);
|
||||
}
|
||||
|
||||
var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
|
||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
||||
!_watchers.Any(watcher =>
|
||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
@@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
InitializeFileSystemWatcher(newWatchedDirectory);
|
||||
|
||||
}
|
||||
|
||||
BuildSnapshot(); // rebuild everything
|
||||
PersistSnapshot();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeFileSystemWatcher(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
@@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
|
||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
lock (_lock)
|
||||
{
|
||||
_debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer
|
||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
||||
}
|
||||
/*Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(250);
|
||||
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
|
||||
UpdateSnapshot();
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private int _debounceMs = 200;
|
||||
|
||||
private void DebounceElapsed()
|
||||
{
|
||||
UpdateSnapshot();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Snapshot logic
|
||||
|
||||
private void BuildSnapshot()
|
||||
public void BuildSnapshot()
|
||||
{
|
||||
var cfg = _config.Settings;
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length);
|
||||
var entries = new List<FileEntry>();
|
||||
var index = LoadSnapshotIndex();
|
||||
|
||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
||||
var fileInfo = new FileInfo(_snapshotPath);
|
||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
||||
{
|
||||
_logger.LogInformation("Snapshot is up to date");
|
||||
return;
|
||||
}
|
||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
||||
var entries = new List<FileEntry>();
|
||||
|
||||
var snapshotChanged = false;
|
||||
foreach (var dir in cfg.RootDirectories)
|
||||
foreach (var dir in _options.RootDirectories)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
|
||||
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
|
||||
if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
||||
continue;
|
||||
|
||||
if (index.ContainsKey(file)) continue;
|
||||
if (index.TryGetValue(file, out var value))
|
||||
{
|
||||
entries.Add(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3) extract title if applicable
|
||||
string hash;
|
||||
NcaMetadataWithHash? title = null;
|
||||
if (cfg.RomExtensions.Contains(ext))
|
||||
if (_options.RomExtensions.Contains(ext))
|
||||
{
|
||||
using var nspStream = File.OpenRead(file);
|
||||
hash = ComputeFirstStreamHash(nspStream);
|
||||
@@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
if (snapshotChanged)
|
||||
{
|
||||
_logger.LogInformation("Snapshot rebuilt");
|
||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
|
||||
private void PersistSnapshot()
|
||||
{
|
||||
var entries = GetSnapshot();
|
||||
var newHash = ComputeSnapshotHash(entries);
|
||||
var snapshot = GetSnapshot();
|
||||
var newHash = ComputeSnapshotHash(snapshot.Files);
|
||||
if (_currentSnapshotHash != newHash)
|
||||
{
|
||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
||||
_currentSnapshotHash = newHash;
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
|
||||
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files));
|
||||
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
}
|
||||
#endregion
|
||||
|
||||
public IReadOnlyList<FileEntry> GetSnapshot()
|
||||
public ROMSnapshot GetSnapshot()
|
||||
{
|
||||
if (!File.Exists(_jsonPath)) return new();
|
||||
var json = File.ReadAllText(_jsonPath);
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
|
||||
var hash = ComputeHash(_jsonPath);
|
||||
var romSnapshot = new ROMSnapshot()
|
||||
{
|
||||
Hash = hash,
|
||||
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json,
|
||||
new JsonSerializerOptions() { IncludeFields = true })!
|
||||
};
|
||||
return romSnapshot;
|
||||
}
|
||||
|
||||
public void RebuildSnapshot()
|
||||
@@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public class ROMSnapshot
|
||||
{
|
||||
public string Hash { get; set; }
|
||||
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public sealed class TitleDatabaseService : IHostedService
|
||||
private readonly IOptionsMonitor<TitleDbOptions> _options;
|
||||
private readonly ILogger<TitleDatabaseService> _logger;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly NSPExtractor _nspExtractor;
|
||||
private readonly INSPExtractor _nspExtractor;
|
||||
private readonly string _cacheFolder; // Where the JSON is cached.
|
||||
private readonly List<string> _rootDirectories; // directories that contain game files
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class TitleDatabaseService : IHostedService
|
||||
ILogger<TitleDatabaseService> logger,
|
||||
ISnapshotService snapshotService,
|
||||
IHttpClientFactory httpFactory,
|
||||
NSPExtractor nspExtractor,
|
||||
INSPExtractor nspExtractor,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_options = options;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace TinfoilVibeServer.Utilities;
|
||||
|
||||
public static class FileSystemExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the most recent last‑write time (UTC) of any file under the supplied
|
||||
/// root directories, traversing all sub‑directories. If no files are found,
|
||||
/// <c>null</c> is returned.
|
||||
/// </summary>
|
||||
/// <param name="rootDirectories">
|
||||
/// A collection of absolute paths that must point to existing directories.
|
||||
/// Paths that do not exist or are inaccessible are silently skipped.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The UTC <see cref="DateTime"/> of the newest file, or <c>null</c> if there are none.
|
||||
/// </returns>
|
||||
public static DateTime? GetLatestModifiedUtc(IEnumerable<string> rootDirectories)
|
||||
{
|
||||
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
|
||||
|
||||
// We keep a mutable variable because we don't want to materialise the entire
|
||||
// sequence into memory.
|
||||
DateTime? latest = null;
|
||||
|
||||
foreach (var root in rootDirectories)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||
continue; // skip bad paths
|
||||
|
||||
try
|
||||
{
|
||||
// Enumerate lazily and process each file as soon as it’s yielded.
|
||||
foreach (var filePath in Directory.EnumerateFiles(
|
||||
root,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Using FileSystemInfo to fetch only the property we need.
|
||||
var fsi = new FileInfo(filePath);
|
||||
var lastWrite = fsi.LastWriteTimeUtc;
|
||||
|
||||
if (!latest.HasValue || lastWrite > latest.Value)
|
||||
latest = lastWrite;
|
||||
}
|
||||
catch (FileNotFoundException) // file vanished while we were enumerating
|
||||
{
|
||||
// ignore and keep going
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// file exists but we can’t read its attributes – skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// the root directory itself is inaccessible – skip it
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parallelised version that may be faster on very large directory trees.
|
||||
/// </summary>
|
||||
public static DateTime? GetLatestModifiedUtcParallel(IEnumerable<string> rootDirectories)
|
||||
{
|
||||
if (rootDirectories == null) throw new ArgumentNullException(nameof(rootDirectories));
|
||||
|
||||
// Flatten all file paths into a single stream first (this is the only
|
||||
// part that needs to be thread‑safe).
|
||||
var allFiles = rootDirectories
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r) && Directory.Exists(r))
|
||||
.SelectMany(r => Directory.EnumerateFiles(r, "*", SearchOption.AllDirectories))
|
||||
.ToArray(); // materialise once, then parallelise
|
||||
|
||||
// Now fetch the dates in parallel. The LINQ overload of Max() that takes
|
||||
// an async selector is not available, so we just use Parallel.ForEach.
|
||||
DateTime? latest = null;
|
||||
var lockObj = new object();
|
||||
|
||||
Parallel.ForEach(allFiles, filePath =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var lastWrite = new FileInfo(filePath).LastWriteTimeUtc;
|
||||
lock (lockObj)
|
||||
{
|
||||
if (!latest.HasValue || lastWrite > latest.Value)
|
||||
latest = lastWrite;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// swallow all exceptions – the caller only cares about the max date
|
||||
}
|
||||
});
|
||||
|
||||
return latest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the directory (and all missing parent directories) if it does not already exist.
|
||||
/// </summary>
|
||||
/// <param name="path">Absolute or relative path to the directory to create.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown if <paramref name="path"/> is empty or contains only whitespace.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown if the caller does not have permission.</exception>
|
||||
/// <exception cref="IOException">Thrown if a file exists at the target path or the directory cannot be created.</exception>
|
||||
public static void EnsureDirectoryExists(string path)
|
||||
{
|
||||
if (path is null)
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Path must not be empty or whitespace.", nameof(path));
|
||||
|
||||
// Directory.CreateDirectory is already idempotent – it only creates missing parts.
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,19 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
|
||||
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
|
||||
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
||||
"SnapshotFile": "index.tfl",
|
||||
"SnapshotBackupFile": "snapshot.bin",
|
||||
"KeySetFile": "prod.keys",
|
||||
"CredentialsFile": "credentials.json",
|
||||
"FingerprintsFile": "fingerprints.json",
|
||||
"BlacklistFile": "blacklist.json",
|
||||
"MaxFailedAttempts": 5,
|
||||
"KeySetFile": "prod.keys",
|
||||
"Snapshot" : {
|
||||
"RootDirectories": [ "\\\\NAS\\Roms\\Switch", "Z:\\imgs\\roms\\Switch" ],
|
||||
"WhitelistExtensions": [ ".bin", ".jpg", ".png", ".txt" ],
|
||||
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
||||
"CacheTtl": 60,
|
||||
"SnapshotFile": "index.tfl",
|
||||
"SnapshotBackupFile": "snapshot.bin"
|
||||
},
|
||||
"TitleDb": {
|
||||
"CountryCode": "AU",
|
||||
"Language": "en",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using TinfoilVibeServer.Authentication;
|
||||
using TinfoilVibeServer.Services;
|
||||
|
||||
// <-- adjust namespace
|
||||
|
||||
namespace TinfoilVibeServerTest.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class AuthStoreTests
|
||||
{
|
||||
private Mock<ILogger<AuthStore>> _loggerMock;
|
||||
private AuthStore _authStore;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<AuthStore>>();
|
||||
// Assume Settings is static and can be patched for tests
|
||||
MockConfigManager = new Mock<ConfigManager>();
|
||||
_authStore = new AuthStore(_loggerMock.Object, MockConfigManager.Object);
|
||||
}
|
||||
|
||||
public Mock<ConfigManager> MockConfigManager { get; set; }
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
_authStore.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LoadAll_ShouldPopulateCollections()
|
||||
{
|
||||
// Act
|
||||
var users = _authStore.Credentials.Count;
|
||||
var fprs = _authStore.Fingerprints.Count;
|
||||
|
||||
// Assert
|
||||
Assert.That(users, Is.GreaterThan(0), "At least one user must be loaded");
|
||||
Assert.That(fprs, Is.GreaterThanOrEqualTo(0));
|
||||
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Loaded")),
|
||||
null,
|
||||
(Func<It.IsAnyType, Exception, string>)It.IsAny<object>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryValidate_NewUser_ShouldCreateAndVerify()
|
||||
{
|
||||
// Arrange
|
||||
var newUser = "newuser";
|
||||
var ip = "127.0.0.1";
|
||||
var password = "";
|
||||
var uid = null as int?;
|
||||
// Act
|
||||
var result = _authStore.TryValidate(newUser, password, uid, ip, out var cred);
|
||||
|
||||
// Assert
|
||||
Assert.That(result, Is.False, "New user should be not be verified automatically");
|
||||
Assert.That(_authStore.Credentials[newUser], Is.Not.Null);
|
||||
Assert.That(_authStore.Credentials[newUser].Verified, Is.False);
|
||||
|
||||
// New user should now exist
|
||||
Assert.That(_authStore.Credentials.Any(u => u.Value.Username == newUser), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IncrementFailed_BeforeBlacklist_ShouldNotBlacklist()
|
||||
{
|
||||
// Arrange
|
||||
var ip = "203.0.113.5";
|
||||
var cred = new Credential("dummy", "hash", 1, false);
|
||||
_authStore.UnbanIp(ip); // ensure clean
|
||||
|
||||
// Act
|
||||
var counter = _authStore.IncrementFailed(cred.Username, ip);
|
||||
|
||||
// Assert
|
||||
Assert.That(counter, Is.EqualTo(1));
|
||||
Assert.That(_authStore.IsIPBlacklisted(ip), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IncrementFailed_ExceedingThreshold_ShouldBlacklist()
|
||||
{
|
||||
// Arrange
|
||||
var ip = "203.0.113.5";
|
||||
var cred = new Credential("dummy", "hash", MockConfigManager.Object.Settings.MaxFailedAttempts, false);
|
||||
int threshold = MockConfigManager.Object.Settings.MaxFailedAttempts;
|
||||
|
||||
// Simulate threshold failures
|
||||
for (int i = 0; i < threshold; i++)
|
||||
_authStore.IncrementFailed(cred.Username, ip);
|
||||
|
||||
// Act
|
||||
int final = _authStore.IncrementFailed(cred.Username, ip);
|
||||
|
||||
// Assert
|
||||
Assert.That(final, Is.EqualTo(threshold + 1));
|
||||
Assert.That(_authStore.IsIPBlacklisted(ip), Is.True);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using TinfoilVibeServer.Authentication;
|
||||
using TinfoilVibeServer.Middleware;
|
||||
|
||||
namespace TinfoilVibeServerTest.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class BasicAuthMiddlewareTests
|
||||
{
|
||||
private Mock<ILogger<BasicAuthMiddleware>> _loggerMock;
|
||||
private Mock<IAuthStore> _authMock;
|
||||
private BasicAuthMiddleware _middleware;
|
||||
private RequestDelegate _next;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<BasicAuthMiddleware>>();
|
||||
_authMock = new Mock<IAuthStore>();
|
||||
_next = (HttpContext ctx) => Task.CompletedTask;
|
||||
|
||||
_middleware = new BasicAuthMiddleware(_next);
|
||||
}
|
||||
|
||||
private HttpContext CreateContext(string authHeader = null, string ip = "127.0.0.1", int? uid = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Connection.RemoteIpAddress = IPAddress.Parse(ip);
|
||||
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
ctx.Request.Headers["Authorization"] = authHeader;
|
||||
}
|
||||
|
||||
if (uid!= null)
|
||||
{
|
||||
ctx.Request.Headers["UID"] = uid.ToString();
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvokeAsync_NoAuthHeader_ShouldReturn401()
|
||||
{
|
||||
// Arrange
|
||||
var ctx = CreateContext();
|
||||
|
||||
// Act
|
||||
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status401Unauthorized));
|
||||
_loggerMock.Verify(l => l.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Missing Authorization header")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvokeAsync_BlacklistedIP_ShouldReturn403()
|
||||
{
|
||||
// Arrange
|
||||
var ctx = CreateContext("Basic dXNlcjpwYXNz");
|
||||
|
||||
_authMock.Setup(a => a.IsIPBlacklisted("127.0.0.1")).Returns(true);
|
||||
|
||||
// Act
|
||||
|
||||
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvokeAsync_ValidCredentials_ShouldCallNext()
|
||||
{
|
||||
// Arrange
|
||||
var user = "alice";
|
||||
var pw = "secret";
|
||||
var uid = 1234;
|
||||
var header = $"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{user}:{pw}"))}";
|
||||
|
||||
var ip = "127.0.0.1";
|
||||
var ctx = CreateContext(header,ip, uid);
|
||||
|
||||
string? error;
|
||||
_authMock.Setup(a =>
|
||||
a.TryValidate(user, pw, uid, ip, out error))
|
||||
.Returns(true);
|
||||
|
||||
bool nextCalled = false;
|
||||
_next = (HttpContext _) => { nextCalled = true; return Task.CompletedTask; };
|
||||
_middleware = new BasicAuthMiddleware(_next);
|
||||
|
||||
// Act
|
||||
await _middleware.InvokeAsync(ctx, _authMock.Object, _loggerMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.That(nextCalled, Is.True);
|
||||
Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using LibHac.Ncm;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using TinfoilVibeServer.Models;
|
||||
using TinfoilVibeServer.Services;
|
||||
using TinfoilVibeServer.Utilities;
|
||||
|
||||
namespace TinfoilVibeServerTest.Tests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SnapshotServiceTests
|
||||
{
|
||||
private Mock<ILogger<SnapshotService>> _loggerMock;
|
||||
private SnapshotService _service;
|
||||
private Mock<INSPExtractor> _nspExtractorMock;
|
||||
private Mock<IArchiveHandler> _archiveHander;
|
||||
private Mock<IOptionsMonitor<SnapshotOptions>> _mockOptions;
|
||||
private SnapshotOptions _options;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mockOptions = new Mock<IOptionsMonitor<SnapshotOptions>>();
|
||||
_options = new SnapshotOptions()
|
||||
{
|
||||
RomExtensions = [".nsp"],
|
||||
RootDirectories = ["TestData/ROMS"],
|
||||
SnapshotFile = "TestData/snapshot.json",
|
||||
SnapshotBackupFile = "TestData/snapshot.bak"
|
||||
};
|
||||
/*// ensure ROM test directory has test files removed
|
||||
foreach (var file in Directory.GetFiles("TestData/ROMS"))
|
||||
{
|
||||
File.Delete(file);
|
||||
}*/
|
||||
_mockOptions.Setup(m => m.CurrentValue).Returns(_options);
|
||||
_loggerMock = new Mock<ILogger<SnapshotService>>();
|
||||
_archiveHander = new Mock<IArchiveHandler>();
|
||||
_nspExtractorMock = new Mock<INSPExtractor>();
|
||||
_nspExtractorMock.Setup(extractor => extractor.ExtractHashFromStream(It.IsAny<Stream>())).Returns("HASH");
|
||||
_nspExtractorMock.Setup(extractor => extractor.ExtractFromStream(It.IsAny<Stream>())).Returns(
|
||||
new NcaMetadataWithHash(titleId: "0000000000000000","0000000000000000", version: 1, ContentMetaType.Application, "HASH"));
|
||||
//Settings.RootDirs = new List<string> { "TestData/Root1", "TestData/Root2" };
|
||||
_service = new SnapshotService(_mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
_service.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist()
|
||||
{
|
||||
// Arrange
|
||||
await File.WriteAllTextAsync(_options.SnapshotFile, "[]");
|
||||
var initialHash = _service.GetSnapshot()?.Hash;
|
||||
// Add a file to Root1
|
||||
var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp");
|
||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(newFile));
|
||||
// Create a new valid NSP file
|
||||
// copy to temp to touch modified date
|
||||
foreach (var file in Directory.GetFiles("../../../Data/"))
|
||||
{
|
||||
var filename = Path.GetFileName(file);
|
||||
var destFilename = Path.Combine(Path.GetTempPath(), filename);
|
||||
File.Copy(file, destFilename, true);
|
||||
var info = new FileInfo(destFilename)
|
||||
{
|
||||
LastWriteTimeUtc = DateTime.UtcNow
|
||||
};
|
||||
info.CopyTo(Path.Combine(_options.RootDirectories.First(),filename), true);
|
||||
}
|
||||
|
||||
// Act
|
||||
_service.SnapshotRebuilt+= (sender, args) =>
|
||||
{
|
||||
// Assert
|
||||
var newHash = _service.GetSnapshot()?.Hash;
|
||||
Assert.That(newHash, Is.Not.EqualTo(initialHash));
|
||||
|
||||
|
||||
};
|
||||
Task.Delay(300).Wait();
|
||||
_loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Snapshot rebuilt")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BuildSnapshot_NoChange_ShouldNotPersist()
|
||||
{
|
||||
// Act
|
||||
_service.BuildSnapshot();
|
||||
|
||||
// Act again – snapshot should be identical
|
||||
_service.BuildSnapshot();
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("persisting new snapshot")),
|
||||
null,
|
||||
It.IsAny<Func<It.IsAnyType, Exception, string>>()), Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,41 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<CollectCoverage>true</CollectCoverage>
|
||||
<CoverletOutputFormat>opencover</CoverletOutputFormat>
|
||||
<CoverletOutput>../coverage/</CoverletOutput>
|
||||
<ExcludeByFile>**/Program.cs</ExcludeByFile> <!-- if you don’t want the host code -->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="NUnit" Version="4.2.2"/>
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.4.0"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="NUnit.Framework"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TinfoilVibeServer\TinfoilVibeServer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Data\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="LibHac">
|
||||
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace TinfoilVibeServerTest;
|
||||
|
||||
public class Tests
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test1()
|
||||
{
|
||||
Assert.Pass();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user