Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9d535a34 |
@@ -1,100 +0,0 @@
|
|||||||
name: Build & Push Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Trigger on pushes to main or release branches, and on manual workflow dispatch
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'release/**'
|
|
||||||
- 'beta/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- name: Convert to lowercase
|
|
||||||
id: github_repository_to_lowercase
|
|
||||||
run: |
|
|
||||||
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
|
||||||
raw_value="${{ github.repository }}"
|
|
||||||
# Convert to lower case
|
|
||||||
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
|
||||||
# Export it to the workflow environment
|
|
||||||
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
|
||||||
# If you want to use it as an output of this step:
|
|
||||||
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 1. Checkout repository
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # needed for git rev‑parse and tag generation
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 2. Set up Docker Buildx (optional, but recommended for multi‑arch)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 3. Log in to the Gitea container registry
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Log in to Gitea registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ secrets.REGISTRY_HOST }} # e.g. registry.example.com
|
|
||||||
username: ${{ secrets.REGISTRY_USER }} # e.g. admin
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }} # e.g. <api‑token>
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 4. Build the Docker image
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Build image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: TinfoilVibeServer/Dockerfile
|
|
||||||
# do not push yet
|
|
||||||
push: false
|
|
||||||
tags: |
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
|
||||||
build-args: |
|
|
||||||
# Add any build args here
|
|
||||||
# ARG_NAME=VALUE
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 5. Push the image to the registry
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Push image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: TinfoilVibeServer/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 6. (Optional) Clean up local Docker cache
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Docker system prune
|
|
||||||
run: docker system prune -f
|
|
||||||
if: ${{ always() }}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 7. Output useful info
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
- name: Show pushed image tags
|
|
||||||
run: |
|
|
||||||
echo "Pushed image tags:"
|
|
||||||
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}"
|
|
||||||
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}"
|
|
||||||
echo "- ${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest"
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_linux:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Convert to lowercase
|
|
||||||
id: github_repository_to_lowercase
|
|
||||||
run: |
|
|
||||||
# Grab the value (you can also use `${{ github.ref }}`, `${{ secrets.MY_SECRET }}`, etc.)
|
|
||||||
raw_value="${{ github.repository }}"
|
|
||||||
# Convert to lower case
|
|
||||||
lower_value=$(echo "$raw_value" | tr '[:upper:]' '[:lower:]')
|
|
||||||
# Export it to the workflow environment
|
|
||||||
# echo "MY_LOWER=$lower_value" >> $GITHUB_ENV
|
|
||||||
# If you want to use it as an output of this step:
|
|
||||||
echo "lowercase=$lower_value" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: TinfoilVibeServer/Dockerfile
|
|
||||||
# do not push yet
|
|
||||||
push: false
|
|
||||||
tags: |
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.sha }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:${{ github.ref_name }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/${{ steps.github_repository_to_lowercase.outputs.lowercase }}:latest
|
|
||||||
build-args: |
|
|
||||||
# Add any build args here
|
|
||||||
# ARG_NAME=VALUE
|
|
||||||
Vendored
BIN
Binary file not shown.
@@ -7,7 +7,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServer", "TinfoilVibeServer\TinfoilVibeServer.csproj", "{DE992FDB-6D13-4152-925D-29D39A23FB75}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTest", "TinfoilVibeServerTest\TinfoilVibeServerTest.csproj", "{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinfoilVibeServerTests", "TinfoilVibeServerTests\TinfoilVibeServerTests.csproj", "{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -19,9 +19,9 @@ Global
|
|||||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU
|
{DE992FDB-6D13-4152-925D-29D39A23FB75}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{E0A5CACD-E3F9-4420-AA14-4C447CCF430A}.Release|Any CPU.Build.0 = Release|Any CPU
|
{EB041FA0-F87C-4F24-9E39-9BAF3D3776D8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,104 +1,8 @@
|
|||||||
<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">
|
<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:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CLibHac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005CCloud_005CGit_005CLibHac_005Csrc_005CLibHac_005Cbin_005CRelease_005Cnet8_002E0_005CLibHac_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||||
<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: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_003AContains_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F67987bd98adb31db56e1521f051c645ef14c5c8767f3826e274fb9087354cee_003FContains_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_003AContentMetaType_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7df76fe8248d4d00bc92ef3a1d9ce9a0189a00_003F0e_003Fde2efd6d_003FContentMetaType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArchiveReader_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff0a43ad18bad5a87cbf2acea8abe9bb9b0a35a54aaabd6ab2dfea29281543_003FArchiveReader_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_003AArrayMemoryPool_002EArrayMemoryPoolBuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8d472c6e66d6b1c7ae84d34d6a2e85aeee5ad6861eabf9ea9b5e85f7595c96cf_003FArrayMemoryPool_002EArrayMemoryPoolBuffer_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_003ABuffer_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F7ee725ee96f111d37690c58fee67515d5d1706bf9892196531efff8346adf_003FBuffer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACancellationTokenSource_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F558c1d46e1e21d2e78ee2ab67a674f6927bf95355b2f245f35d74bb5ec0f92_003FCancellationTokenSource_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACnmt_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fb0_003Fa6f99852_003FCnmt_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_003AControllerBase_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6fa05d7dbf2e454ce78b261b422234da1f1ccedee678fd997e5ef4afe6df6e_003FControllerBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACryptoUtil_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd4_003F032ece9d_003FCryptoUtil_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADefaultHttpContext_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6af28599e71724c5fc6a617444e2f60b5d57dfdc5be0df4ba43ccfc36977_003FDefaultHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADictionary_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe5d623ea960f2c3c9fda144954d339f8d4cd3dad6dd8bd4ab96093a010ab_003FDictionary_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_003AFirst_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbc14b6df5cd75368b65afefb4bd2493c9facd3bbb41af2b1d0eab7e8eee87dbf_003FFirst_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_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashAlgorithm_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fce7580e8e0f6a2637f8ec8ab41c5fd2f845ad94ea18c76d554db62248d8954_003FHashAlgorithm_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_003AResult_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F13_003Faf246217_003FResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHostingAbstractionsHostExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F41efb8821245d1097e7a593356b9bd4e26a16282ac228833317de7645a9d81_003FHostingAbstractionsHostExtensions_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></wpf:ResourceDictionary>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHost_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcfb8165d037ea5062589e7be2a322d53397b1abadab026bb319132b6a24556_003FHost_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_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fca01a4e200c84eee8955133d13d81dc8c0e00_003F1a_003F8011ac3b_003FIArchiveEntryExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIArchiveEntryExtensions_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe82cab4cb05dfe89c48ab345f842025771adcc674cc9ede9c399369f8ab8748_003FIArchiveEntryExtensions_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIFile_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F9b_003Fef91f762_003FIFile_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_003AListeningStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F633629cdc36745a741a359d1d83ca239ddb3ac7c7dd49ce6b63025acd5f5e8e2_003FListeningStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1b81cb3be224213a6a73519b6e340a628d9a1fb8629c351a186a26f6376669_003FList_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_003AMonitor_002ECoreCLR_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ff5c5fabf10b2751774ff136491ac41fbc0932fbd1315879ac29f2d13e2ad24b_003FMonitor_002ECoreCLR_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANxFileStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F35_003F56be8607_003FNxFileStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObject_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F9a6b1457cbcf17db31a383ba49ef9bcc786cf3ef77146d997eee499b27a46d_003FObject_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemCore_00604_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F42_003Ff49d31db_003FPartitionFileSystemCore_00604_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemFormat_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F12_003Ff913ca40_003FPartitionFileSystemFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystemMetaCore_00603_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F84_003F3a6a0a14_003FPartitionFileSystemMetaCore_00603_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APartitionFileSystem_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F94_003F00e38e6a_003FPartitionFileSystem_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_003APath_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F8bfc57955751d07e3fad13bb97d5f66af412d7a34486ec29ad916547ec8ce6b_003FPath_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARandomAccess_002EWindows_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F5cb442cc26c0b982afc257bb62f18d1554630732b2b8245e97107fb51974d_003FRandomAccess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc9e95c2dd9e75e585f636790bc74e1484a536fc029954c307fe585a3822129_003FRarArchiveEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchiveVolumeFactory_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F416daa6b333a5ecff282ed559ac81071113ee993ebcdc662c5cf488d7072575b_003FRarArchiveVolumeFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarArchive_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F897247968e1ee1cdab3bf5f342eab3992b66d159a27a99ebb88a7593da4aeb_003FRarArchive_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarEntry_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F48a2e7ca6b54673c580a84a5f84c86d7a8cf2b199df9ca68655d9734e95_003FRarEntry_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARarStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fc35d82af548651a2b21dec4154692cd38619ed1ae76afd1aae437738cde798_003FRarStream_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AReadOnlySpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F47bfed48817fad7d8e1a89bf3530e4be7277b022a9c7477c5a243031605a5f_003FReadOnlySpan_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_003ASpanHelpers_002EByteMemOps_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F6169f1bb3c3c365c1f886cb89adaa3d156367d5b9d3e238bb46901cffce27_003FSpanHelpers_002EByteMemOps_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASpan_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Faa74b2b4e9988ce29825f798c3ef4254a9bde18b81c60754798e520d5445a27_003FSpan_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F2854ce6d56c18d0d837d3a3ef9c4f2c7c77691fa3528c8394986ac7ce7719_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamStorage_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa6_003F13a0d744_003FStreamStorage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStream_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fd1287462d4ec4078c61b8e92a0952fb7de3e7e877d279e390a4c136a6365126_003FStream_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_003AThreadPoolWorkQueue_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F478cd37a9cf5552e58e069a6aafb2a112b89b262faef3cfb5054d65b1ea95345_003FThreadPoolWorkQueue_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/@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/@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_003ATitleVersion_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa9_003F226f6bd5_003FTitleVersion_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AToCollection_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F439c4ee753b23e743cc14119593bc889751f9eb0b38997577d8e4c47c4fed_003FToCollection_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_003AUniqueRef_00601_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fa0_003F631946a0_003FUniqueRef_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnpack_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F4c375bcb6a2d2378855cad1b1c7cfe7ca1448866f1e8af44226775b5f75df86_003FUnpack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUnsafeHelpers_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003Fd1_003Fc59f91c2_003FUnsafeHelpers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUtility_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3ea3ed6216d2412ac7c33016ad940618bcfbcafe1633dc26832be514633b4_003FUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AXciPartition_002Ecs_002Fl_003AC_0021_003FUsers_003Fecens_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F77078e9a1d254191bb508f54a277fc6e1c2e00_003F04_003F4e8815da_003FXciPartition_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" />
|
|
||||||
<Assembly Path="D:\Cloud\Git\TinfoilVibeServer\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" />
|
|
||||||
</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,10 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Settings for AuthStore loaded from appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AuthSettings(
|
|
||||||
string CredentialsFile,
|
|
||||||
string FingerprintsFile,
|
|
||||||
string BlacklistFile,
|
|
||||||
int MaxFailedAttempts);
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
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 class AuthStore : IDisposable, IAuthStore
|
|
||||||
{
|
|
||||||
private readonly ILogger<AuthStore> _logger;
|
|
||||||
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();
|
|
||||||
private readonly HashSet<string> BlacklistIPs = new();
|
|
||||||
|
|
||||||
private readonly object _sync = new();
|
|
||||||
private readonly FileSystemWatcher _credentialsWatcher;
|
|
||||||
|
|
||||||
public AuthStore(ILogger<AuthStore> logger, ConfigManager configManager)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_configManager = configManager;
|
|
||||||
|
|
||||||
LoadAll();
|
|
||||||
|
|
||||||
var directoryName = Path.GetDirectoryName(_configManager.Settings.CredentialsFile);
|
|
||||||
_credentialsWatcher = new FileSystemWatcher
|
|
||||||
{
|
|
||||||
Path = (!string.IsNullOrEmpty(directoryName))?directoryName : AppContext.BaseDirectory,
|
|
||||||
Filter = Path.GetFileName(_configManager.Settings.CredentialsFile),
|
|
||||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
|
||||||
};
|
|
||||||
_credentialsWatcher.Changed += (_, _) => OnCredentialsChanged();
|
|
||||||
_credentialsWatcher.EnableRaisingEvents = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_credentialsWatcher?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Loading helpers
|
|
||||||
|
|
||||||
private void LoadAll()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Loading authentication data from {File}", _configManager.Settings.CredentialsFile);
|
|
||||||
// credentials
|
|
||||||
if (File.Exists(_configManager.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(_configManager.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(_configManager.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Watcher callbacks
|
|
||||||
|
|
||||||
private void OnCredentialsChanged()
|
|
||||||
{
|
|
||||||
// Small debounce – the file may still be locked by the editor.
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(200);
|
|
||||||
ReloadCredentials();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReloadCredentials()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_configManager.Settings.CredentialsFile))
|
|
||||||
{
|
|
||||||
_logger.LogError("Credentials file {File} does not exist", _configManager.Settings.CredentialsFile);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var txt = File.ReadAllText(_configManager.Settings.CredentialsFile);
|
|
||||||
var newDict = JsonSerializer.Deserialize<Dictionary<string, Credential>>(txt)!;
|
|
||||||
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
// Update existing users & add new ones
|
|
||||||
foreach (var kv in newDict)
|
|
||||||
{
|
|
||||||
if (Credentials.TryGetValue(kv.Key, out var existing))
|
|
||||||
{
|
|
||||||
existing.PasswordHash = kv.Value.PasswordHash;
|
|
||||||
existing.AllowedUidCount = kv.Value.AllowedUidCount;
|
|
||||||
existing.Verified = kv.Value.Verified;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Credentials[kv.Key] = kv.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove users that were deleted from the file
|
|
||||||
var toRemove = Credentials.Keys.Except(newDict.Keys).ToList();
|
|
||||||
foreach (var key in toRemove)
|
|
||||||
{
|
|
||||||
Credentials.TryRemove(key, out _);
|
|
||||||
Fingerprints.TryRemove(key, out _);
|
|
||||||
FailedAttempts.TryRemove(key, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch(Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to reload credentials from {File}", _configManager.Settings.CredentialsFile);
|
|
||||||
// ignore – malformed JSON or IO error – keep old state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Authentication logic
|
|
||||||
|
|
||||||
public bool TryValidate(string username,
|
|
||||||
string password,
|
|
||||||
int? uid,
|
|
||||||
string ip,
|
|
||||||
out string? error)
|
|
||||||
{
|
|
||||||
error = null;
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
if (!Credentials.TryGetValue(username, out var cred))
|
|
||||||
{
|
|
||||||
// Create user on the fly
|
|
||||||
cred = new Credential(username, PasswordHash: ComputeHash(password), 1, Verified: false);
|
|
||||||
Credentials[username] = cred;
|
|
||||||
PersistCredentials();
|
|
||||||
_logger.LogInformation("Created new user {Username} (verified={Verified})", username, cred.Verified);
|
|
||||||
|
|
||||||
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
|
|
||||||
if (uid.HasValue && !list.Contains(uid.Value))
|
|
||||||
{
|
|
||||||
if (list.Count < cred.AllowedUidCount)
|
|
||||||
list.Add(uid.Value);
|
|
||||||
PersistFingerprints();
|
|
||||||
}
|
|
||||||
|
|
||||||
error = "User not verified";
|
|
||||||
IncrementFailed(username, ip);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!VerifyPasswordHash(password, cred.PasswordHash))
|
|
||||||
{
|
|
||||||
error = "Invalid password";
|
|
||||||
IncrementFailed(username, ip);
|
|
||||||
_logger.LogWarning("Auth failed for {Username} from {IP} – {Error}", username, ip, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cred.Verified)
|
|
||||||
{
|
|
||||||
error = "User not verified";
|
|
||||||
IncrementFailed(username, ip);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uid.HasValue)
|
|
||||||
{
|
|
||||||
var list = Fingerprints.GetOrAdd(username, _ => new List<int>());
|
|
||||||
|
|
||||||
if (!list.Contains(uid.Value))
|
|
||||||
{
|
|
||||||
if (list.Count < cred.AllowedUidCount)
|
|
||||||
{
|
|
||||||
list.Add(uid.Value);
|
|
||||||
PersistFingerprints();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
error = $"UID limit ({cred.AllowedUidCount}) exceeded";
|
|
||||||
IncrementFailed(username, ip);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FailedAttempts[username] = 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int IncrementFailed(string username, string ip)
|
|
||||||
{
|
|
||||||
var newCount = FailedAttempts.GetOrAdd(username, 0) + 1;
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
FailedAttempts[username] = newCount;
|
|
||||||
}
|
|
||||||
_logger.LogInformation("Failed attempts for {Username} increased to {Count}", username, newCount);
|
|
||||||
|
|
||||||
if (newCount < _configManager.Settings.MaxFailedAttempts+1) return newCount;
|
|
||||||
|
|
||||||
BlacklistIPs.Add(ip);
|
|
||||||
PersistBlacklist();
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
FailedAttempts[username] = 0;
|
|
||||||
}
|
|
||||||
_logger.LogWarning("IP {IP} blacklisted after {Count} failures", ip, newCount);
|
|
||||||
return newCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Helpers
|
|
||||||
|
|
||||||
public static string ComputeHash(string input)
|
|
||||||
{
|
|
||||||
using var sha = SHA256.Create();
|
|
||||||
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
|
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool VerifyPasswordHash(string plain, string storedHash)
|
|
||||||
=> string.Equals(ComputeHash(plain), storedHash, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private void PersistCredentials()
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(Credentials, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
File.WriteAllText(_configManager.Settings.CredentialsFile, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistFingerprints()
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(Fingerprints, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
File.WriteAllText(_configManager.Settings.FingerprintsFile, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistBlacklist()
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(BlacklistIPs.ToArray(), new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Authentication;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User credential record.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record Credential(
|
|
||||||
string Username,
|
|
||||||
string PasswordHash,
|
|
||||||
int AllowedUidCount,
|
|
||||||
bool Verified
|
|
||||||
)
|
|
||||||
{
|
|
||||||
public string PasswordHash { get; set; } = PasswordHash;
|
|
||||||
public int AllowedUidCount{ get; set; } = AllowedUidCount;
|
|
||||||
public bool Verified { get; set; } = Verified;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// File: TinfoilVibeServer/Config/GameDirectoriesOptions.cs
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Config;
|
||||||
|
|
||||||
|
public sealed class GameDirectoriesOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Paths to scan for Switch ROMs and archives. Values may change at runtime.
|
||||||
|
/// </summary>
|
||||||
|
public IList<string> Paths { get; set; } = new List<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Configuration/AppSettings.cs
|
||||||
|
namespace TinfoilVibeServer.Configuration
|
||||||
|
{
|
||||||
|
public class AppSettings
|
||||||
|
{
|
||||||
|
public List<string> GameDirectories { get; set; } = new();
|
||||||
|
public List<string> ValidExtensions { get; set; } = new() { ".nsp", ".xci", ".zip", ".rar", ".7z" };
|
||||||
|
public string SnapshotPath { get; set; } = "snapshot.json";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Controllers;
|
|
||||||
|
|
||||||
public class CancelableFileResult : FileResult
|
|
||||||
{
|
|
||||||
private readonly Stream _fileStream;
|
|
||||||
|
|
||||||
public CancelableFileResult(string contentType, Stream fileStream)
|
|
||||||
: base(contentType)
|
|
||||||
{
|
|
||||||
_fileStream = fileStream ?? throw new ArgumentNullException(nameof(fileStream));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allows you to set a suggested download name.
|
|
||||||
/// It will be sent in a “Content‑Disposition” header.
|
|
||||||
/// </summary>
|
|
||||||
public string? FileDownloadName { get; set; }
|
|
||||||
|
|
||||||
public override async Task ExecuteResultAsync(ActionContext context)
|
|
||||||
{
|
|
||||||
var response = context.HttpContext.Response;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(FileDownloadName))
|
|
||||||
{
|
|
||||||
// Typical “attachment” disposition – most browsers honour it.
|
|
||||||
response.Headers.Append("Content-Disposition",
|
|
||||||
$"attachment; filename=\"{FileDownloadName}\"");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The request‑aborted token tells the stream copy to stop ASAP
|
|
||||||
var cancellationToken = context.HttpContext.RequestAborted;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Copy the file to the response body in 8 KiB chunks
|
|
||||||
await _fileStream.CopyToAsync(
|
|
||||||
response.Body,
|
|
||||||
bufferSize: 81920, // 80 KiB – default for Stream.CopyToAsync
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// The client disconnected – nothing to do.
|
|
||||||
// Swallowing keeps the API from returning a 500 error.
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _fileStream.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using SharpCompress.Readers;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
using TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("/")]
|
|
||||||
public sealed class IndexController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ISnapshotService _snapshotService;
|
|
||||||
private readonly TitleDatabaseService _titleDb;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IndexBuilderService _indexBuilderService;
|
|
||||||
|
|
||||||
public IndexController(ISnapshotService snapshotService,
|
|
||||||
TitleDatabaseService titleDb,
|
|
||||||
IConfiguration configuration, IndexBuilderService indexBuilderService)
|
|
||||||
{
|
|
||||||
_snapshotService = snapshotService;
|
|
||||||
_titleDb = titleDb;
|
|
||||||
_configuration = configuration;
|
|
||||||
_indexBuilderService = indexBuilderService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// GET /
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
/// <summary>
|
|
||||||
/// Return the “index snapshot” – e.g. a JSON file that lists
|
|
||||||
/// every available resource. The snapshot could also be a
|
|
||||||
/// rendered Razor view – simply change the return type.
|
|
||||||
/// </summary>
|
|
||||||
public IActionResult Index()
|
|
||||||
{
|
|
||||||
if (HttpContext.Request.Headers.CacheControl == "no-cache")
|
|
||||||
{
|
|
||||||
_indexBuilderService.InvalidateIndex(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = _indexBuilderService.Build(HttpContext);
|
|
||||||
|
|
||||||
return Ok(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// GET /{*path}
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
/// <summary>
|
|
||||||
/// Catch‑all action. Any URL that is **not** “/” is routed
|
|
||||||
/// here. The value of *path* is the relative location you
|
|
||||||
/// want to stream back to the client.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="path">The relative file path requested.</param>
|
|
||||||
[HttpGet("{*path}")]
|
|
||||||
public async Task<IActionResult> Download(string path)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
|
||||||
return BadRequest("Missing url query parameter.");
|
|
||||||
|
|
||||||
// ---- 1️⃣ Parse the brackets --------------------------------
|
|
||||||
// Expected format: [name][TitleId][v][patchOrApp].nsp
|
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(path,
|
|
||||||
@"(?<name>.*?)\[(?<id>[0-9a-fA-F]{8}[0-9a-fA-F]{8})\]\[v(?<v>[0-9]+)\]\[(?<app>Base|Update)\]\.nsp",
|
|
||||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
if (!match.Success)
|
|
||||||
return BadRequest("Url does not match the expected pattern.");
|
|
||||||
|
|
||||||
var titleId = match.Groups["id"].Value.ToUpperInvariant();
|
|
||||||
|
|
||||||
// ---- 2️⃣ Find the file that contains this TitleId ------------
|
|
||||||
var entry = _snapshotService.GetSnapshot().Files
|
|
||||||
.FirstOrDefault(e => { return e.Titles.FirstOrDefault(hash => hash.TitleId == titleId)?.TitleId == titleId; });
|
|
||||||
|
|
||||||
if (entry == null)
|
|
||||||
return NotFound("No file with that TitleId found.");
|
|
||||||
|
|
||||||
// ---- 3️⃣ If the file is a normal NSP → send it ----------------
|
|
||||||
|
|
||||||
if (Path.GetExtension(entry.Path).Equals(".nsp", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !entry.Path.Contains(_snapshotService.GetArchivePathSeparator()))
|
|
||||||
{
|
|
||||||
if (System.IO.File.Exists(entry.Path))
|
|
||||||
{
|
|
||||||
// 1️⃣ Open the file for async read
|
|
||||||
var fileStream = new FileStream(
|
|
||||||
entry.Path,
|
|
||||||
FileMode.Open,
|
|
||||||
FileAccess.Read,
|
|
||||||
FileShare.Read,
|
|
||||||
bufferSize: 128 * 1024 * 1024, // 81920, // 80 KiB
|
|
||||||
useAsync: true); // <‑‑ VERY important for scalability
|
|
||||||
|
|
||||||
// 2️⃣ Return a cancellation‑aware result
|
|
||||||
return new CancelableFileResult(
|
|
||||||
contentType: "application/octet-stream",
|
|
||||||
fileStream: fileStream)
|
|
||||||
{
|
|
||||||
FileDownloadName = Path.GetFileName(entry.Path) // optional but nice
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it is inside an archive.
|
|
||||||
// If the path contains a slash that is not the root separator
|
|
||||||
// it might be an entry inside an archive; we simply stream it.
|
|
||||||
// - For normal files, we can use SendFileAsync.
|
|
||||||
// - For archives, we stream the entry using ArchiveHandler.
|
|
||||||
|
|
||||||
if (IsInsideArchive(entry.Path))
|
|
||||||
{
|
|
||||||
// Example: file is inside an archive – use ArchiveHandler
|
|
||||||
var innerFileName = entry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last();
|
|
||||||
var stream = StreamFromArchive(entry, titleId, out var streamContainer);
|
|
||||||
|
|
||||||
if (stream == null)
|
|
||||||
return NotFound("Could not stream entry from archive.");
|
|
||||||
|
|
||||||
var wrappedStream = new DependentStream(stream, streamContainer);
|
|
||||||
var contentDisposition = $"inline; filename=\"{innerFileName}\"";
|
|
||||||
|
|
||||||
Response.ContentType = "application/octet-stream";
|
|
||||||
Response.ContentLength = entry.Size;
|
|
||||||
Response.Headers["Content-Disposition"] = contentDisposition;
|
|
||||||
await using var entryStream = wrappedStream;
|
|
||||||
const int bufferSize = 10 * 1024 * 1024;// 81920; // 80 KB – default used by CopyToAsync
|
|
||||||
await entryStream.CopyToAsync(Response.Body, bufferSize, HttpContext.RequestAborted);
|
|
||||||
|
|
||||||
// Once the copy completes, the `await using` disposes the archive.
|
|
||||||
return new EmptyResult(); // We already wrote the stream to Response.Body
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotFound("Requested URL does not reference an NSP file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Very light‑weight helper – decides whether the file path
|
|
||||||
/// represents a file inside an archive.
|
|
||||||
/// </summary>
|
|
||||||
private bool IsInsideArchive(string path)
|
|
||||||
{
|
|
||||||
// If the path contains a separator that is not a root separator
|
|
||||||
// (e.g. "Games/MyGame.nsp" is a regular file; "archive.7z/mygame.nsp"
|
|
||||||
// would be inside an archive). For simplicity we only check
|
|
||||||
// for common archive extensions.
|
|
||||||
var filePath = path.Split(_snapshotService.GetArchivePathSeparator()).First();
|
|
||||||
return filePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
filePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
filePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If the NSP is inside an archive, this method opens the archive
|
|
||||||
/// and returns the entry stream. It is deliberately minimal –
|
|
||||||
/// if the archive can’t be opened we return null.
|
|
||||||
/// </summary>
|
|
||||||
private Stream? StreamFromArchive(FileEntry fileEntry, string titleId, out IDisposable? streamContainer)
|
|
||||||
{
|
|
||||||
// Example: file is inside an archive – use ArchiveHandler
|
|
||||||
var archivePath = fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).First();
|
|
||||||
_snapshotService.GetArchiveName(titleId);
|
|
||||||
var innerFileName = Path.GetFileName(fileEntry.Path.Split(_snapshotService.GetArchivePathSeparator()).Last());
|
|
||||||
|
|
||||||
// Use SharpCompress to open the archive and find the entry.
|
|
||||||
// Only the 3 archive types we support are handled.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Check which archive type
|
|
||||||
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var zip = SharpCompress.Archives.Zip.ZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
|
|
||||||
streamContainer = zip;
|
|
||||||
var entry = zip.Entries
|
|
||||||
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (entry != null)
|
|
||||||
return entry.OpenEntryStream();
|
|
||||||
}
|
|
||||||
else if (archivePath.EndsWith(".7z", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var sevenZip = SharpCompress.Archives.SevenZip.SevenZipArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
|
|
||||||
streamContainer = sevenZip;
|
|
||||||
var entry = sevenZip.Entries
|
|
||||||
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (entry != null)
|
|
||||||
return entry.OpenEntryStream();
|
|
||||||
}
|
|
||||||
else if (archivePath.EndsWith(".rar", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var rar = SharpCompress.Archives.Rar.RarArchive.Open(archivePath, new ReaderOptions { LeaveStreamOpen = true });
|
|
||||||
streamContainer = rar;
|
|
||||||
var entry = rar.Entries
|
|
||||||
.FirstOrDefault(e => e.Key != null && e.Key.Equals(innerFileName,
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
if (entry != null)
|
|
||||||
return entry.OpenEntryStream();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignore – we will just return null and the controller
|
|
||||||
// will respond with 404.
|
|
||||||
}
|
|
||||||
|
|
||||||
streamContainer = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class DependentStream : Stream
|
|
||||||
{
|
|
||||||
private readonly Stream _innerStream;
|
|
||||||
private readonly IDisposable? _parentContainer;
|
|
||||||
|
|
||||||
public DependentStream(Stream innerStream, IDisposable? parentContainer)
|
|
||||||
{
|
|
||||||
_innerStream = innerStream;
|
|
||||||
_parentContainer = parentContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Flush() => _innerStream.Flush();
|
|
||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);
|
|
||||||
|
|
||||||
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
|
|
||||||
|
|
||||||
public override void SetLength(long value) => _innerStream.SetLength(value);
|
|
||||||
|
|
||||||
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
|
|
||||||
|
|
||||||
|
|
||||||
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanRead => _innerStream.CanRead;
|
|
||||||
public override bool CanSeek => _innerStream.CanSeek;
|
|
||||||
public override bool CanWrite => _innerStream.CanWrite;
|
|
||||||
public override long Length => _innerStream.Length;
|
|
||||||
public override long Position { get => _innerStream.Position; set => _innerStream.Position = value; }
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
_parentContainer?.Dispose();
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using TinfoilVibeServer.Authentication;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Middleware;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal Basic‑Auth middleware that also checks UID, failure counters and a blacklist.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BasicAuthMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
|
|
||||||
public BasicAuthMiddleware(RequestDelegate next)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context, IAuthStore store, ILogger<BasicAuthMiddleware> logger)
|
|
||||||
{
|
|
||||||
// ------------- 1) Bypass auth for every path except “/” ----------------
|
|
||||||
// PathString is a struct – compare its value directly.
|
|
||||||
if (!context.Request.Path.Equals("/", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
await _next(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.IsIPBlacklisted(ip))
|
|
||||||
{
|
|
||||||
logger.LogWarning("Blocked request from blacklisted IP {IP}", ip);
|
|
||||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
||||||
await context.Response.WriteAsync("Forbidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Authorization header
|
|
||||||
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeaders))
|
|
||||||
{
|
|
||||||
logger.LogWarning("Missing Authorization header from {IP}", ip);
|
|
||||||
Challenge(context);
|
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var authHeader = authHeaders.FirstOrDefault() ?? "";
|
|
||||||
if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
Challenge(context);
|
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string decoded;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var b64 = authHeader[6..].Trim();
|
|
||||||
decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Challenge(context);
|
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts = decoded.Split(':', 2);
|
|
||||||
if (parts.Length != 2)
|
|
||||||
{
|
|
||||||
Challenge(context);
|
|
||||||
logger.LogInformation("Sent 401 challenge to client");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var username = parts[0];
|
|
||||||
var password = parts[1];
|
|
||||||
|
|
||||||
// 3) UID header (optional)
|
|
||||||
int? uid = null;
|
|
||||||
if (context.Request.Headers.TryGetValue("UID", out var uidHeader))
|
|
||||||
{
|
|
||||||
if (int.TryParse(uidHeader.ToString(), out var parsedUid))
|
|
||||||
uid = parsedUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Validate
|
|
||||||
if (!store.TryValidate(username, password, uid, ip, out var error))
|
|
||||||
{
|
|
||||||
logger.LogWarning("Auth failed for user {User} from {IP}: {Error}", username, ip, error);
|
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
||||||
context.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
|
||||||
await context.Response.WriteAsync(error ?? "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication succeeded – attach username for downstream handlers if needed
|
|
||||||
context.Items["User"] = username;
|
|
||||||
logger.LogInformation("User {User} authenticated successfully (UID={UID})", username, uid);
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Challenge(HttpContext ctx)
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
||||||
ctx.Response.Headers.Append("WWW-Authenticate", "Basic realm=\"FileSnapshot\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Top‑level configuration – maps directly to appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AppSettings(
|
|
||||||
string[] RootDirectories,
|
|
||||||
string[] WhitelistExtensions,
|
|
||||||
string[] RomExtensions,
|
|
||||||
string CredentialsFile,
|
|
||||||
string FingerprintsFile,
|
|
||||||
string BlacklistFile,
|
|
||||||
int MaxFailedAttempts,
|
|
||||||
string KeySetFile
|
|
||||||
);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One line in the snapshot – the JSON will be an array of these.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record FileEntry(
|
|
||||||
string Path, // nsp or archive path
|
|
||||||
long Size, // size of nsp or full archive
|
|
||||||
string Hash, // SHA‑256 hex of first NCA of first NCP in NSP or archive
|
|
||||||
List<NcaMetadataWithHash> Titles // Details of all NSP Roms in the Path
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Models
|
||||||
|
{
|
||||||
|
public class GameSnapshot
|
||||||
|
{
|
||||||
|
public string TitleId { get; set; } // 16‑hex string
|
||||||
|
public int Version { get; set; }
|
||||||
|
public bool IsApplication { get; set; }
|
||||||
|
public bool IsPatch { get; set; }
|
||||||
|
|
||||||
|
public string FullPath { get; set; } // Path on disk (file or archive)
|
||||||
|
public string InsidePath { get; set; } // Path inside an archive (empty if none)
|
||||||
|
public string FirstNcaHash { get; set; } // SHA‑256 of the first NCA stream
|
||||||
|
|
||||||
|
public DateTime LastModified { get; set; } // For snapshot refresh logic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
|
|
||||||
public static class IdHelper
|
|
||||||
{
|
|
||||||
public static string ToStrId(this int num)
|
|
||||||
{
|
|
||||||
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ToStrId(this uint num)
|
|
||||||
{
|
|
||||||
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ToStrId(this long num)
|
|
||||||
{
|
|
||||||
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ToStrId(this ulong num)
|
|
||||||
{
|
|
||||||
return ToIdFromConvertedNumBytes(BitConverter.GetBytes(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ToStrId(this IEnumerable<byte> bytes)
|
|
||||||
{
|
|
||||||
return bytes.Aggregate("", (current, b) => current + b.ToString("X2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ToStrId(this Span<byte> bytes)
|
|
||||||
{
|
|
||||||
return bytes.ToArray().ToStrId();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ToIdFromConvertedNumBytes(IEnumerable<byte> getBytes)
|
|
||||||
{
|
|
||||||
return ToStrId(getBytes.Reverse());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetTitleId(this FileInfo fileInfo)
|
|
||||||
{
|
|
||||||
var match = Regex.Match(fileInfo.Name, "^.*\\[(\\w{16})\\].*\\.nsp$");
|
|
||||||
return match is { Length: > 0, Groups.Count: > 1 }?match.Groups[1].Value:string.Empty;
|
|
||||||
}
|
|
||||||
#region 1️⃣ From hex string → byte[] (big‑endian)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Turns an even‑length hex string into a byte array in **big‑endian** order
|
|
||||||
/// (the same order that the original <c>ToStrId</c> creates).
|
|
||||||
/// </summary>
|
|
||||||
private static byte[] HexStringToBytes(string hex)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(hex))
|
|
||||||
throw new ArgumentException("ID string cannot be empty", nameof(hex));
|
|
||||||
|
|
||||||
if (hex.Length % 2 != 0)
|
|
||||||
throw new ArgumentException("ID string must contain an even number of hex digits", nameof(hex));
|
|
||||||
|
|
||||||
var bytes = new byte[hex.Length / 2];
|
|
||||||
for (var i = 0; i < bytes.Length; i++)
|
|
||||||
{
|
|
||||||
var chunk = hex.Substring(i * 2, 2);
|
|
||||||
bytes[i] = byte.Parse(chunk, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public static long ToLongFromStrId(this string hex) =>
|
|
||||||
BitConverter.ToInt64(HexStringToBytes(hex).Reverse().ToArray(), 0);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
public class IndexBuilderSettings
|
|
||||||
{
|
|
||||||
public string ApiBaseUrl { get; set; } = "http://tinfoil.localhost";
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The JSON object that will be returned for the “index” route.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record IndexDto(
|
|
||||||
List<FileDto> Files,
|
|
||||||
List<string> Directories,
|
|
||||||
string Success);
|
|
||||||
|
|
||||||
public sealed record FileDto(
|
|
||||||
string Url,
|
|
||||||
long Size);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using LibHac.Common.Keys;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A tiny static holder that contains the KeySet loaded from disk.
|
|
||||||
/// All parts of the application that need keys just read this property.
|
|
||||||
/// </summary>
|
|
||||||
public static class KeySetHolder
|
|
||||||
{
|
|
||||||
public static KeySet KeySet { get; set; } = new KeySet();
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO that is returned by the extractor.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record NcaMetadataDto(
|
|
||||||
string TitleId, // 16‑digit hex, e.g. 0004000000000000
|
|
||||||
int Version, // header version – 0 = application, >0 = patch
|
|
||||||
bool IsApplication, // true if the NSP is an application
|
|
||||||
bool IsPatch // true if the NSP is a patch
|
|
||||||
);
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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> _archiveExtensions = new();
|
|
||||||
public List<string> ArchiveExtensions
|
|
||||||
{
|
|
||||||
get => _archiveExtensions;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (_archiveExtensions != value)
|
|
||||||
{
|
|
||||||
_archiveExtensions = value;
|
|
||||||
OnPropertyChanged(nameof(_archiveExtensions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,8 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
public class TitleDbOptions
|
|
||||||
{
|
|
||||||
public int TtlSeconds { get; set; } = 60; // fallback
|
|
||||||
public string LanguageCode { get; set; } = "en";
|
|
||||||
public string CountryCode { get; set; } = "US";
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Metadata extracted from a Nintendo NSP/XCI archive.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record TitleInfo(
|
|
||||||
string TitleId, // e.g. 0004000000000000
|
|
||||||
string Name, // title name
|
|
||||||
string Version, // e.g. 1.02
|
|
||||||
bool IsApplication // true for applications, false for patches
|
|
||||||
);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One entry that is read from the JSON files on GitHub and can also be
|
|
||||||
/// constructed from an NSP. The key for the dictionary that stores these
|
|
||||||
/// objects is <see cref="TitleId"/>.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record TitleInfoDto(
|
|
||||||
string TitleId, // 16‑digit hex – “0004000000000000”
|
|
||||||
string Name,
|
|
||||||
string Id,
|
|
||||||
int? ReleaseDate,
|
|
||||||
long NSUID,
|
|
||||||
string Version);
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// File: TinfoilVibeServer/Persistence/ISnapshotRepository.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Persistence;
|
||||||
|
|
||||||
|
public interface ISnapshotRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyDictionary<string, SnapshotEntry>> LoadAsync();
|
||||||
|
Task PersistAsync(IReadOnlyDictionary<string, SnapshotEntry> snapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// File: TinfoilVibeServer/Persistence/SnapshotRepository.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Persistence;
|
||||||
|
|
||||||
|
public sealed class SnapshotRepository : ISnapshotRepository
|
||||||
|
{
|
||||||
|
private readonly string _path = "snapshot.json";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<string, SnapshotEntry>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, SnapshotEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(_path);
|
||||||
|
var snapshot = await JsonSerializer.DeserializeAsync<Dictionary<string, SnapshotEntry>>(stream,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
return snapshot ?? new Dictionary<string, SnapshotEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PersistAsync(IReadOnlyDictionary<string, SnapshotEntry> snapshot)
|
||||||
|
{
|
||||||
|
await using var stream = File.Create(_path);
|
||||||
|
await JsonSerializer.SerializeAsync(stream, snapshot,
|
||||||
|
new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,53 @@
|
|||||||
using Microsoft.Extensions.Caching.Memory;
|
// File: TinfoilVibeServer/Program.cs
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using TinfoilVibeServer.Authentication;
|
using LibHac.Common.Keys;
|
||||||
using TinfoilVibeServer.Middleware;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using TinfoilVibeServer.Services;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using TinfoilVibeServer.Config;
|
||||||
using TinfoilVibeServer.Models;
|
using TinfoilVibeServer.Models;
|
||||||
|
using TinfoilVibeServer.Services;
|
||||||
|
using TinfoilVibeServer.Persistence;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
namespace TinfoilVibeServer;
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddConsole();
|
internal static class Program
|
||||||
builder.Logging.AddDebug();
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// 1) Configuration – read appsettings.json once and expose it via
|
|
||||||
// ConfigManager (reloads on file change)
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
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<INSPExtractor, NSPExtractor>(sp =>
|
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<ConfigManager>();
|
public static void Main(string[] args)
|
||||||
var logger = sp.GetRequiredService<ILogger<INSPExtractor>>();
|
|
||||||
var keySet = KeySetHolder.KeySet; // already loaded by ConfigManager
|
|
||||||
return new NSPExtractor(keySet, logger);
|
|
||||||
});
|
|
||||||
builder.Services.AddSingleton<SnapshotService>();
|
|
||||||
builder.Services.AddSingleton<ISnapshotService, SnapshotService>(sp => sp.GetRequiredService<SnapshotService>());
|
|
||||||
builder.Services.AddSingleton<IAuthStore, AuthStore>();
|
|
||||||
builder.Services.AddSingleton<TitleDatabaseService>();
|
|
||||||
builder.Services.AddSingleton<IArchiveHandler, ArchiveHandler>();
|
|
||||||
builder.Services.AddSingleton<IndexBuilderService>();
|
|
||||||
builder.Services.AddHostedService<SnapshotService>(provider => provider.GetRequiredService<SnapshotService>());
|
|
||||||
builder.Services.AddHostedService<TitleDatabaseService>(provider => provider.GetRequiredService<TitleDatabaseService>()).AddHttpClient();
|
|
||||||
builder.Services.AddHostedService<IndexBuilderService>(provider => provider.GetRequiredService<IndexBuilderService>());
|
|
||||||
builder.Services.AddControllers(); // add MVC
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// 2) Middleware – Basic‑Auth (verifies username, password, UID, blacklist)
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
app.UseMiddleware<BasicAuthMiddleware>();
|
|
||||||
app.MapControllers(); // routes the /index.json & /download endpoints
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// 3) End‑points
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
app.MapGet("/debug", () => new SnapshotService(
|
|
||||||
app.Services.GetRequiredService<IMemoryCache>(),
|
|
||||||
app.Services.GetRequiredService<IOptionsMonitor<SnapshotOptions>>(),
|
|
||||||
app.Services.GetRequiredService<INSPExtractor>(),
|
|
||||||
app.Services.GetRequiredService<IArchiveHandler>(),
|
|
||||||
app.Services.GetRequiredService<ILogger<SnapshotService>>())
|
|
||||||
.GetSnapshot());
|
|
||||||
app.Lifetime.ApplicationStarted.Register(() =>
|
|
||||||
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Application started. Listening on {Urls}", string.Join(", ", app.Urls)));
|
|
||||||
app.Use(async (ctx, next) =>
|
|
||||||
{
|
|
||||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
|
||||||
var correlationId = ctx.Request.Headers["X-Correlation-ID"].FirstOrDefault()
|
|
||||||
?? Guid.NewGuid().ToString();
|
|
||||||
using (logger.BeginScope("CorrelationId:{CorrelationId}", correlationId))
|
|
||||||
{
|
{
|
||||||
await next();
|
var host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureAppConfiguration((ctx, cfg) =>
|
||||||
|
{
|
||||||
|
cfg.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
|
||||||
|
cfg.AddEnvironmentVariables();
|
||||||
|
cfg.AddCommandLine(args);
|
||||||
|
})
|
||||||
|
.ConfigureServices((ctx, services) =>
|
||||||
|
{
|
||||||
|
// Configuration POCO
|
||||||
|
services.Configure<GameDirectoriesOptions>(ctx.Configuration.GetSection("GameDirectories"));
|
||||||
|
|
||||||
|
// Snapshot persistence
|
||||||
|
services.AddSingleton<ISnapshotRepository, SnapshotRepository>();
|
||||||
|
|
||||||
|
// LibHac parser
|
||||||
|
services.AddSingleton<ILibHacParser, LibHacParser>();
|
||||||
|
|
||||||
|
// Main service
|
||||||
|
services.AddHostedService<GameDirectoryWatcherService>();
|
||||||
|
KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(ctx.Configuration.GetSection("KeySet").Get<string>());
|
||||||
|
})
|
||||||
|
.ConfigureLogging((ctx, logging) =>
|
||||||
|
{
|
||||||
|
logging.ClearProviders();
|
||||||
|
logging.AddConsole(options =>
|
||||||
|
{
|
||||||
|
options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] ";
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
host.Run();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
app.Run();
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://192.168.1.145:80;http://tinfoil.localhost:8080;http://tinfoil.ecenshu.net",
|
"applicationUrl": "http://localhost:5253",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Repositories/SnapshotRepository.cs
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Repositories
|
||||||
|
{
|
||||||
|
public class SnapshotRepository
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
|
||||||
|
public SnapshotRepository(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SnapshotEntry> Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path))
|
||||||
|
return new();
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_path);
|
||||||
|
return JsonSerializer.Deserialize<List<SnapshotEntry>>(json) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Persist(IEnumerable<SnapshotEntry> entries)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(_path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using SharpCompress.Archives;
|
|
||||||
using SharpCompress.Archives.Zip;
|
|
||||||
using SharpCompress.Archives.Rar;
|
|
||||||
using SharpCompress.Archives.SevenZip;
|
|
||||||
using SharpCompress.Common;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
using TinfoilVibeServer.Utilities;
|
|
||||||
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>
|
|
||||||
IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
|
||||||
/// </summary>
|
|
||||||
IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 : IArchiveHandler
|
|
||||||
{
|
|
||||||
private readonly INSPExtractor _nspExtractor;
|
|
||||||
private readonly ILogger<ArchiveHandler> _logger;
|
|
||||||
|
|
||||||
public ArchiveHandler(INSPExtractor nspExtractor, ILogger<ArchiveHandler> logger)
|
|
||||||
{
|
|
||||||
_nspExtractor = nspExtractor;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return TitleInfo if an embedded Nintendo archive is found; otherwise null.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(string filePath)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Examining archive {File} for embedded NSP", filePath);
|
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
switch (ext)
|
|
||||||
{
|
|
||||||
case ".zip":
|
|
||||||
return HandleZip(filePath);
|
|
||||||
case ".7z":
|
|
||||||
return Handle7z(filePath);
|
|
||||||
case ".rar":
|
|
||||||
return HandleRar(filePath);
|
|
||||||
default:
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Unsupported archive type {Extension} – skipping", ext);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError("Error opening archive {File}: {Exception}", filePath, ex.Message);
|
|
||||||
// Graceful fallback – return null
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<(string, long, NcaMetadataWithHash)> TryExtractTitleInfos(Stream archiveStream, string archiveType)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<(string, long, NcaMetadataWithHash)> HandleZip(string path)
|
|
||||||
{
|
|
||||||
using var archive = ZipArchive.Open(path);
|
|
||||||
foreach (var entry in archive.Entries)
|
|
||||||
{
|
|
||||||
if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
|
|
||||||
|
|
||||||
var temp = Path.GetTempFileName();
|
|
||||||
entry.WriteToFile(temp);
|
|
||||||
var title = _nspExtractor.ExtractFromFile(temp); // instance call
|
|
||||||
File.Delete(temp);
|
|
||||||
if (title != null) yield return (entry.Key, entry.Size, title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<(string, long, NcaMetadataWithHash)> Handle7z(string path)
|
|
||||||
{
|
|
||||||
using var archive = SevenZipArchive.Open(path);
|
|
||||||
foreach (var entry in archive.Entries)
|
|
||||||
{
|
|
||||||
if (!entry.IsDirectory && IsRomArchive(entry.Key))
|
|
||||||
{
|
|
||||||
var temp = Path.GetTempFileName();
|
|
||||||
entry.WriteToFile(temp);
|
|
||||||
var title = _nspExtractor.ExtractFromFile(temp); // instance call
|
|
||||||
File.Delete(temp);
|
|
||||||
if (title != null) yield return (entry.Key, entry.Size, title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<(string, long, NcaMetadataWithHash)> HandleRar(string path)
|
|
||||||
{
|
|
||||||
var titles = new List<(string, long, NcaMetadataWithHash)>();
|
|
||||||
var entryCount = 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// todo: handle and skip multipart archives
|
|
||||||
using var archive = RarArchive.Open(path);
|
|
||||||
entryCount = archive.Entries.Count;
|
|
||||||
foreach (var entry in archive.Entries)
|
|
||||||
{
|
|
||||||
if (entry.IsDirectory || entry.Key == null || !IsRomArchive(entry.Key)) continue;
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var streamWrapper = new SeekableBufferedStream(entry.OpenEntryStream(), entry.Size, 64 * 1024 * 1024, true);
|
|
||||||
var title = _nspExtractor.ExtractFromStream(streamWrapper);
|
|
||||||
if (title != null) titles.Add((entry.Key, entry.Size, title));
|
|
||||||
}
|
|
||||||
catch (IncompleteArchiveException incompleteArchiveException)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Incomplete archive {Archive}: {Exception}", path, incompleteArchiveException.Message);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
if (e.Message.StartsWith("Failed to extract NSP"))
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to extract title info from archive {Archive}: {Exception}", path, e.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
if (titles.Count > 0 && titles.Count == entryCount)
|
|
||||||
{
|
|
||||||
// broken archive but managed to read some titles
|
|
||||||
_logger.LogInformation("Failed to fully process archive with SharpCompress, but found {Count} titles: {Exception}", titles.Count, exception.Message);
|
|
||||||
return titles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to SharpSevenZip (if needed)
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Failed to open archive with SharpCompress, falling back to SharpSevenZip {Exception}",
|
|
||||||
exception.Message);
|
|
||||||
using var archive = SevenZipArchive.Open(path);
|
|
||||||
foreach (var entry in archive.Entries)
|
|
||||||
{
|
|
||||||
if (entry is { IsDirectory: false, Key: not null } && IsRomArchive(entry.Key))
|
|
||||||
{
|
|
||||||
var temp = Path.GetTempFileName();
|
|
||||||
entry.WriteToFile(temp);
|
|
||||||
var title = _nspExtractor.ExtractFromFile(temp); // instance call
|
|
||||||
File.Delete(temp);
|
|
||||||
if (title != null) titles.Add((entry.Key, entry.Size, title));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return titles;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsRomArchive(string entryName)
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(entryName).ToLowerInvariant();
|
|
||||||
return ext is ".xci" or ".nsp" or ".xcz";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using LibHac.Common.Keys;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 class ConfigManager
|
|
||||||
{
|
|
||||||
public AppSettings Settings { get; private set; }
|
|
||||||
|
|
||||||
public event Action<AppSettings>? OnChange;
|
|
||||||
|
|
||||||
private readonly string _configPath;
|
|
||||||
private readonly FileSystemWatcher _watcher;
|
|
||||||
private readonly object _sync = new();
|
|
||||||
|
|
||||||
public ConfigManager()
|
|
||||||
{
|
|
||||||
_configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
|
||||||
Load();
|
|
||||||
|
|
||||||
_watcher = new FileSystemWatcher
|
|
||||||
{
|
|
||||||
Path = Path.GetDirectoryName(_configPath)!,
|
|
||||||
Filter = Path.GetFileName(_configPath),
|
|
||||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes
|
|
||||||
};
|
|
||||||
_watcher.Changed += (_, _) => Reload();
|
|
||||||
_watcher.EnableRaisingEvents = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Load()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_configPath))
|
|
||||||
{
|
|
||||||
Settings = new AppSettings(
|
|
||||||
RootDirectories: Array.Empty<string>(),
|
|
||||||
WhitelistExtensions: Array.Empty<string>(),
|
|
||||||
RomExtensions: Array.Empty<string>(),
|
|
||||||
CredentialsFile: "credentials.json",
|
|
||||||
FingerprintsFile: "fingerprints.json",
|
|
||||||
BlacklistFile: "blacklist.json",
|
|
||||||
MaxFailedAttempts: 5,
|
|
||||||
KeySetFile: "keys.bin"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var txt = File.ReadAllText(_configPath);
|
|
||||||
Settings = JsonSerializer.Deserialize<AppSettings>(txt, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
|
||||||
|
|
||||||
// --- Load the KeySet --------------------------------------------
|
|
||||||
if (!string.IsNullOrWhiteSpace(Settings.KeySetFile))
|
|
||||||
{
|
|
||||||
var keyFilePath = Path.Combine(AppContext.BaseDirectory, Settings.KeySetFile);
|
|
||||||
if (File.Exists(keyFilePath))
|
|
||||||
{
|
|
||||||
// LibHac provides a static helper to load a key‑set file.
|
|
||||||
// If the file is not found or corrupt, we simply keep the
|
|
||||||
// default (empty) key set – the app will throw later
|
|
||||||
// when a title requires a missing key.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = ExternalKeyReader.ReadKeyFile(keyFilePath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
KeySetHolder.KeySet = new KeySet(); // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Reload()
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
Load();
|
|
||||||
OnChange?.Invoke(Settings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// File: TinfoilVibeServer/Services/GameDirectoryWatcherService.cs
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SharpCompress.Archives;
|
||||||
|
using TinfoilVibeServer.Config;
|
||||||
|
using TinfoilVibeServer.Persistence;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 1. Loads persisted snapshot on start.
|
||||||
|
/// 2. Scans configured directories for Switch ROMs or archives, extracts metadata, and keeps snapshot up‑to‑date.
|
||||||
|
/// 3. Watches directories for changes and updates snapshot incrementally.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameDirectoryWatcherService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<GameDirectoryWatcherService> _log;
|
||||||
|
private readonly IOptionsMonitor<GameDirectoriesOptions> _options;
|
||||||
|
private readonly ISnapshotRepository _repo;
|
||||||
|
private readonly ILibHacParser _parser;
|
||||||
|
private readonly Dictionary<string, SnapshotEntry> _snapshot = new();
|
||||||
|
private readonly List<FileSystemWatcher> _watchers = new();
|
||||||
|
|
||||||
|
// Valid extensions for Switch ROMs (case‑insensitive).
|
||||||
|
private static readonly string[] _romExtensions = { ".nsp", ".xci" };
|
||||||
|
|
||||||
|
// Archive extensions that we can open with SharpCompress.
|
||||||
|
private static readonly string[] _archiveExtensions = { ".zip", ".rar", ".7z", ".tar", ".gz" };
|
||||||
|
|
||||||
|
public GameDirectoryWatcherService(
|
||||||
|
ILogger<GameDirectoryWatcherService> log,
|
||||||
|
IOptionsMonitor<GameDirectoriesOptions> options,
|
||||||
|
ISnapshotRepository repo,
|
||||||
|
ILibHacParser parser)
|
||||||
|
{
|
||||||
|
_log = log;
|
||||||
|
_options = options;
|
||||||
|
_repo = repo;
|
||||||
|
_parser = parser;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// 1. Load persisted snapshot
|
||||||
|
var persisted = await _repo.LoadAsync();
|
||||||
|
foreach (var kvp in persisted)
|
||||||
|
{
|
||||||
|
_snapshot[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Initial scan
|
||||||
|
await ScanAllDirectoriesAsync(stoppingToken);
|
||||||
|
|
||||||
|
// 3. Setup watchers
|
||||||
|
foreach (var dir in _options.CurrentValue.Paths)
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(dir)
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher.Created += async (_, e) => await HandleCreatedAsync(e.FullPath, stoppingToken);
|
||||||
|
watcher.Changed += async (_, e) => await HandleChangedAsync(e.FullPath, stoppingToken);
|
||||||
|
watcher.Deleted += (_, e) => HandleDeleted(e.FullPath);
|
||||||
|
watcher.Renamed += async (_, e) => await HandleRenamedAsync(e.OldFullPath, e.FullPath, stoppingToken);
|
||||||
|
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
_watchers.Add(watcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the service alive until cancellation
|
||||||
|
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanAllDirectoriesAsync(CancellationToken token)
|
||||||
|
{
|
||||||
|
foreach (var dir in _options.CurrentValue.Paths)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
_log.LogWarning("Configured directory does not exist: {Dir}", dir);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories).ToAsyncEnumerable().WithCancellation(token))
|
||||||
|
{
|
||||||
|
await ProcessFileAsync(file, token);
|
||||||
|
_log.LogInformation("Processed file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistSnapshotAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessFileAsync(string fullPath, CancellationToken token)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||||
|
|
||||||
|
if (_romExtensions.Contains(ext))
|
||||||
|
{
|
||||||
|
await ProcessRomAsync(fullPath, string.Empty, token);
|
||||||
|
}
|
||||||
|
else if (_archiveExtensions.Contains(ext))
|
||||||
|
{
|
||||||
|
await ProcessArchiveAsync(fullPath, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessRomAsync(string fullPath, string insidePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(fullPath);
|
||||||
|
var entry = await _parser.ExtractAsync(stream, fullPath, insidePath);
|
||||||
|
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
_log.LogDebug("No valid NCA found in {Path}", fullPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpsertSnapshotAsync(entry, token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Failed to process ROM {Path}", fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessArchiveAsync(string archivePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = ArchiveFactory.Open(archivePath);
|
||||||
|
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(entry.Key).ToLowerInvariant();
|
||||||
|
if (_romExtensions.Contains(ext))
|
||||||
|
{
|
||||||
|
await using var entryStream = entry.OpenEntryStream();
|
||||||
|
await ProcessRomAsync(archivePath, entry.Key, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "SharpCompress failed on {Archive}. Trying SharpSevenZip fallback.", archivePath);
|
||||||
|
await ProcessArchiveWithSharpSevenZipAsync(archivePath, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessArchiveWithSharpSevenZipAsync(string archivePath, CancellationToken token)
|
||||||
|
{
|
||||||
|
// Placeholder – actual implementation requires SharpSevenZip integration.
|
||||||
|
// The idea is to open the archive, enumerate entries, and for each ROM
|
||||||
|
// call ProcessRomAsync with the archive path and internal entry path.
|
||||||
|
_log.LogDebug("SharpSevenZip fallback not yet implemented for {Archive}", archivePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertSnapshotAsync(SnapshotEntry entry, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (_snapshot.TryGetValue(entry.TitleId, out var existing))
|
||||||
|
{
|
||||||
|
// File exists – compare paths and hashes
|
||||||
|
if (existing.FullPath == entry.FullPath &&
|
||||||
|
existing.Hash.SequenceEqual(entry.Hash))
|
||||||
|
{
|
||||||
|
// No change
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.LogInformation("Updating snapshot for TitleId {TitleId} – path change or hash update.", entry.TitleId);
|
||||||
|
_snapshot[entry.TitleId] = entry;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_log.LogInformation("Adding new entry to snapshot: {TitleId}", entry.TitleId);
|
||||||
|
_snapshot[entry.TitleId] = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistSnapshotAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleDeleted(string fullPath)
|
||||||
|
{
|
||||||
|
var titleId = _snapshot.Values.FirstOrDefault(e => e.FullPath == fullPath)?.TitleId;
|
||||||
|
if (titleId != null)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Removing deleted file from snapshot: {TitleId} ({Path})", titleId, fullPath);
|
||||||
|
_snapshot.Remove(titleId);
|
||||||
|
_repo.PersistAsync(_snapshot).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleCreatedAsync(string fullPath, CancellationToken token)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Detected new file: {Path}", fullPath);
|
||||||
|
await ProcessFileAsync(fullPath, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleChangedAsync(string fullPath, CancellationToken token)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Detected changed file: {Path}", fullPath);
|
||||||
|
await ProcessFileAsync(fullPath, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRenamedAsync(string oldFullPath, string newFullPath, CancellationToken token)
|
||||||
|
{
|
||||||
|
_log.LogInformation("Detected renamed file: {Old} → {New}", oldFullPath, newFullPath);
|
||||||
|
// Treat as delete + create
|
||||||
|
HandleDeleted(oldFullPath);
|
||||||
|
await ProcessFileAsync(newFullPath, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistSnapshotAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _repo.PersistAsync(_snapshot);
|
||||||
|
_log.LogDebug("Snapshot persisted with {Count} entries.", _snapshot.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Failed to persist snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// File: TinfoilVibeServer/Services/ILibHacParser.cs
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
|
public interface ILibHacParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a ROM stream (NSP/XCI/…) and extracts metadata.
|
||||||
|
/// </summary>
|
||||||
|
Task<SnapshotEntry?> ExtractAsync(Stream romStream, string fullPath, string insidePath);
|
||||||
|
}
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using LibHac.Ncm;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
// File: Services/IndexBuilderService.cs
|
|
||||||
// *** NEW ***
|
|
||||||
public sealed class IndexBuilderService: IHostedService
|
|
||||||
{
|
|
||||||
private const string CacheFileName = "indexcache.json";
|
|
||||||
|
|
||||||
private readonly IOptions<IndexBuilderSettings> _options;
|
|
||||||
private readonly ISnapshotService _snapshotService;
|
|
||||||
private readonly TitleDatabaseService _titleDb;
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILogger<IndexBuilderService> _logger;
|
|
||||||
private readonly string _cachePath;
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
|
||||||
public IndexBuilderService(
|
|
||||||
IOptions<IndexBuilderSettings> options,
|
|
||||||
ISnapshotService snapshotService,
|
|
||||||
TitleDatabaseService titleDb,
|
|
||||||
IConfiguration configuration,
|
|
||||||
ILogger<IndexBuilderService> logger)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
_snapshotService = snapshotService;
|
|
||||||
_titleDb = titleDb;
|
|
||||||
_configuration = configuration;
|
|
||||||
_logger = logger;
|
|
||||||
_cachePath = Path.Combine(AppContext.BaseDirectory, CacheFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IndexDto Build(HttpContext httpContext)
|
|
||||||
{
|
|
||||||
// 1️⃣ Load cache if it exists
|
|
||||||
var cached = LoadCache();
|
|
||||||
var snapshot = _snapshotService.GetSnapshot();
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(snapshot.Hash) || snapshot.Files.Count == 0)
|
|
||||||
{
|
|
||||||
_snapshotService.BuildSnapshotAsync();
|
|
||||||
snapshot = _snapshotService.GetSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2️⃣ Re‑build only if the snapshot hash changed
|
|
||||||
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.Files.Count);
|
|
||||||
// 3️⃣ Build new index from snapshot entries
|
|
||||||
var files = ParseSnapshotFiles(snapshot, new Uri(httpContext.Request.Scheme + "://" + httpContext.Request.Host + httpContext.Request.PathBase));
|
|
||||||
|
|
||||||
var directories = _configuration.GetSection("Directories")
|
|
||||||
.Get<string[]>() ?? Array.Empty<string>();
|
|
||||||
|
|
||||||
var success = _configuration["SuccessMessage"] ?? string.Empty;
|
|
||||||
|
|
||||||
var index = new IndexDto(files.SelectMany(inner => inner).ToList(), directories.ToList(), success);
|
|
||||||
|
|
||||||
// 4️⃣ Persist cache
|
|
||||||
PersistCache(snapshotHash, index);
|
|
||||||
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<List<FileDto>> ParseSnapshotFiles(SnapshotService.ROMSnapshot snapshot, Uri baseUri)
|
|
||||||
{
|
|
||||||
var files = snapshot.Files
|
|
||||||
.Where(e => e.Titles.Count > 0)
|
|
||||||
.Select(e =>
|
|
||||||
{
|
|
||||||
var fileDtos = new List<FileDto>();
|
|
||||||
foreach (var title in e.Titles)
|
|
||||||
{
|
|
||||||
var titleId = title.TitleId;
|
|
||||||
var name = _titleDb.TryGetTitle(titleId, out var t)
|
|
||||||
? t?.Name
|
|
||||||
: "Unknown";
|
|
||||||
|
|
||||||
var versionNumberParsed = (title.ContentMetaType == ContentMetaType.Application) ? title.Version : title.Version * 0x10000;
|
|
||||||
var patchOrApp = title.ContentMetaType == ContentMetaType.Application ? "Base" : "Update";
|
|
||||||
if (title.ContentMetaType == ContentMetaType.Patch ||
|
|
||||||
title.ContentMetaType == ContentMetaType.AddOnContent)
|
|
||||||
{
|
|
||||||
// Patch should use the application title name
|
|
||||||
name = _titleDb.TryGetTitle(title.ApplicationTitle, out var appTitle)
|
|
||||||
? appTitle?.Name
|
|
||||||
: "Unknown";
|
|
||||||
//_logger.LogInformation("Patch title {TitleId} uses application title {ApplicationTitle}", titleId, title.ApplicationTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if still unknown, its probably a demo, use filename extraction
|
|
||||||
if (name == "Unknown")
|
|
||||||
{
|
|
||||||
var match = Regex.Match(Path.GetFileNameWithoutExtension(e.Path), "^(.+?)\\s*(?:\\[.*)?$",
|
|
||||||
RegexOptions.None);
|
|
||||||
if (match.Success)
|
|
||||||
{
|
|
||||||
name = match.Groups[1].Value;
|
|
||||||
_logger.LogInformation("Name not found for {TitleId}, using filename: {Name}", titleId,
|
|
||||||
name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileName =Uri.EscapeDataString($"{name}[{titleId}][v{versionNumberParsed}][{patchOrApp}].nsp");
|
|
||||||
var url = $"{baseUri.ToString().TrimEnd('/')}/{fileName}";
|
|
||||||
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
|
||||||
{
|
|
||||||
fileDtos.Add(new FileDto(url, e.Size));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Invalid URL for {TitleId}: {Url}", titleId, url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fileDtos;
|
|
||||||
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IndexCache? LoadCache()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_cachePath)) return null;
|
|
||||||
_lock.Wait();
|
|
||||||
var json = File.ReadAllText(_cachePath);
|
|
||||||
_lock.Release();
|
|
||||||
_logger.LogInformation("Loaded index cache from {Path}", _cachePath);
|
|
||||||
return JsonSerializer.Deserialize<IndexCache>(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistCache(string snapshotHash, IndexDto index)
|
|
||||||
{
|
|
||||||
var cache = new IndexCache(snapshotHash, index);
|
|
||||||
File.WriteAllText(_cachePath, JsonSerializer.Serialize(cache, new JsonSerializerOptions{WriteIndented=true}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(entries);
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
return BitConverter.ToString(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json))).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTO for the cache file
|
|
||||||
private sealed record IndexCache(string SnapshotHash, IndexDto Index);
|
|
||||||
|
|
||||||
#region IHostedService
|
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var url = new Uri(_options.Value.ApiBaseUrl);
|
|
||||||
var host = url.Host;
|
|
||||||
Build(new DefaultHttpContext {HttpContext = { Request = { Host = new HostString(host), Scheme = url.Scheme, Path = new PathString(url.AbsolutePath)}}});
|
|
||||||
_snapshotService.SnapshotRebuilt += InvalidateIndex;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void InvalidateIndex(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!File.Exists(_cachePath)) return;
|
|
||||||
|
|
||||||
File.Delete(_cachePath);
|
|
||||||
_logger.LogInformation("Index cache cleared");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_snapshotService.SnapshotRebuilt -= InvalidateIndex;
|
|
||||||
return Task.CompletedTask; // nothing special to do on shutdown
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using LibHac.Common.Keys;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
public class KeySetHolder
|
||||||
|
{
|
||||||
|
public static KeySet KeySet { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// File: TinfoilVibeServer/Services/LibHacParser.cs
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LibHac.Fs;
|
||||||
|
using LibHac.Fs.Fsa;
|
||||||
|
using LibHac.FsSystem;
|
||||||
|
using LibHac.Ncm;
|
||||||
|
using LibHac.Tools.FsSystem;
|
||||||
|
using LibHac.Tools.FsSystem.NcaUtils;
|
||||||
|
using TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
namespace TinfoilVibeServer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the LibHac extraction logic copied from the provided NSPExtractor.cs [4].
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LibHacParser : ILibHacParser
|
||||||
|
{
|
||||||
|
private readonly NSPExtactor _nspExtractor;
|
||||||
|
|
||||||
|
public LibHacParser(NSPExtactor nspExtractor)
|
||||||
|
{
|
||||||
|
_nspExtractor = nspExtractor;
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the stream of a single Switch ROM (NSP or XCI) and returns metadata.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SnapshotEntry?> ExtractAsync(Stream romStream, string fullPath, string insidePath)
|
||||||
|
{
|
||||||
|
var extractFromStream = _nspExtractor.ExtractFromStream(romStream);
|
||||||
|
// Build snapshot entry
|
||||||
|
if (extractFromStream?.TitleId == null) return null;
|
||||||
|
|
||||||
|
var entry = new SnapshotEntry
|
||||||
|
{
|
||||||
|
TitleId = extractFromStream.TitleId,
|
||||||
|
Version = extractFromStream.Version,
|
||||||
|
IsApp = extractFromStream.ContentMetaType == ContentMetaType.Application,
|
||||||
|
IsPatch = extractFromStream.ContentMetaType == ContentMetaType.Patch,
|
||||||
|
IsDlc = extractFromStream.ContentMetaType == ContentMetaType.AddOnContent,
|
||||||
|
FullPath = fullPath,
|
||||||
|
InsidePath = insidePath,
|
||||||
|
Hash = await ComputeHashAsync(romStream),
|
||||||
|
LastModified = File.GetLastWriteTimeUtc(fullPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ComputeHashAsync(Stream stream)
|
||||||
|
{
|
||||||
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||||
|
return await sha256.ComputeHashAsync(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +1,73 @@
|
|||||||
using System.Security.Cryptography;
|
using LibHac.Common;
|
||||||
using LibHac.Common;
|
using LibHac.Common.Keys;
|
||||||
using LibHac.Fs;
|
using LibHac.Fs;
|
||||||
using LibHac.Fs.Fsa;
|
using LibHac.Fs.Fsa;
|
||||||
using LibHac.FsSystem;
|
using LibHac.FsSystem;
|
||||||
using LibHac.Tools.FsSystem;
|
using LibHac.Tools.FsSystem;
|
||||||
using LibHac.Tools.FsSystem.NcaUtils;
|
using LibHac.Tools.FsSystem.NcaUtils;
|
||||||
using LibHac.Common.Keys;
|
|
||||||
using LibHac.Ncm;
|
using LibHac.Ncm;
|
||||||
using LibHac.Tools.Fs;
|
|
||||||
using LibHac.Tools.Ncm;
|
using LibHac.Tools.Ncm;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
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>
|
/// <summary>
|
||||||
/// Extracts the TitleId, version, type *and* the SHA‑256 of the first NCA stream.
|
/// Extracts the TitleId, version and patch/application flag from an NSP/XCI file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NSPExtractor : INSPExtractor
|
public sealed class NSPExtactor
|
||||||
{
|
{
|
||||||
private readonly KeySet _keySet;
|
private readonly KeySetProvider _keySetProvider;
|
||||||
private readonly ILogger<INSPExtractor> _logger;
|
|
||||||
|
|
||||||
public NSPExtractor(KeySet keySet, ILogger<INSPExtractor> logger)
|
public NSPExtactor(KeySetProvider keySetProvider)
|
||||||
{
|
{
|
||||||
_keySet = keySet;
|
_keySetProvider = keySetProvider;
|
||||||
_logger = logger;
|
|
||||||
}
|
}
|
||||||
|
public NcaMetadataDto? ExtractFromFile(string filePath)
|
||||||
/// <summary>
|
|
||||||
/// Public convenience wrapper that opens the file on disk.
|
|
||||||
/// </summary>
|
|
||||||
public NcaMetadataWithHash? ExtractFromFile(string filePath)
|
|
||||||
{
|
{
|
||||||
using var stream = File.OpenRead(filePath);
|
using var stream = File.OpenRead(filePath);
|
||||||
return ExtractFromStream(stream);
|
return ExtractFromStream(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public NcaMetadataDto? ExtractFromStream(Stream stream)
|
||||||
/// Core implementation – works on any seekable stream that contains a full NSP/XCI container.
|
|
||||||
/// </summary>
|
|
||||||
public NcaMetadataWithHash? ExtractFromStream(Stream stream)
|
|
||||||
{
|
{
|
||||||
if (!stream.CanSeek) return null;
|
if (!IsPfsFileSystem(stream))
|
||||||
|
return null;
|
||||||
|
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
_logger.LogInformation("Extracting NSP/XCI from stream (length={Length})", stream.Length);
|
// 0‑19.0.0 stream wrapper
|
||||||
using var storage = new StreamStorage(stream, false);
|
using var storage = new StreamStorage(stream, false);
|
||||||
if (IsPfs0FileSystem(stream))
|
|
||||||
{
|
|
||||||
return ExtractNSPFromStream(storage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsXciFileSystem(stream))
|
|
||||||
{
|
|
||||||
var xci = new Xci(_keySet, storage);
|
|
||||||
List<DirectoryEntryEx> ncaEntries;
|
|
||||||
if (xci.HasPartition(XciPartitionType.Secure))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Processing as XCI");
|
|
||||||
var partition = xci.OpenPartition(XciPartitionType.Secure);
|
|
||||||
ncaEntries = partition
|
|
||||||
.EnumerateEntries("*.cnmt.nca", SearchOptions.RecurseSubdirectories)
|
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
|
||||||
.ToList();
|
|
||||||
byte[]? hash = null;
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
|
||||||
{
|
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
|
||||||
partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
|
||||||
using var ncaFile = fileRef.Release();
|
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
|
||||||
|
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
|
||||||
if (hash == null)
|
|
||||||
{
|
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
using var ncaStream = ncaFile.AsStream();
|
|
||||||
hash = sha256.ComputeHash(ncaStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
|
||||||
continue; // only the meta NCA contains title metadata
|
|
||||||
|
|
||||||
string titleId = nca.Header.TitleId.ToString("X16");
|
|
||||||
var (contentMetaType, applicationTitleId, titleVersion) = GetMetaData(nca);
|
|
||||||
|
|
||||||
_logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", titleId, titleVersion);
|
|
||||||
// XCI can never be a patch?
|
|
||||||
return new NcaMetadataWithHash(titleId, applicationTitleId.ToString("X16"), titleVersion.Major, ContentMetaType.Application, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // no meta NCA found
|
|
||||||
}
|
|
||||||
|
|
||||||
private NcaMetadataWithHash? ExtractNSPFromStream(StreamStorage storage)
|
|
||||||
{
|
|
||||||
List<DirectoryEntryEx> ncaEntries;
|
|
||||||
_logger.LogInformation("Processing as NSP");
|
|
||||||
var partition = new PartitionFileSystem();
|
var partition = new PartitionFileSystem();
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
partition.Initialize(storage).ThrowIfFailure();
|
||||||
// Find the first *.nca that contains the meta header
|
|
||||||
ncaEntries = partition
|
// Find the first Meta‑NCA inside the NSP/XCI
|
||||||
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
|
foreach (var entry in partition.EnumerateEntries("*.nca",
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
SearchOptions.RecurseSubdirectories)
|
||||||
.ToList();
|
.Where(e => e.Type == DirectoryEntryType.File))
|
||||||
byte[]? hash = null;
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
|
||||||
{
|
{
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
using var fileRef = new UniqueRef<IFile>();
|
||||||
partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
partition.OpenFile(ref fileRef.Ref, entry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
||||||
using var ncaFile = fileRef.Release();
|
using var file = fileRef.Release();
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
using var fileStorage = new FileStorage(file);
|
||||||
|
var nca = new Nca(_keySetProvider.Get(), fileStorage);
|
||||||
|
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
// Meta‑NCA contains the TitleId & version
|
||||||
if (hash == null)
|
if (nca.Header.ContentType == NcaContentType.Meta)
|
||||||
{
|
{
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
var titleId = nca.Header.TitleId.ToString("X16");
|
||||||
using var sha256 = SHA256.Create();
|
int version = nca.Header.Version;
|
||||||
using var ncaStream = ncaFile.AsStream();
|
var contentMetaType = GetMetaDataType(nca);
|
||||||
hash = sha256.ComputeHash(ncaStream);
|
if (contentMetaType != null) return new NcaMetadataDto(titleId, version, contentMetaType.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
|
||||||
continue; // only the meta NCA contains title metadata
|
|
||||||
|
|
||||||
_logger.LogInformation("Meta NCA found – TitleId={TitleId} Version={Version}", nca.Header.TitleId, nca.Header.Version);
|
|
||||||
string titleId = nca.Header.TitleId.ToString("X16");
|
|
||||||
|
|
||||||
var (contentMetaType,applicationTitle,titleVersion) = GetMetaData(nca);
|
|
||||||
if (contentMetaType != null)
|
|
||||||
return new NcaMetadataWithHash(titleId, applicationTitle.ToString("X16"), titleVersion.Minor, contentMetaType.Value, BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (ContentMetaType?,ulong, TitleVersion) GetMetaData(Nca nca)
|
private static ContentMetaType? GetMetaDataType(Nca nca)
|
||||||
{
|
{
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta) return (null,0, new TitleVersion(0, true));
|
if (nca.Header.ContentType != NcaContentType.Meta) return null;
|
||||||
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
|
using var openFileSystem = nca.OpenFileSystem(0, IntegrityCheckLevel.ErrorOnInvalid);
|
||||||
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
|
foreach (var entry in openFileSystem.EnumerateEntries("*.cnmt", SearchOptions.Default))
|
||||||
{
|
{
|
||||||
@@ -166,131 +79,65 @@ namespace TinfoilVibeServer.Services
|
|||||||
using var asStream = nacpFile.AsStream();
|
using var asStream = nacpFile.AsStream();
|
||||||
|
|
||||||
var cnmt = new Cnmt(asStream);
|
var cnmt = new Cnmt(asStream);
|
||||||
var applicationTitle = cnmt.ApplicationTitleId;
|
return cnmt.Type;
|
||||||
return (cnmt.Type,applicationTitle, cnmt.TitleVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (null,0, new TitleVersion(0, true));
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static bool IsPfsFileSystem(Stream stream)
|
||||||
/// Quick sanity check that the stream looks like a PFS0 file system.
|
|
||||||
/// </summary>
|
|
||||||
private bool IsPfs0FileSystem(Stream stream)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!stream.CanSeek) return false;
|
if (!stream.CanSeek) return false;
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
var storage = new StreamStorage(stream, true);
|
var storage = new StreamStorage(stream, false);
|
||||||
var partition = new PartitionFileSystem();
|
var partition = new PartitionFileSystem();
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
partition.Initialize(storage).ThrowIfFailure();
|
||||||
_logger.LogInformation("PFS0 found");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
private bool IsXciFileSystem(Stream stream)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!stream.CanSeek) return false;
|
|
||||||
stream.Seek(0, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
var storage = new StreamStorage(stream, true);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var xciBlock = new Xci(_keySet, storage);
|
|
||||||
_logger.LogInformation("XCI found");
|
|
||||||
return xciBlock.HasPartition(XciPartitionType.Secure);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to extract XCI: {Exception}", e.Message);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string ExtractHashFromStream(Stream nspStream)
|
public class KeySetProvider
|
||||||
|
{
|
||||||
|
private readonly KeySet _keySet;
|
||||||
|
|
||||||
|
public KeySetProvider(IOptions<NSPExtractorSettings> nspExtractorSettings)
|
||||||
{
|
{
|
||||||
if (!IsPfs0FileSystem(nspStream))
|
_keySet = ExternalKeyReader.ReadKeyFile(nspExtractorSettings.Value.KeyFilePath);
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
nspStream.Seek(0, SeekOrigin.Begin);
|
|
||||||
|
|
||||||
using var storage = new StreamStorage(nspStream, true);
|
|
||||||
var partition = new PartitionFileSystem();
|
|
||||||
partition.Initialize(storage).ThrowIfFailure();
|
|
||||||
|
|
||||||
// Find the first *.nca that contains the meta header
|
|
||||||
var ncaEntries = partition
|
|
||||||
.EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories)
|
|
||||||
.Where(e => e.Type == DirectoryEntryType.File)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var dirEntry in ncaEntries)
|
|
||||||
{
|
|
||||||
using var fileRef = new UniqueRef<IFile>();
|
|
||||||
partition.OpenFile(ref fileRef.Ref, dirEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
|
|
||||||
using var ncaFile = fileRef.Release();
|
|
||||||
using var ncaFileStorage = new FileStorage(ncaFile);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var nca = new Nca(_keySet, ncaFileStorage);
|
|
||||||
if (nca.Header.ContentType != NcaContentType.Meta)
|
|
||||||
continue; // only the meta NCA contains title metadata
|
|
||||||
|
|
||||||
// Hash the *first* NCA stream – the stream we just opened
|
|
||||||
using var ncaStream = ncaFile.AsStream();
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
var hash = sha256.ComputeHash(ncaStream);
|
|
||||||
var extractHashFromStream = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
_logger.LogInformation("Computed first‑stream hash {Hash} for {TitleId}", extractHashFromStream,
|
|
||||||
nca.Header.TitleId);
|
|
||||||
return extractHashFromStream;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to extract NSP: {Exception}", e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public KeySet Get() => _keySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NSPExtractorSettings
|
||||||
|
{
|
||||||
|
public string KeyFilePath { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO returned by the extractor – contains all data the snapshot needs.
|
/// Simple DTO that matches the information extracted by NSPExtactor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NcaMetadataWithHash
|
public sealed class NcaMetadataDto
|
||||||
{
|
{
|
||||||
public string TitleId { get; }
|
public string TitleId { get; }
|
||||||
public string ApplicationTitle { get; set; }
|
public int Version { get; }
|
||||||
public int Version { get; }
|
public ContentMetaType ContentMetaType { get; }
|
||||||
|
|
||||||
public ContentMetaType ContentMetaType { get; set; }
|
public NcaMetadataDto(string titleId, int version, ContentMetaType contentMetaType)
|
||||||
public string Hash { get; }
|
|
||||||
|
|
||||||
public NcaMetadataWithHash(string titleId, string applicationTitle, int version,
|
|
||||||
ContentMetaType contentMetaType, string hash)
|
|
||||||
{
|
{
|
||||||
TitleId = titleId;
|
TitleId = titleId;
|
||||||
ApplicationTitle = applicationTitle;
|
|
||||||
Version = version;
|
Version = version;
|
||||||
ContentMetaType = contentMetaType;
|
ContentMetaType = contentMetaType;
|
||||||
Hash = hash;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace TinfoilVibeServer.Models;
|
||||||
|
|
||||||
|
public class NcaMetadataDto(string titleId, int version, bool isApp)
|
||||||
|
{
|
||||||
|
public string TitleId { get; set; } = titleId;
|
||||||
|
public int Version { get; set; } = version;
|
||||||
|
public bool IsApp { get; set; } = isApp;
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// File: Services/RomArchiveReader.cs
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.IO.Compression;
|
|
||||||
using SharpCompress.Archives;
|
|
||||||
using SharpCompress.Common;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a ROM archive (zip / 7z / rar) from a stream.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RomArchiveReader : IDisposable
|
|
||||||
{
|
|
||||||
private readonly ZipArchive? _zipArchive;
|
|
||||||
private readonly IArchive? _sharpArchive;
|
|
||||||
private readonly Stream? _archiveStream; // the stream actually handed to SharpCompress
|
|
||||||
|
|
||||||
public RomArchiveReader(string path) : this(File.OpenRead(path), path) { }
|
|
||||||
/// <summary>
|
|
||||||
/// Opens an archive from a stream.
|
|
||||||
/// The *fileName* parameter is only used to decide which archive format
|
|
||||||
/// to open; it can be <c>null</c> if the caller already knows the format.
|
|
||||||
/// </summary>
|
|
||||||
public RomArchiveReader(Stream stream, string? fileName = null)
|
|
||||||
{
|
|
||||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
|
||||||
|
|
||||||
var ext = fileName?.ToLowerInvariant() ?? string.Empty;
|
|
||||||
|
|
||||||
switch (ext)
|
|
||||||
{
|
|
||||||
case ".zip":
|
|
||||||
// System.IO.Compression can use the stream directly
|
|
||||||
_zipArchive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ".7z":
|
|
||||||
case ".rar":
|
|
||||||
// SharpCompress requires a seekable stream; copy if necessary
|
|
||||||
if (!stream.CanSeek)
|
|
||||||
{
|
|
||||||
var ms = new MemoryStream();
|
|
||||||
stream.CopyTo(ms);
|
|
||||||
ms.Position = 0;
|
|
||||||
_archiveStream = ms;
|
|
||||||
stream.Dispose(); // original non‑seekable stream no longer needed
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_archiveStream = stream;
|
|
||||||
}
|
|
||||||
_sharpArchive = ArchiveFactory.Open(_archiveStream);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new NotSupportedException($"Archive type '{ext}' is not supported.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enumerates every file entry inside the archive.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<RomArchiveEntry> GetEntries()
|
|
||||||
{
|
|
||||||
if (_zipArchive != null)
|
|
||||||
{
|
|
||||||
foreach (var entry in _zipArchive.Entries)
|
|
||||||
{
|
|
||||||
if (entry.FullName.EndsWith("/", StringComparison.Ordinal))
|
|
||||||
continue; // skip directories
|
|
||||||
|
|
||||||
// ZipArchiveEntry.Open returns a seekable stream that must be disposed by the caller
|
|
||||||
yield return new RomArchiveEntry(entry.FullName, entry.Open());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (_sharpArchive != null)
|
|
||||||
{
|
|
||||||
foreach (var entry in _sharpArchive.Entries)
|
|
||||||
{
|
|
||||||
if (entry.IsDirectory)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// SharpCompress gives us a stream that must be disposed by the caller
|
|
||||||
yield return new RomArchiveEntry(entry.Key, entry.OpenEntryStream());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Back‑compat wrapper used by SnapshotService.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<RomArchiveEntry> GetContentInfos() => GetEntries();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disposes the underlying archive objects and the stream(s).
|
|
||||||
/// </summary>
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_zipArchive?.Dispose();
|
|
||||||
_sharpArchive?.Dispose();
|
|
||||||
_archiveStream?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Lightweight container that holds the entry name and the opened stream.
|
|
||||||
/// The caller must dispose <c>Stream</c> after it is done.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RomArchiveEntry(string Name, Stream Stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
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();
|
|
||||||
SnapshotService.ROMSnapshot GetSnapshot();
|
|
||||||
|
|
||||||
Task AddToSnapshotAsync(FileEntry entry);
|
|
||||||
Task BuildSnapshotAsync();
|
|
||||||
void GetArchiveName(string titleId);
|
|
||||||
char GetArchivePathSeparator();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Keeps an in‑memory snapshot, watches the filesystem for changes, and
|
|
||||||
/// only re‑processes a file if its hash changed.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SnapshotService : IDisposable, ISnapshotService, IHostedService
|
|
||||||
{
|
|
||||||
#region FileSystemWatcher
|
|
||||||
private readonly List<FileSystemWatcher> _watchers = new();
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
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 ConcurrentDictionary<string, SnapshotEntry> _cache = new();
|
|
||||||
private readonly ConcurrentDictionary<string, string> _hashCache = new();
|
|
||||||
// Archive full path -> FileEntry.Path
|
|
||||||
private readonly ConcurrentDictionary<string, string> _archiveLookup = new();
|
|
||||||
// hash -> file size
|
|
||||||
private readonly ConcurrentDictionary<string, long> _sizeLookup = new();
|
|
||||||
private readonly IMemoryCache _debouncerCache;
|
|
||||||
public event EventHandler? SnapshotRebuilt;
|
|
||||||
public event EventHandler? SnapshotRebuilding;
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim _snapshotFileSemaphore = new(1,1);
|
|
||||||
private const char ArchivePathSeparator = '|';
|
|
||||||
public char GetArchivePathSeparator() => ArchivePathSeparator;
|
|
||||||
|
|
||||||
public SnapshotService(
|
|
||||||
IMemoryCache debouncerCache,
|
|
||||||
IOptionsMonitor<SnapshotOptions> options,
|
|
||||||
INSPExtractor nspExtractor,
|
|
||||||
IArchiveHandler archiveHandler,
|
|
||||||
ILogger<SnapshotService> logger)
|
|
||||||
{
|
|
||||||
_options = options.CurrentValue;
|
|
||||||
_debouncerCache = debouncerCache;
|
|
||||||
_nspExtractor = nspExtractor;
|
|
||||||
_archiveHandler = archiveHandler;
|
|
||||||
_logger = logger;
|
|
||||||
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
|
|
||||||
|
|
||||||
// Debounce timer for persisting snapshot
|
|
||||||
long debounceTime = 200;
|
|
||||||
var entryOptions = new MemoryCacheEntryOptions()
|
|
||||||
.SetSlidingExpiration(TimeSpan.FromSeconds(debounceTime)).RegisterPostEvictionCallback((key, value, reason,
|
|
||||||
state) =>
|
|
||||||
{
|
|
||||||
|
|
||||||
_logger.LogInformation("Should persist the snapshot {Key}, {Reason}", key, reason);
|
|
||||||
}); // <‑‑ sliding!
|
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_jsonPath)));
|
|
||||||
if (!File.Exists(_jsonPath))
|
|
||||||
{
|
|
||||||
_snapshotFileSemaphore.Wait();
|
|
||||||
File.WriteAllText(_jsonPath, "[]");
|
|
||||||
_snapshotFileSemaphore.Release();
|
|
||||||
}
|
|
||||||
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
|
|
||||||
FileSystemExtensions.EnsureDirectoryExists(Path.GetFullPath(Path.GetDirectoryName(_snapshotPath)));
|
|
||||||
// 1️⃣ Register for *property* changes
|
|
||||||
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
|
|
||||||
|
|
||||||
foreach (var path in _options.RootDirectories)
|
|
||||||
{
|
|
||||||
AddWatchDirectory(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --------- Private helpers ---------
|
|
||||||
private void OnOptionsChanged(string propertyName)
|
|
||||||
{
|
|
||||||
if (propertyName == nameof(SnapshotOptions.RootDirectories))
|
|
||||||
{
|
|
||||||
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
|
|
||||||
foreach (var watcher in fileSystemWatchers)
|
|
||||||
{
|
|
||||||
watcher.EnableRaisingEvents = false;
|
|
||||||
watcher.Dispose();
|
|
||||||
_watchers.Remove(watcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
|
|
||||||
!_watchers.Any(watcher =>
|
|
||||||
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
|
|
||||||
|
|
||||||
foreach (var newWatchedDirectory in newWatchedDirectories)
|
|
||||||
{
|
|
||||||
AddWatchDirectory(newWatchedDirectory);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
BuildSnapshotAsync(); // rebuild everything
|
|
||||||
PersistSnapshotAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#region FileSystemWatcher
|
|
||||||
private void AddWatchDirectory(string path)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(path)) return;
|
|
||||||
var watcher = new FileSystemWatcher
|
|
||||||
{
|
|
||||||
Path = path,
|
|
||||||
IncludeSubdirectories = true,
|
|
||||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName |
|
|
||||||
NotifyFilters.Size | NotifyFilters.LastWrite
|
|
||||||
};
|
|
||||||
watcher.Created += OnChanged;
|
|
||||||
watcher.Changed += OnChanged;
|
|
||||||
watcher.Deleted += OnChanged;
|
|
||||||
watcher.Renamed += OnRenamed;
|
|
||||||
watcher.EnableRaisingEvents = true;
|
|
||||||
_logger.LogInformation("Watching {Path}", path);
|
|
||||||
_watchers.Add(watcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveWatchDirectory(string path)
|
|
||||||
{
|
|
||||||
var fileSystemWatchers = _watchers.FirstOrDefault(watcher => watcher.Path == path);
|
|
||||||
if (fileSystemWatchers == null) return;
|
|
||||||
fileSystemWatchers.EnableRaisingEvents = false;
|
|
||||||
fileSystemWatchers.Dispose();
|
|
||||||
_logger.LogInformation("Stopped watching {Path}", path);
|
|
||||||
_watchers.Remove(fileSystemWatchers);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnChanged(object? _, FileSystemEventArgs e) => ThrottleSnapshotUpdate(e);
|
|
||||||
private void OnRenamed(object? _, RenamedEventArgs e) => ThrottleSnapshotUpdate(e);
|
|
||||||
|
|
||||||
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
|
|
||||||
{
|
|
||||||
SnapshotRebuilding?.Invoke(this, fileSystemEventArgs);
|
|
||||||
using var cacheEntry = _debouncerCache.CreateEntry(fileSystemEventArgs.FullPath)
|
|
||||||
//.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
|
||||||
.SetValue(fileSystemEventArgs)
|
|
||||||
.SetOptions(new MemoryCacheEntryOptions
|
|
||||||
{
|
|
||||||
PostEvictionCallbacks =
|
|
||||||
{
|
|
||||||
new PostEvictionCallbackRegistration
|
|
||||||
{
|
|
||||||
EvictionCallback =
|
|
||||||
(key, value, reason, state) =>
|
|
||||||
{
|
|
||||||
if (reason != EvictionReason.Expired) return;
|
|
||||||
|
|
||||||
if (value is FileSystemEventArgs args)
|
|
||||||
{
|
|
||||||
if (IsFileLocked(args.FullPath))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot update", args.FullPath);
|
|
||||||
using var rebounce = _debouncerCache.CreateEntry(args.FullPath)
|
|
||||||
.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(DebounceMs))
|
|
||||||
.SetValue(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RebuildSnapshot();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(DebounceMs);
|
|
||||||
|
|
||||||
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType,
|
|
||||||
fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
|
|
||||||
}
|
|
||||||
private static bool IsFileLocked(string filePath)
|
|
||||||
{
|
|
||||||
FileStream? stream = null;
|
|
||||||
var file = new FileInfo(filePath);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
stream = file.Open(FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stream?.Close();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private const int DebounceMs = 400;
|
|
||||||
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { IncludeFields = true };
|
|
||||||
private int SnapshotFileLockTimeout { get; } = 1000;
|
|
||||||
|
|
||||||
private void DebounceElapsed()
|
|
||||||
{
|
|
||||||
UpdateSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Snapshot logic
|
|
||||||
|
|
||||||
public Task AddToSnapshotAsync(FileEntry entry)
|
|
||||||
{
|
|
||||||
// Update lookup tables
|
|
||||||
_cache[entry.Path] = new SnapshotEntry(entry.Path, entry.Hash, entry.Size, entry.Titles);
|
|
||||||
_hashCache[entry.Hash] = entry.Path;
|
|
||||||
_sizeLookup[entry.Hash] = entry.Size;
|
|
||||||
if (entry.Path.Contains(ArchivePathSeparator))
|
|
||||||
{
|
|
||||||
var filename = entry.Path.Split(ArchivePathSeparator)[0];
|
|
||||||
_archiveLookup[filename] = entry.Path;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var ncaMetadataWithHash in entry.Titles)
|
|
||||||
{
|
|
||||||
_hashCache[ncaMetadataWithHash.Hash] = entry.Path;
|
|
||||||
_sizeLookup[ncaMetadataWithHash.Hash] = entry.Size;
|
|
||||||
_logger.LogInformation("Added entry {titleId} to snapshot (hash={hash})", ncaMetadataWithHash.TitleId, ncaMetadataWithHash.Hash);
|
|
||||||
}
|
|
||||||
// Persist snapshot to disk
|
|
||||||
PersistSnapshotAsync();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds _cache and _hashCache based on directory configuration
|
|
||||||
public Task BuildSnapshotAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Building snapshot");
|
|
||||||
var index = LoadSnapshotIndex();
|
|
||||||
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
|
|
||||||
var fileInfo = new FileInfo(_snapshotPath);
|
|
||||||
bool snapshotVerified = true;
|
|
||||||
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
|
|
||||||
{
|
|
||||||
if (index.Count != 0)
|
|
||||||
{
|
|
||||||
// directory may have been added with older roms, verify that the snapshot is still up to date
|
|
||||||
foreach (var dir in _options.RootDirectories)
|
|
||||||
{
|
|
||||||
// check first entry is in index
|
|
||||||
var entry = BuildSnapshot(dir).FirstOrDefault();
|
|
||||||
if (entry != null)
|
|
||||||
{
|
|
||||||
if (!index.TryGetValue(entry.Path, out var cached))
|
|
||||||
{
|
|
||||||
snapshotVerified = false;
|
|
||||||
_logger.LogInformation("Snapshot does not contain first entry in directory {Directory}", dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshotVerified)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Snapshot is up to date");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Snapshot is up to date but index is empty");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
|
|
||||||
var entries = new List<FileEntry>();
|
|
||||||
|
|
||||||
var snapshotChanged = false;
|
|
||||||
foreach (var dir in _options.RootDirectories)
|
|
||||||
{
|
|
||||||
_ = Task.Run(() =>
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Rebuilding directory {Directory}", dir);
|
|
||||||
var buildSnapshot = BuildSnapshot(dir);
|
|
||||||
var fileEntries = buildSnapshot.ToList();
|
|
||||||
snapshotChanged = snapshotChanged || fileEntries.Count != 0;
|
|
||||||
entries.AddRange(fileEntries.Where(entry => entry != null)!);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the entire snapshot
|
|
||||||
ComputeSnapshotHash(entries);
|
|
||||||
if (snapshotChanged)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Snapshot rebuilt");
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void GetArchiveName(string titleId)
|
|
||||||
{
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns List of FileEntry that do not have a hash in the cache
|
|
||||||
// Each entry that has not been added to the lookup table is added to the cache
|
|
||||||
private IEnumerable<FileEntry?> BuildSnapshot(string dir)
|
|
||||||
{
|
|
||||||
FileEntry entry;
|
|
||||||
if (!Directory.Exists(dir)) yield break;
|
|
||||||
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
string hash = string.Empty;
|
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
|
||||||
|
|
||||||
if (!(_options.ArchiveExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (_cache.ContainsKey(file) || _hashCache.ContainsKey(hash))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) extract title if applicable
|
|
||||||
var titles = new List<(string, long, NcaMetadataWithHash)>();
|
|
||||||
if (_options.RomExtensions.Contains(ext))
|
|
||||||
{
|
|
||||||
using var nspStream = File.OpenRead(file);
|
|
||||||
hash = ComputeFirstStreamHash(nspStream);
|
|
||||||
|
|
||||||
if (_hashCache.ContainsKey(hash))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nspStreamLength = nspStream.Length;
|
|
||||||
var title = _nspExtractor.ExtractFromStream(nspStream);
|
|
||||||
if (title != null)
|
|
||||||
{
|
|
||||||
var archiveEntry = new FileEntry(file, nspStreamLength, hash, [title]);
|
|
||||||
AddToSnapshotAsync(archiveEntry);
|
|
||||||
titles.Add((title.TitleId, nspStreamLength, title));
|
|
||||||
yield return archiveEntry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (_options.ArchiveExtensions.Contains(ext))
|
|
||||||
{
|
|
||||||
if (_archiveLookup.ContainsKey(file)) continue;
|
|
||||||
hash = ComputeFirstStreamHash(file);
|
|
||||||
if (_hashCache.ContainsKey(hash))
|
|
||||||
{
|
|
||||||
yield return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
IEnumerable<(string, long, NcaMetadataWithHash)>? titlesEnumerable = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
titlesEnumerable = _archiveHandler.TryExtractTitleInfos(file);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "Failed to extract title info from archive {Archive}", file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titlesEnumerable == null) continue;
|
|
||||||
|
|
||||||
titles = titlesEnumerable.ToList();
|
|
||||||
foreach (var title in titles)
|
|
||||||
{
|
|
||||||
var archiveEntry = new FileEntry(file + ArchivePathSeparator + title.Item1, title.Item2, title.Item3.Hash, [title.Item3]);
|
|
||||||
AddToSnapshotAsync(archiveEntry);
|
|
||||||
yield return archiveEntry;
|
|
||||||
}
|
|
||||||
/*var fileEntry = new FileEntry(file, new FileInfo(file).Length, hash, titles.Select((tuple, i) => tuple.Item3).ToList());
|
|
||||||
AddToSnapshotAsync(fileEntry);
|
|
||||||
yield return fileEntry;*/
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titles.Count == 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Failed to process {File}", file);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Added {File} to snapshot (hash={Hash})", file, hash);
|
|
||||||
yield return new FileEntry(file, titles.Select((tuple, i) => tuple.Item2).FirstOrDefault(), hash, titles.Select((tuple, i) => tuple.Item3).ToList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(Stream nspStream)
|
|
||||||
{
|
|
||||||
return _nspExtractor.ExtractHashFromStream(nspStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSnapshot() => BuildSnapshotAsync();
|
|
||||||
|
|
||||||
IEnumerable<FileEntry> GetEntries()
|
|
||||||
{
|
|
||||||
foreach (var snapshotEntry in _cache)
|
|
||||||
{
|
|
||||||
_sizeLookup.TryGetValue(snapshotEntry.Value.Hash, out var size);
|
|
||||||
var fileEntry = new FileEntry(snapshotEntry.Key, snapshotEntry.Value.Size, snapshotEntry.Value.Hash, snapshotEntry.Value.NcaMetadataWithHash);
|
|
||||||
yield return fileEntry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private Task PersistSnapshotAsync()
|
|
||||||
{
|
|
||||||
if (_debouncerCache.TryGetValue(_jsonPath, out var value))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Sliding debounce in progress, skipping snapshot persistence");
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
var snapshot = GetSnapshot();
|
|
||||||
var entries = GetEntries();
|
|
||||||
var fileEntries = entries.ToList();
|
|
||||||
var newHash = ComputeSnapshotHash(fileEntries);
|
|
||||||
if (snapshot.Hash == newHash) return Task.CompletedTask;
|
|
||||||
|
|
||||||
_logger.LogInformation("Snapshot hash changed – persisting new snapshot");
|
|
||||||
using var debouncedPersistence = _debouncerCache.CreateEntry(_jsonPath);
|
|
||||||
debouncedPersistence.SlidingExpiration = TimeSpan.FromMilliseconds(DebounceMs);
|
|
||||||
debouncedPersistence.Value = fileEntries;
|
|
||||||
debouncedPersistence.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
|
|
||||||
{
|
|
||||||
EvictionCallback = (key, entriesCallback, reason, state) =>
|
|
||||||
{
|
|
||||||
if (entriesCallback is IEnumerable<FileEntry> entriesToPersist && key is string filePath)
|
|
||||||
{
|
|
||||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
|
||||||
{
|
|
||||||
if (IsFileLocked(filePath))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("File {FilePath} is locked, skipping snapshot persistence", filePath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
File.WriteAllText(filePath,
|
|
||||||
JsonSerializer.Serialize(entriesToPersist, _jsonSerializerOptions));
|
|
||||||
_snapshotFileSemaphore.Release();
|
|
||||||
_logger.LogInformation("Persisted snapshot");
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Failed to persist file {FilePath} due to timeout", filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static string ComputeHash(string filePath)
|
|
||||||
{
|
|
||||||
using var sha = SHA256.Create();
|
|
||||||
using var stream = File.OpenRead(filePath);
|
|
||||||
var hash = sha.ComputeHash(stream);
|
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSnapshotHash(IEnumerable<FileEntry> entries)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(entries);
|
|
||||||
using var sha = SHA256.Create();
|
|
||||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(json));
|
|
||||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
/// <summary>
|
|
||||||
/// From filesystem cache, load each entry and build the lookups
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
private Dictionary<string, FileEntry> LoadSnapshotIndex()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_jsonPath)) return new Dictionary<string, FileEntry>();
|
|
||||||
_snapshotFileSemaphore.Wait();
|
|
||||||
var json = File.ReadAllText(_jsonPath);
|
|
||||||
_snapshotFileSemaphore.Release();
|
|
||||||
var entries = JsonSerializer.Deserialize<List<FileEntry>>(json, _jsonSerializerOptions)!;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fileEntries = new Dictionary<string, FileEntry>();
|
|
||||||
// Reindex the cache
|
|
||||||
foreach (var fileEntry in entries)
|
|
||||||
{
|
|
||||||
if (_hashCache.TryGetValue(fileEntry.Hash, out var value))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Duplicate hash found in snapshot: {Hash}, {OldPath}, {newPath}", fileEntry.Hash, value, fileEntry.Path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_options.RomExtensions.Contains(Path.GetExtension(fileEntry.Path)))
|
|
||||||
{
|
|
||||||
if (fileEntry.Path.Contains(ArchivePathSeparator))
|
|
||||||
{
|
|
||||||
var filename = fileEntry.Path.Split(ArchivePathSeparator)[0];
|
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
|
||||||
_archiveLookup[filename] = fileEntry.Path;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_cache[fileEntry.Path] = new SnapshotEntry(fileEntry.Path, fileEntry.Hash, fileEntry.Size, fileEntry.Titles);
|
|
||||||
fileEntries.TryAdd(fileEntry.Path, fileEntry);
|
|
||||||
_hashCache[fileEntry.Hash] = fileEntry.Path;
|
|
||||||
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
|
||||||
if (fileEntry.Titles == null) continue;
|
|
||||||
foreach (var ncaMetadataWithHash in fileEntry.Titles)
|
|
||||||
{
|
|
||||||
_hashCache[ncaMetadataWithHash.Hash] = fileEntry.Path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fileEntries;
|
|
||||||
}
|
|
||||||
catch (ArgumentException e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "Failed to load snapshot");
|
|
||||||
return new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void RebuildSnapshot()
|
|
||||||
{
|
|
||||||
// 1️⃣ Flush the old in‑memory snapshot
|
|
||||||
_cache.Clear();
|
|
||||||
_hashCache.Clear();
|
|
||||||
_archiveLookup.Clear();
|
|
||||||
_sizeLookup.Clear();
|
|
||||||
//_failedAttempts.Clear(); // if you keep per‑user counters
|
|
||||||
|
|
||||||
// 2️⃣ Re‑build from disk again
|
|
||||||
BuildSnapshotAsync().Wait(); // synchronous – we already own the lock
|
|
||||||
PersistSnapshotAsync().Wait(); // same
|
|
||||||
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public ROMSnapshot GetSnapshot()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_jsonPath)) return new ROMSnapshot();
|
|
||||||
|
|
||||||
if (_snapshotFileSemaphore.Wait(SnapshotFileLockTimeout))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = File.ReadAllText(_jsonPath);
|
|
||||||
var hash = ComputeHash(_jsonPath);
|
|
||||||
var romSnapshot = new ROMSnapshot
|
|
||||||
{
|
|
||||||
Hash = hash,
|
|
||||||
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, _jsonSerializerOptions)!
|
|
||||||
};
|
|
||||||
return romSnapshot;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogError(e, "Failed to load snapshot");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_snapshotFileSemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Failed to load snapshot due to timeout");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ROMSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var watcher in _watchers)
|
|
||||||
{
|
|
||||||
watcher.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record SnapshotEntry(string Path, string Hash, long Size, List<NcaMetadataWithHash> NcaMetadataWithHash);
|
|
||||||
|
|
||||||
// File: TinfoilVibeServer/Services/SnapshotService.cs (inside SnapshotService class)
|
|
||||||
|
|
||||||
private string ComputeFirstStreamHash(string filePath)
|
|
||||||
{
|
|
||||||
// Only treat NSP/XCI/XCZ as “first‑stream” files
|
|
||||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
|
||||||
if (ext is not ".nsp" and not ".xci" and not ".xcz")
|
|
||||||
{
|
|
||||||
// Open the NSP/XCI with LibHac and read the first stream.
|
|
||||||
// The first stream is the first entry returned by GetContentInfos().
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var reader = new RomArchiveReader(filePath);
|
|
||||||
|
|
||||||
var first = reader.GetEntries().FirstOrDefault();
|
|
||||||
if (first == null) return ComputeFullHash(filePath);
|
|
||||||
|
|
||||||
using var firstStream = first.Stream;
|
|
||||||
var hash = _nspExtractor.ExtractHashFromStream(firstStream);
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// On error, fall back to the full file hash
|
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
||||||
var ncaMetadataWithHash = _nspExtractor.ExtractFromStream(fs);
|
|
||||||
return ncaMetadataWithHash?.Hash ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeFullHash(string filePath)
|
|
||||||
{
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
using var stream = File.OpenRead(filePath);
|
|
||||||
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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Starting snapshot service");
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
await BuildSnapshotAsync();
|
|
||||||
await PersistSnapshotAsync();
|
|
||||||
}, cancellationToken); // initial scan
|
|
||||||
new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using TinfoilVibeServer.Models;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// * Loads the title‑database JSON that lives on GitHub.
|
|
||||||
/// * Caches the JSON file on disk (in a configurable “cache” folder).
|
|
||||||
/// * Builds a dictionary that maps a 16‑digit hex TitleId → the full
|
|
||||||
/// filesystem path of the NSP that contains it (for later look‑ups).
|
|
||||||
/// * Provides a convenient look‑up API (via <c>GetTitleByTitleId</c>).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TitleDatabaseService : IHostedService
|
|
||||||
{
|
|
||||||
#region Configuration keys
|
|
||||||
private const string CacheKey = "TitleDb";
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Fields
|
|
||||||
|
|
||||||
private readonly IOptionsMonitor<TitleDbOptions> _options;
|
|
||||||
private readonly ILogger<TitleDatabaseService> _logger;
|
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
|
||||||
private readonly INSPExtractor _nspExtractor;
|
|
||||||
private readonly string _cacheFolder; // Where the JSON is cached.
|
|
||||||
private readonly List<string> _rootDirectories; // directories that contain game files
|
|
||||||
|
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
private readonly ISnapshotService _snapshotService;
|
|
||||||
|
|
||||||
private readonly Dictionary<string,string> _titleIdToPath = new Dictionary<string, string>();
|
|
||||||
|
|
||||||
// Regex to find a 16‑digit hex TitleId in a filename
|
|
||||||
private static readonly Regex TitleIdRegex = new(
|
|
||||||
@"([0-9a-fA-F]{8})([0-9a-fA-F]{8})", RegexOptions.Compiled);
|
|
||||||
private readonly SemaphoreSlim _reloadLock = new(1, 1); // protects reload logic
|
|
||||||
private Task? _reloadTask; // the currently running or last finished reload
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region ctor
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Register as a singleton IHostedService.
|
|
||||||
/// The constructor receives the values that are needed to build
|
|
||||||
/// the GitHub URL (CountryCode + Language) and the root
|
|
||||||
/// directories that contain the NSP files.
|
|
||||||
/// </summary>
|
|
||||||
public TitleDatabaseService(
|
|
||||||
IConfiguration configuration,
|
|
||||||
IOptionsMonitor<TitleDbOptions> options,
|
|
||||||
ILogger<TitleDatabaseService> logger,
|
|
||||||
ISnapshotService snapshotService,
|
|
||||||
IHttpClientFactory httpFactory,
|
|
||||||
INSPExtractor nspExtractor,
|
|
||||||
IMemoryCache cache)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
_logger = logger;
|
|
||||||
_snapshotService = snapshotService;
|
|
||||||
_httpFactory = httpFactory;
|
|
||||||
_nspExtractor = nspExtractor;
|
|
||||||
_cache = cache;
|
|
||||||
|
|
||||||
_cacheFolder = Path.Combine(AppContext.BaseDirectory, "titledb-cache");
|
|
||||||
_rootDirectories = new List<string>
|
|
||||||
{
|
|
||||||
// You can extend this list – it is the set of directories that
|
|
||||||
// are scanned when the service starts up.
|
|
||||||
Path.Combine(AppContext.BaseDirectory, "Games")
|
|
||||||
};
|
|
||||||
// Reload cache immediately when a snapshot rebuild occurs
|
|
||||||
_snapshotService.SnapshotRebuilt += SnapshotServiceOnSnapshotRebuilt;
|
|
||||||
_options.OnChange(OptionsChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OptionsChanged(TitleDbOptions arg1, string? arg2)
|
|
||||||
{
|
|
||||||
// todo: handle ttl changes
|
|
||||||
// todo: handle country code change
|
|
||||||
// todo: handle language change
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region IHostedService
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1️⃣ Load the JSON (download if not cached).
|
|
||||||
await ReloadCacheAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("Title database ready – {Count} entries loaded.",
|
|
||||||
GetAllAsync().Result.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
|
||||||
=> Task.CompletedTask; // nothing special to do on shutdown
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 1️⃣ Cache loading / reloading – sliding expiration
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
private async Task ReloadCacheAsync()
|
|
||||||
{
|
|
||||||
var ttlSec = _options.CurrentValue.TtlSeconds;
|
|
||||||
_logger.LogInformation("Reloading title database cache (TTL={TTL}s).", ttlSec);
|
|
||||||
|
|
||||||
var entryOptions = new MemoryCacheEntryOptions()
|
|
||||||
.SetSlidingExpiration(TimeSpan.FromSeconds(ttlSec))
|
|
||||||
.RegisterPostEvictionCallback((key, value, reason, state) => { _logger.LogInformation("Cache eviction: {Key} ({Reason})", key, reason); });
|
|
||||||
|
|
||||||
var dict = await LoadFromDiskAsync() ?? new Dictionary<string, TitleInfoDto>();
|
|
||||||
_cache.Set(CacheKey, dict, entryOptions);
|
|
||||||
_logger.LogInformation("Title DB reloaded – {Count} items cached (TTL={TTL}s).",
|
|
||||||
dict.Count, ttlSec);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Dictionary<string, TitleInfoDto>?> LoadFromDiskAsync()
|
|
||||||
{
|
|
||||||
var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
|
|
||||||
if (!File.Exists(cacheFile))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Cache miss – downloading title DB from {Url}", cacheFile);
|
|
||||||
await LoadAndCacheTitleDb(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ReadTitleDbAsync(cacheFile, CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 2️⃣ Public API – every call slides the cache
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
public async Task<Dictionary<string, TitleInfoDto>> GetAllAsync()
|
|
||||||
{
|
|
||||||
await EnsureCacheLoadedAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
// The cache entry is guaranteed to exist now
|
|
||||||
_cache.TryGetValue(CacheKey, out Dictionary<string, TitleInfoDto>? dict);
|
|
||||||
return dict!;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<TitleInfoDto?> GetAsync(string titleId)
|
|
||||||
{
|
|
||||||
var all = await GetAllAsync(); // slides the entry
|
|
||||||
all.TryGetValue(titleId, out var dto);
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 3️⃣ Persist to disk & notify snapshot service
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
private async Task PersistAsync(Dictionary<string, TitleInfoDto> dict)
|
|
||||||
{
|
|
||||||
// Trigger a rebuild so SnapshotService (and any other listeners)
|
|
||||||
// can pick up the new snapshot.
|
|
||||||
//_snapshotService.RebuildSnapshot();
|
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* 4️⃣ Dispose
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_snapshotService.SnapshotRebuilt -= SnapshotServiceOnSnapshotRebuilt;
|
|
||||||
_reloadLock.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void SnapshotServiceOnSnapshotRebuilt(object? o, EventArgs eventArgs)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await EnsureCacheLoadedAsync();
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.LogCritical(e, "Failed to reload title database cache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Public API
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return the <c>TitleInfoDto</c> for a known <c>TitleId</c>.
|
|
||||||
/// </summary>
|
|
||||||
public bool TryGetTitle(string titleId, out TitleInfoDto? title)
|
|
||||||
{
|
|
||||||
title = GetAsync(titleId).GetAwaiter().GetResult();
|
|
||||||
return title != null;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Private helpers
|
|
||||||
/// <summary>
|
|
||||||
/// Makes sure the cache is loaded. If a reload is already in progress,
|
|
||||||
/// the caller simply awaits the same task. Otherwise it starts a new reload.
|
|
||||||
/// </summary>
|
|
||||||
private async Task EnsureCacheLoadedAsync()
|
|
||||||
{
|
|
||||||
// Fast path – a cache entry exists, no work needed
|
|
||||||
if (_cache.TryGetValue(CacheKey, out _))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Fast path – a reload is already underway
|
|
||||||
var existingTask = _reloadTask;
|
|
||||||
if (existingTask != null)
|
|
||||||
{
|
|
||||||
await existingTask.ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path – we need to start the reload
|
|
||||||
await _reloadLock.WaitAsync().ConfigureAwait(false);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Double‑check after acquiring the lock
|
|
||||||
if (_cache.TryGetValue(CacheKey, out _))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// If another thread started the reload while we were waiting, use it
|
|
||||||
existingTask = _reloadTask;
|
|
||||||
if (existingTask != null)
|
|
||||||
{
|
|
||||||
await existingTask.ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the shared task
|
|
||||||
_reloadTask = ReloadCacheAsync();
|
|
||||||
await _reloadTask.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_reloadTask = null; // reset for the next miss
|
|
||||||
_reloadLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downloads the JSON file from GitHub (raw) if it does not exist
|
|
||||||
/// or the cached copy is older than the remote one.
|
|
||||||
/// </summary>
|
|
||||||
private async Task LoadAndCacheTitleDb(CancellationToken ct)
|
|
||||||
{
|
|
||||||
// Build the raw URL
|
|
||||||
var rawUrl = $"https://raw.githubusercontent.com/blawar/titledb/refs/heads/master/{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json";
|
|
||||||
|
|
||||||
// Ensure the cache directory exists.
|
|
||||||
Directory.CreateDirectory(_cacheFolder);
|
|
||||||
var cacheFile = Path.Combine(_cacheFolder, $"{_options.CurrentValue.CountryCode}.{_options.CurrentValue.LanguageCode}.json");
|
|
||||||
|
|
||||||
// If the file exists & is recent – no download needed.
|
|
||||||
if (File.Exists(cacheFile))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(cacheFile);
|
|
||||||
// If the file is newer than 24h – use it.
|
|
||||||
if (fi.LastWriteTimeUtc > DateTime.UtcNow.AddHours(-24))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Using cached title database {File}", cacheFile);
|
|
||||||
await LoadAndCacheTitleDb(ct);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_logger.LogInformation("Cache miss – downloading title DB from {Url}", cacheFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Downloading title database from {Url}", rawUrl);
|
|
||||||
var client = _httpFactory.CreateClient();
|
|
||||||
using var response = await client.GetAsync(rawUrl, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
await using var fs = new FileStream(cacheFile, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
||||||
await response.Content.CopyToAsync(fs, ct);
|
|
||||||
|
|
||||||
_logger.LogInformation("Title database cached to {File}", cacheFile);
|
|
||||||
await ReadTitleDbAsync(cacheFile, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Read the JSON file and populate <c>_titleData</c>.
|
|
||||||
/// Also remap titleId for XCI files based on cnmts.json
|
|
||||||
/// </summary>
|
|
||||||
private async Task<Dictionary<string,TitleInfoDto>> ReadTitleDbAsync(string filePath, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var json = await File.ReadAllTextAsync(filePath, ct);
|
|
||||||
|
|
||||||
var titleInfoDtos = JsonSerializer.Deserialize<Dictionary<string, TitleInfoDto>>(
|
|
||||||
json,
|
|
||||||
new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
||||||
}) ?? new Dictionary<string, TitleInfoDto>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
_logger.LogInformation("Loaded {Count} titles from the database.", titleInfoDtos.Count);
|
|
||||||
|
|
||||||
var titleData = new Dictionary<string, TitleInfoDto>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
for (var i =0; i< titleInfoDtos.Values.Count; i++)
|
|
||||||
{
|
|
||||||
var entry = titleInfoDtos.Values.ElementAt(i);
|
|
||||||
var key = titleInfoDtos.Keys.ElementAt(i);
|
|
||||||
if (!string.IsNullOrWhiteSpace(key))
|
|
||||||
{
|
|
||||||
if (entry.Id != null)
|
|
||||||
{
|
|
||||||
if (entry.Id.Length == 16)
|
|
||||||
{
|
|
||||||
titleData[entry.Id] = entry;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return titleData;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="Microsoft.Extensions.Options" Version="9.0.10" />
|
|
||||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||||
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
<PackageReference Include="SharpSevenZip" Version="2.0.32" />
|
||||||
<PackageReference Include="System.Runtime.Caching" Version="9.0.10" />
|
|
||||||
|
<PackageReference Include="LibHac" Version="0.19.0" />
|
||||||
|
|
||||||
|
<PackageReference Include="System.Interactive.Async" Version="6.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -23,32 +24,32 @@
|
|||||||
<Content Update="appsettings.Development.json">
|
<Content Update="appsettings.Development.json">
|
||||||
<DependentUpon>appsettings.json</DependentUpon>
|
<DependentUpon>appsettings.json</DependentUpon>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Remove="obj\**" />
|
<Content Update="appsettings.json">
|
||||||
<AdditionalFiles Include="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll">
|
|
||||||
<Link>LibHac.dll</Link>
|
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</AdditionalFiles>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
|
<!-- libhac ships a native helper called Ryujinx.HLE.HOS.Native.dll.
|
||||||
The build script copies it to the output folder automatically. -->
|
The build script copies it to the output folder automatically. -->
|
||||||
<None Update="..\Dependencies\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
|
<None Update="..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<None Remove="obj\**" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="LibHac">
|
<Reference Include="LibHac">
|
||||||
<HintPath>..\Dependencies\LibHac.dll</HintPath>
|
<HintPath>..\libhac\src\LibHac\bin\Release\net8.0\LibHac.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="obj\**" />
|
<Folder Include="Middleware\" />
|
||||||
|
<Folder Include="Utilities\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Remove="obj\**" />
|
<Compile Include="C:\Users\ecens\.nuget\packages\sharpsevenzip\2.0.32\build\x86\Models\SnapshotEntry.cs">
|
||||||
|
<Link>x86\Models\SnapshotEntry.cs</Link>
|
||||||
|
</Compile>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
using System.Buffers;
|
|
||||||
|
|
||||||
namespace TinfoilVibeServer.Utilities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A read‑only, seekable wrapper around a non‑seekable stream.
|
|
||||||
/// It buffers the source data on demand in chunks so that you can seek
|
|
||||||
/// back and forth without reading the whole source at once.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SeekableBufferedStream : Stream
|
|
||||||
{
|
|
||||||
private const int DefaultChunkSize = 128 * 1024 * 1024; // 128 MiB
|
|
||||||
|
|
||||||
private readonly Stream _source;
|
|
||||||
private readonly ArrayPool<byte> _pool;
|
|
||||||
private readonly int _chunkSize;
|
|
||||||
private readonly bool _disposeSource;
|
|
||||||
|
|
||||||
// Buffer block – holds a rented byte[] and the number of bytes actually read.
|
|
||||||
private readonly struct BufferBlock
|
|
||||||
{
|
|
||||||
public readonly byte[] Data;
|
|
||||||
public readonly int Length;
|
|
||||||
public BufferBlock(byte[] data, int length) { Data = data; Length = length; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly List<BufferBlock> _blocks = new();
|
|
||||||
private readonly long _specifiedLength = 0;
|
|
||||||
private long _bufferedLength; // total number of bytes buffered so far
|
|
||||||
private long _position; // current logical position in the stream
|
|
||||||
private bool _eof; // true when the source stream has been exhausted
|
|
||||||
|
|
||||||
#region ctor / dispose
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new instance.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="source">The underlying source stream. Must be readable.</param>
|
|
||||||
/// <param name="specifiedLength">Length of underlying stream if known before using</param>
|
|
||||||
/// <param name="chunkSize">Size of each buffer chunk (bytes). 128 MiB by default.</param>
|
|
||||||
/// <param name="disposeSource">If true, disposing this wrapper will also dispose the source stream.</param>
|
|
||||||
public SeekableBufferedStream(Stream source, long specifiedLength = 0, int chunkSize = DefaultChunkSize, bool disposeSource = false)
|
|
||||||
{
|
|
||||||
if (source == null) throw new ArgumentNullException(nameof(source));
|
|
||||||
if (!source.CanRead) throw new ArgumentException("Source stream must be readable.", nameof(source));
|
|
||||||
if (chunkSize <= 0) throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
|
|
||||||
if (specifiedLength <= 0) throw new ArgumentOutOfRangeException(nameof(specifiedLength), "Specified length must be positive.");
|
|
||||||
|
|
||||||
_source = source;
|
|
||||||
_specifiedLength = specifiedLength;
|
|
||||||
_pool = ArrayPool<byte>.Shared;
|
|
||||||
_chunkSize = chunkSize;
|
|
||||||
_disposeSource = disposeSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
foreach (var block in _blocks)
|
|
||||||
_pool.Return(block.Data, clearArray: true);
|
|
||||||
_blocks.Clear();
|
|
||||||
|
|
||||||
if (_disposeSource)
|
|
||||||
_source.Dispose();
|
|
||||||
}
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region helpers
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ensures that at least <paramref name="requiredOffset"/> bytes are buffered.
|
|
||||||
/// Reads from the source stream until the requested offset is reached or EOF is hit.
|
|
||||||
/// </summary>
|
|
||||||
private void EnsureBuffered(long requiredOffset)
|
|
||||||
{
|
|
||||||
if (_eof || _bufferedLength >= requiredOffset)
|
|
||||||
return;
|
|
||||||
|
|
||||||
while (_bufferedLength < requiredOffset && !_eof)
|
|
||||||
{
|
|
||||||
var buf = _pool.Rent(_chunkSize);
|
|
||||||
int read = _source.Read(buf, 0, _chunkSize);
|
|
||||||
if (read == 0)
|
|
||||||
{
|
|
||||||
_eof = true;
|
|
||||||
_pool.Return(buf, clearArray: true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
_blocks.Add(new BufferBlock(buf, read));
|
|
||||||
_bufferedLength += read;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Finds the block that contains <paramref name="pos"/> and the offset inside that block.
|
|
||||||
/// </summary>
|
|
||||||
private void GetBlockAndOffset(long pos, out int blockIndex, out int offsetInBlock)
|
|
||||||
{
|
|
||||||
long accumulated = 0;
|
|
||||||
for (int i = 0; i < _blocks.Count; i++)
|
|
||||||
{
|
|
||||||
int blockLen = _blocks[i].Length;
|
|
||||||
if (pos < accumulated + blockLen)
|
|
||||||
{
|
|
||||||
blockIndex = i;
|
|
||||||
offsetInBlock = (int)(pos - accumulated);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
accumulated += blockLen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This should never happen because we always call EnsureBuffered before accessing.
|
|
||||||
throw new InvalidOperationException("Requested position is outside buffered range.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Stream overrides
|
|
||||||
|
|
||||||
public override bool CanRead => true;
|
|
||||||
public override bool CanSeek => true;
|
|
||||||
public override bool CanWrite => false;
|
|
||||||
public override long Length
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
// If we were given a length, we can return that.
|
|
||||||
if (_specifiedLength > 0) return _specifiedLength;
|
|
||||||
|
|
||||||
// If we already hit EOF, we know the length.
|
|
||||||
if (_eof) return _bufferedLength;
|
|
||||||
|
|
||||||
// If the underlying stream is seekable, we can ask it directly.
|
|
||||||
if (_source.CanSeek)
|
|
||||||
return _source.Length;
|
|
||||||
|
|
||||||
// Otherwise we need to drain the source to discover its length.
|
|
||||||
while (!_eof)
|
|
||||||
EnsureBuffered(_bufferedLength + _chunkSize);
|
|
||||||
return _bufferedLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override long Position
|
|
||||||
{
|
|
||||||
get => _position;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
|
|
||||||
if (value > Length) throw new ArgumentOutOfRangeException(nameof(value));
|
|
||||||
_position = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count)
|
|
||||||
{
|
|
||||||
if (buffer == null) throw new ArgumentNullException(nameof(buffer));
|
|
||||||
if (offset < 0 || count < 0 || offset + count > buffer.Length)
|
|
||||||
throw new ArgumentOutOfRangeException();
|
|
||||||
|
|
||||||
// If we are already at or beyond the logical end, nothing to read.
|
|
||||||
if (_position >= Length)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
// We will read at most `count` bytes but not past the logical end.
|
|
||||||
long maxRead = Math.Min(count, Length - _position);
|
|
||||||
EnsureBuffered(_position + maxRead);
|
|
||||||
|
|
||||||
int bytesRead = 0;
|
|
||||||
while (bytesRead < maxRead)
|
|
||||||
{
|
|
||||||
GetBlockAndOffset(_position, out int blockIdx, out int blockOffset);
|
|
||||||
var block = _blocks[blockIdx];
|
|
||||||
int available = block.Length - blockOffset;
|
|
||||||
int toCopy = (int)Math.Min(available, maxRead - bytesRead);
|
|
||||||
|
|
||||||
Buffer.BlockCopy(block.Data, blockOffset, buffer, offset + bytesRead, toCopy);
|
|
||||||
|
|
||||||
_position += toCopy;
|
|
||||||
bytesRead += toCopy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytesRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override long Seek(long offset, SeekOrigin origin)
|
|
||||||
{
|
|
||||||
long newPos = origin switch
|
|
||||||
{
|
|
||||||
SeekOrigin.Begin => offset,
|
|
||||||
SeekOrigin.Current => _position + offset,
|
|
||||||
SeekOrigin.End => Length + offset,
|
|
||||||
_ => throw new ArgumentException("Invalid SeekOrigin", nameof(origin))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (newPos < 0) throw new IOException("Attempted to seek before the beginning of the stream.");
|
|
||||||
|
|
||||||
// Make sure we have buffered data up to the new position.
|
|
||||||
EnsureBuffered(newPos);
|
|
||||||
_position = newPos;
|
|
||||||
return _position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void SetLength(long value) => throw new NotSupportedException();
|
|
||||||
|
|
||||||
public override void Flush() { /* No-op – read‑only stream */ }
|
|
||||||
|
|
||||||
public override void Write(byte[] buffer, int offset, int count) =>
|
|
||||||
throw new NotSupportedException();
|
|
||||||
|
|
||||||
public override void WriteByte(byte value) => throw new NotSupportedException();
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region async helpers (optional)
|
|
||||||
|
|
||||||
public override async ValueTask<int> ReadAsync(Memory<byte> destination, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// If we are already at or beyond the logical end, nothing to read.
|
|
||||||
if (_position >= Length)
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
long maxRead = Math.Min(destination.Length, Length - _position);
|
|
||||||
EnsureBuffered(_position + maxRead);
|
|
||||||
|
|
||||||
int bytesRead = 0;
|
|
||||||
while (bytesRead < maxRead)
|
|
||||||
{
|
|
||||||
GetBlockAndOffset(_position, out int blockIdx, out int blockOffset);
|
|
||||||
var block = _blocks[blockIdx];
|
|
||||||
int available = block.Length - blockOffset;
|
|
||||||
int toCopy = (int)Math.Min(available, maxRead - bytesRead);
|
|
||||||
|
|
||||||
// We copy synchronously – no async source involved
|
|
||||||
destination.Slice(bytesRead, toCopy).Span
|
|
||||||
.CopyTo(block.Data.AsSpan(blockOffset, toCopy));
|
|
||||||
|
|
||||||
_position += toCopy;
|
|
||||||
bytesRead += toCopy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytesRead;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,5 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"RootDirectories": [ "D:\\Cloud\\Git\\TinfoilWebServer\\TinfoilWebServer.Test\\data", "Z:\\imgs\\roms\\Switch" ]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [
|
||||||
"Default": "Information",
|
"Serilog.Sinks.Console"
|
||||||
"Microsoft.AspNetCore": "Warning"
|
],
|
||||||
}
|
"MinimumLevel": "Information",
|
||||||
|
"WriteTo": [
|
||||||
|
{
|
||||||
|
"Name": "Console",
|
||||||
|
"Args": {
|
||||||
|
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"GameDirectories": {
|
||||||
"KeySetFile": "prod.keys",
|
"Paths": [
|
||||||
"CredentialsFile": "credentials.json",
|
"\\\\NAS\\Games",
|
||||||
"FingerprintsFile": "fingerprints.json",
|
"Z:\\imgs\\roms\\Switch"
|
||||||
"BlacklistFile": "blacklist.json",
|
]
|
||||||
"MaxFailedAttempts": 5,
|
|
||||||
"Snapshot" : {
|
|
||||||
"RootDirectories": [ "Z:\\downloads\\roms\\switch", "Z:\\imgs\\roms\\Switch" ],
|
|
||||||
"ArchiveExtensions": [ ".zip", ".rar", ".7z" ],
|
|
||||||
"RomExtensions": [ ".xci", ".nsp", ".xcz" ],
|
|
||||||
"CacheTtl": 60,
|
|
||||||
"SnapshotFile": "index.tfl",
|
|
||||||
"SnapshotBackupFile": "snapshot.bin"
|
|
||||||
},
|
},
|
||||||
"IndexBuilder": {
|
"SnapshotPath": "snapshot.json",
|
||||||
"ApiBaseUrl": "http://tinfoil.localhost:80"
|
"WhitelistExtensions": [
|
||||||
},
|
".bin",
|
||||||
"TitleDb": {
|
".jpg",
|
||||||
"CountryCode": "AU",
|
".png",
|
||||||
"Language": "en",
|
".txt"
|
||||||
"TtlSeconds" : 90,
|
|
||||||
"SnapshotFile" : "snapshot.json"
|
|
||||||
},
|
|
||||||
|
|
||||||
"IndexDirectories": [
|
|
||||||
"https://url1",
|
|
||||||
"sdmc:/url2",
|
|
||||||
"http://url3"
|
|
||||||
],
|
],
|
||||||
"Success" : "Welcome to Tinfoil Vibe Server!"
|
"RomExtensions": [
|
||||||
|
".xci",
|
||||||
|
".nsp",
|
||||||
|
".xcz"
|
||||||
|
],
|
||||||
|
"SnapshotFile": "index.tfl",
|
||||||
|
"SnapshotBackupFile": "snapshot.bin",
|
||||||
|
"ArchiveBufferSize": 8192,
|
||||||
|
"KeySet": "prod.keys"
|
||||||
}
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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 = "", 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using LibHac.Ncm;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
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;
|
|
||||||
private MemoryCache _memoryCache;
|
|
||||||
|
|
||||||
[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>();
|
|
||||||
var memoryCacheOptions = Options.Create(new MemoryCacheOptions());
|
|
||||||
_memoryCache = new MemoryCache(memoryCacheOptions);
|
|
||||||
|
|
||||||
|
|
||||||
_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(_memoryCache, _mockOptions.Object, _nspExtractorMock.Object, _archiveHander.Object, _loggerMock.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TearDown]
|
|
||||||
public void TearDown()
|
|
||||||
{
|
|
||||||
_service.Dispose();
|
|
||||||
_memoryCache.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task BuildSnapshot_WhenFilesChanged_ShouldPersist()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var initialHash = _service.GetSnapshot()?.Hash;
|
|
||||||
var rebuilding = false;
|
|
||||||
var rebuilt = false;
|
|
||||||
CancellationTokenSource snapshotRebuilding = new();
|
|
||||||
_service.SnapshotRebuilding += (sender, args) =>
|
|
||||||
{
|
|
||||||
rebuilding = true;
|
|
||||||
snapshotRebuilding.Cancel();
|
|
||||||
};
|
|
||||||
CancellationTokenSource snapshotPersisting = new();
|
|
||||||
_service.SnapshotRebuilt+= (sender, args) =>
|
|
||||||
{
|
|
||||||
rebuilt = true;
|
|
||||||
snapshotPersisting.Cancel();
|
|
||||||
// Assert
|
|
||||||
var newHash = _service.GetSnapshot()?.Hash;
|
|
||||||
Assert.That(newHash, Is.Not.EqualTo(initialHash));
|
|
||||||
};
|
|
||||||
Timer timer = new(state =>
|
|
||||||
{
|
|
||||||
snapshotPersisting.Cancel();
|
|
||||||
snapshotRebuilding.Cancel();
|
|
||||||
}, null, 20*1000, 0);
|
|
||||||
await File.WriteAllTextAsync(_options.SnapshotFile, "[]", snapshotPersisting.Token);
|
|
||||||
// Add a file to Root1
|
|
||||||
var newFile = Path.Combine(_options.RootDirectories.First(), "new.nsp");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await File.WriteAllTextAsync(newFile,"TEST");
|
|
||||||
|
|
||||||
Task.Delay(4000).Wait();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (_memoryCache.Count > 0)
|
|
||||||
{
|
|
||||||
Task.Delay(200).Wait(snapshotRebuilding.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
Assert.That(rebuilding, Is.True);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (_memoryCache.Count > 0)
|
|
||||||
{
|
|
||||||
Task.Delay(200).Wait(snapshotPersisting.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException e)
|
|
||||||
{
|
|
||||||
Assert.That(rebuilt, Is.True);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task BuildSnapshot_NoChange_ShouldNotPersist()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
_service.BuildSnapshotAsync();
|
|
||||||
|
|
||||||
// Act again – snapshot should be identical
|
|
||||||
_service.BuildSnapshotAsync();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<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.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>
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</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"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="NUnit.Framework"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user