From 1a160442d287d5759746cbdd7da7d946204e2df1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 13 Sep 2026 18:14:33 -0300 Subject: [PATCH 1/3] Add self-hosted Home Storage provider - Add .NET 10 Docker-based storage API with cataloging, authentication, discovery, range downloads, and admin panel - Integrate Home Storage discovery, browsing, downloads, and installation into the Switch client - Document deployment, configuration, security, and CI coverage --- .github/workflows/ci.yml | 30 ++- README.md | 25 +- home-storage/.dockerignore | 4 + home-storage/.env.example | 11 + home-storage/.gitignore | 6 + home-storage/Dockerfile | 23 ++ home-storage/HomeStorage.slnx | 4 + home-storage/README.md | 84 +++++++ home-storage/compose.yaml | 44 ++++ home-storage/openapi.yaml | 41 ++++ .../src/HomeStorage.Api/AdminPanel.cs | 86 +++++++ .../src/HomeStorage.Api/AuthService.cs | 38 +++ .../src/HomeStorage.Api/CatalogIndexer.cs | 80 +++++++ .../src/HomeStorage.Api/DiscoveryService.cs | 28 +++ .../src/HomeStorage.Api/DownloadGate.cs | 18 ++ .../HomeStorage.Api/HomeStorage.Api.csproj | 12 + .../src/HomeStorage.Api/LibraryPathPolicy.cs | 41 ++++ .../Migrations/202609130001_Initial.cs | 20 ++ .../Migrations/HomeStorageDbModelSnapshot.cs | 222 ++++++++++++++++++ home-storage/src/HomeStorage.Api/Models.cs | 88 +++++++ home-storage/src/HomeStorage.Api/Program.cs | 112 +++++++++ .../src/HomeStorage.Api/appsettings.json | 9 + .../tests/HomeStorage.Tests/ApiTests.cs | 100 ++++++++ .../HomeStorage.Tests.csproj | 10 + .../LibraryPathPolicyTests.cs | 15 ++ switch/include/switchdrive/core.hpp | 25 +- switch/include/switchdrive/i18n.hpp | 6 +- switch/include/switchdrive/network.hpp | 48 +++- switch/source/core.cpp | 86 ++++++- switch/source/i18n.cpp | 32 ++- switch/source/main.cpp | 163 ++++++++++--- switch/source/network.cpp | 119 +++++++++- switch/source/ui.cpp | 4 +- tests/CMakeLists.txt | 9 +- tests/core_tests.cpp | 51 +++- 35 files changed, 1619 insertions(+), 75 deletions(-) create mode 100644 home-storage/.dockerignore create mode 100644 home-storage/.env.example create mode 100644 home-storage/.gitignore create mode 100644 home-storage/Dockerfile create mode 100644 home-storage/HomeStorage.slnx create mode 100644 home-storage/README.md create mode 100644 home-storage/compose.yaml create mode 100644 home-storage/openapi.yaml create mode 100644 home-storage/src/HomeStorage.Api/AdminPanel.cs create mode 100644 home-storage/src/HomeStorage.Api/AuthService.cs create mode 100644 home-storage/src/HomeStorage.Api/CatalogIndexer.cs create mode 100644 home-storage/src/HomeStorage.Api/DiscoveryService.cs create mode 100644 home-storage/src/HomeStorage.Api/DownloadGate.cs create mode 100644 home-storage/src/HomeStorage.Api/HomeStorage.Api.csproj create mode 100644 home-storage/src/HomeStorage.Api/LibraryPathPolicy.cs create mode 100644 home-storage/src/HomeStorage.Api/Migrations/202609130001_Initial.cs create mode 100644 home-storage/src/HomeStorage.Api/Migrations/HomeStorageDbModelSnapshot.cs create mode 100644 home-storage/src/HomeStorage.Api/Models.cs create mode 100644 home-storage/src/HomeStorage.Api/Program.cs create mode 100644 home-storage/src/HomeStorage.Api/appsettings.json create mode 100644 home-storage/tests/HomeStorage.Tests/ApiTests.cs create mode 100644 home-storage/tests/HomeStorage.Tests/HomeStorage.Tests.csproj create mode 100644 home-storage/tests/HomeStorage.Tests/LibraryPathPolicyTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 325af47..9818aa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install test dependencies - run: sudo apt-get update && sudo apt-get install --yes libmbedtls-dev libzstd-dev + run: sudo apt-get update && sudo apt-get install --yes libmbedtls-dev libzstd-dev libcurl4-openssl-dev libjansson-dev - name: Configure tests run: cmake -S tests -B build/tests - name: Build tests @@ -47,6 +47,34 @@ jobs: - name: Build run: npm run build + home-storage: + name: Home Storage build and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Test in the .NET 10 SDK container stage + run: docker build --target test --tag switch-drive-home-storage-tests home-storage + - name: Smoke test the runtime container + run: | + mkdir -p /tmp/home-storage-library + docker build --target final --tag switch-drive-home-storage home-storage + docker run --detach --name home-storage-smoke --publish 18080:8080 --env SETUP_TOKEN=ci-only-placeholder --volume /tmp/home-storage-library:/library:ro switch-drive-home-storage + trap 'docker rm --force home-storage-smoke >/dev/null 2>&1 || true' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:18080/health; then break; fi + if [ "$attempt" = 30 ]; then docker logs home-storage-smoke; exit 1; fi + sleep 1 + done + docker rm --force home-storage-smoke + trap - EXIT + - name: Validate Compose + working-directory: home-storage + env: + HOST_LIBRARY_PATH: /tmp + SETUP_TOKEN: ci-only-placeholder + CLOUDFLARE_TUNNEL_TOKEN: ci-only-placeholder + run: docker compose config --quiet + switch-client: name: Switch client build runs-on: ubuntu-latest diff --git a/README.md b/README.md index a2f0174..6e88b13 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ Large-file support requires HOS 4.0.0 or later. active account in the current MVP. - Browse **My Drive** and **Shared with me**, including nested folders and items exposed through shared drives. -- Download files directly from Google to +- Add one or more self-hosted **Home Storage** providers, discover them on the + local network, and browse a private PC folder through the same download and + installation workflow. +- Download files directly from the selected provider to `sd:/switch-drive/downloads//`; file data does not pass through the pairing service. - Download files of 4 GiB or more as native HOS concatenated files, preserving one logical filename on the Switch while avoiding FAT32's per-file limit. -- Resume an interrupted download only after validating its Drive revision, ETag, +- Resume an interrupted download only after validating its provider identity, revision, ETag, HTTP range, expected size, and checksum metadata; invalid partial data can be restarted without appending a full response to it. @@ -69,8 +72,10 @@ immediately and is retained after relaunch. - **L/R:** change the main section. - **A:** activate the highlighted card, select, or open. - **B:** go back; on the main screen, focus the section menu. -- **X:** download the selected Drive file. -- **Y:** download and install the selected Drive file. +- **X:** download the selected remote file. +- **Y:** download and install the selected remote file. +- **ZL in Home Storage:** hide the selected catalog entry when the device token + has catalog-management permission. This changes only SQLite on the PC. - **Y in Library:** delete the downloaded package after confirmation, without uninstalling the game or deleting saves. Downloads without a managed installation are removed from the list; installed items retain their installation record. @@ -241,10 +246,18 @@ home network to the Internet. After configuring a custom domain and registering `https:///oauth/google/callback` in Google Cloud, deploy it with Wrangler and use that public HTTPS origin as `service_url`. +## Run Home Storage + +[`home-storage/`](home-storage/README.md) is an independent .NET 10/Docker +storage provider that exposes a host library folder read-only. It supports LAN +discovery, optional credentials, an SQLite catalog, and resumable HTTP range +downloads. It does not replace or depend on the Google Drive pairing service. + ## Security and data handling -The client never stores Google refresh tokens. It stores only a console session -credential and account IDs in `sd:/switch-drive/state.json`. The server encrypts +The client never stores Google refresh tokens or a Home Storage password. It +stores the console session credential, account IDs, and revocable Home Storage +bearer tokens in `sd:/switch-drive/state.json`. The pairing server encrypts refresh tokens using `TOKEN_ENCRYPTION_KEY` before writing them to PostgreSQL. Do not commit `.env`, console state, logs, or Google OAuth credentials. diff --git a/home-storage/.dockerignore b/home-storage/.dockerignore new file mode 100644 index 0000000..fa182fc --- /dev/null +++ b/home-storage/.dockerignore @@ -0,0 +1,4 @@ +**/bin +**/obj +.env +.git diff --git a/home-storage/.env.example b/home-storage/.env.example new file mode 100644 index 0000000..394328d --- /dev/null +++ b/home-storage/.env.example @@ -0,0 +1,11 @@ +# Windows example only; choose your own existing library folder. +HOST_LIBRARY_PATH=E:/SwitchDrive +LIBRARY_PATH=/library +HOME_STORAGE_PORT=8080 +DISCOVERY_PORT=8080 +INSTANCE_NAME=My Home Storage +SCAN_INTERVAL_MINUTES=5 +MAX_CONCURRENT_DOWNLOADS=4 +SETUP_TOKEN=replace-with-a-long-random-one-time-token +# Optional; needed only with the tunnel profile. +CLOUDFLARE_TUNNEL_TOKEN=replace-with-your-cloudflare-tunnel-token diff --git a/home-storage/.gitignore b/home-storage/.gitignore new file mode 100644 index 0000000..b2d79bd --- /dev/null +++ b/home-storage/.gitignore @@ -0,0 +1,6 @@ +.env +**/bin/ +**/obj/ +*.db +*.db-shm +*.db-wal diff --git a/home-storage/Dockerfile b/home-storage/Dockerfile new file mode 100644 index 0000000..c9378c9 --- /dev/null +++ b/home-storage/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore +WORKDIR /src +COPY HomeStorage.slnx ./ +COPY src/HomeStorage.Api/HomeStorage.Api.csproj src/HomeStorage.Api/ +COPY tests/HomeStorage.Tests/HomeStorage.Tests.csproj tests/HomeStorage.Tests/ +RUN dotnet restore HomeStorage.slnx + +FROM restore AS test +COPY . . +RUN dotnet test HomeStorage.slnx --no-restore --configuration Release + +FROM restore AS publish +COPY src/HomeStorage.Api src/HomeStorage.Api +RUN dotnet publish src/HomeStorage.Api/HomeStorage.Api.csproj --no-restore -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=publish /app/publish . +RUN mkdir -p /data && chown -R app:app /data +USER app +EXPOSE 8080/tcp 8080/udp +ENTRYPOINT ["dotnet", "HomeStorage.Api.dll"] diff --git a/home-storage/HomeStorage.slnx b/home-storage/HomeStorage.slnx new file mode 100644 index 0000000..4849946 --- /dev/null +++ b/home-storage/HomeStorage.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/home-storage/README.md b/home-storage/README.md new file mode 100644 index 0000000..73ba96f --- /dev/null +++ b/home-storage/README.md @@ -0,0 +1,84 @@ +# Switch Drive Home Storage + +Home Storage exposes a read-only host folder as a private Switch Drive provider. It is an independent .NET 10 Minimal API: SQLite stores the catalog and credentials, while file bytes are streamed directly from the bind mount and never pass through the existing pairing service, Worker, or Neon/Postgres. + +## Architecture + +- The ASP.NET Core process hosts the public API, server-rendered administration panel and UDP discovery responder. +- A background indexer walks the configured container directory at startup, every `SCAN_INTERVAL_MINUTES`, or when requested in the panel. +- SQLite and ASP.NET Core data-protection keys live in the writable `/data` volume; the library mount remains read-only. +- Public clients receive opaque catalog IDs. Relative paths are retained only inside SQLite and are revalidated against the configured root whenever a file is opened. +- The metadata pipeline exposes `IFileMetadataExtractor` for later CNMT/NACP/icon extraction without coupling package parsing to scans or downloads. + +## Start with Docker + +Docker is the only host dependency. From this directory: + +```powershell +Copy-Item .env.example .env +# Edit .env. HOST_LIBRARY_PATH must point at an existing folder. +docker compose up -d +docker compose logs -f home-storage +``` + +Open `http://localhost:8080/setup`, enter the `SETUP_TOKEN`, and choose the administrator and Switch-library credentials. There are no default credentials. The host path is controlled by `HOST_LIBRARY_PATH`; the panel may select only `/library` or a subdirectory already mounted into the container. + +The library is mounted with `read_only: true`. Hiding an entry changes SQLite only and never deletes a host file. Back up the `home-storage-data` Docker volume to preserve IDs, settings and device registrations. + +## Configuration + +| Variable | Default | Purpose | +|---|---:|---| +| `HOST_LIBRARY_PATH` | required | Host folder bound read-only to `/library`; for example `E:/SwitchDrive`. | +| `LIBRARY_PATH` | `/library` | Initial path inside the container. The panel can later select a descendant. | +| `HOME_STORAGE_PORT` | `8080` | HTTP and advertised LAN port. | +| `DISCOVERY_PORT` | `8080` | UDP broadcast discovery port. | +| `INSTANCE_NAME` | `Home Storage` | Name shown on the Switch. | +| `SCAN_INTERVAL_MINUTES` | `5` | Periodic scan interval. | +| `MAX_CONCURRENT_DOWNLOADS` | `4` | Bound on simultaneously open download streams. | +| `SETUP_TOKEN` | required | One-time bootstrap secret; it is never logged. | +| `CLOUDFLARE_TUNNEL_TOKEN` | unset | Token used only by the optional tunnel profile. | + +Supported extensions are `.nro`, `.nsp`, `.nsz`, `.xci`, and `.zip`. New or changed files are hashed with SHA-256 using bounded memory. Symlinks and reparse points are skipped. Missing files become inactive; suppressed files stay hidden until restored by the administrator. + +## Authentication + +Protected access is the default. The Switch exchanges the configured library username/password once at `POST /api/v1/auth/token` and stores only the revocable bearer token. The administrator password, library password and bearer tokens are never stored in plaintext by the service. Anonymous mode allows listing and downloads but not hiding catalog entries. + +Plain HTTP should be used only on a trusted LAN. Always use HTTPS when accessing the service over the Internet. + +## Test locally + +```sh +curl http://localhost:8080/health +curl http://localhost:8080/drive-health +curl -u switch-user:password -X POST http://localhost:8080/api/v1/auth/token +curl -H "Authorization: Bearer TOKEN" "http://localhost:8080/api/v1/catalog?parentId=root" +curl -I -H "Authorization: Bearer TOKEN" http://localhost:8080/api/v1/files/FILE_ID/content +curl -H "Authorization: Bearer TOKEN" -H "Range: bytes=1048576-2097151" -o part.bin http://localhost:8080/api/v1/files/FILE_ID/content +``` + +A valid range returns `206 Partial Content`, `Content-Length`, `Content-Range`, `Accept-Ranges`, `ETag`, and `Last-Modified`. Invalid ranges return `416`. Do not modify a file while it is being downloaded; a detected size or timestamp change schedules reindexing and prevents use of stale metadata. + +Run tests entirely in Docker: + +```sh +docker build --target test -t switch-drive-home-storage-tests . +``` + +## Cloudflare Tunnel / CGNAT + +Create a remotely managed HTTP tunnel in Cloudflare and route its public hostname to `http://home-storage:8080`, then set the token in `.env` and run: + +```sh +docker compose --profile tunnel up -d +``` + +Cloudflare Tunnel is outbound-only, so it works behind CGNAT without exposing an inbound router port. Require Home Storage authentication for a public hostname. Do not place an interactive Cloudflare Access login page in front of the Switch API, and never commit the tunnel token. +If `HOME_STORAGE_PORT` is changed, use that same internal port in the tunnel route. + +## Switch integration + +The Switch can enter a LAN address or public HTTPS hostname manually. “Detect on network” broadcasts `SWITCHDRIVE_HOME_DISCOVER_V1` over UDP 8080 and validates replies with `/drive-health`. Discovery is LAN-only and can be blocked by client isolation; manual configuration remains available. + +The public API is documented in [`openapi.yaml`](openapi.yaml). NSP/NSZ metadata columns and an extractor interface exist for later CNMT/NACP/icon parsing; this release indexes only file metadata. diff --git a/home-storage/compose.yaml b/home-storage/compose.yaml new file mode 100644 index 0000000..9e1847f --- /dev/null +++ b/home-storage/compose.yaml @@ -0,0 +1,44 @@ +services: + home-storage: + build: + context: . + target: final + env_file: + - path: .env + required: false + environment: + LIBRARY_MOUNT_ROOT: /library + DATA_PATH: /data + SETUP_TOKEN: ${SETUP_TOKEN:?set SETUP_TOKEN in .env} + ports: + - "${HOME_STORAGE_PORT:-8080}:${HOME_STORAGE_PORT:-8080}/tcp" + - "${DISCOVERY_PORT:-8080}:${DISCOVERY_PORT:-8080}/udp" + volumes: + - type: bind + source: ${HOST_LIBRARY_PATH:?set HOST_LIBRARY_PATH in .env} + target: /library + read_only: true + - type: volume + source: home-storage-data + target: /data + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:${HOME_STORAGE_PORT:-8080}/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + restart: unless-stopped + + cloudflared: + image: cloudflare/cloudflared:latest + profiles: ["tunnel"] + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} + depends_on: + home-storage: + condition: service_healthy + restart: unless-stopped + +volumes: + home-storage-data: diff --git a/home-storage/openapi.yaml b/home-storage/openapi.yaml new file mode 100644 index 0000000..448f102 --- /dev/null +++ b/home-storage/openapi.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: {title: Switch Drive Home Storage API, version: 1.0.0} +paths: + /health: + get: {summary: Container health, responses: {'200': {description: Healthy or degraded}}} + /drive-health: + get: {summary: Discovery metadata, responses: {'200': {description: Home Storage identity}}} + /api/v1/auth/token: + post: + summary: Exchange library Basic credentials for a revocable bearer token + security: [{basicAuth: []}] + responses: {'200': {description: Token issued}, '401': {description: Invalid credentials}, '429': {description: Rate limited}} + /api/v1/catalog: + get: + summary: List children of an opaque folder ID + security: [{bearerAuth: []}, {}] + parameters: + - {in: query, name: parentId, schema: {type: string, default: root}} + - {in: query, name: cursor, schema: {type: string}} + - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 200}} + responses: {'200': {description: Catalog page}, '401': {description: Authentication required}} + /api/v1/files/{fileId}/content: + parameters: [{in: path, name: fileId, required: true, schema: {type: string}}] + get: + summary: Stream a file with byte-range and If-Range support + security: [{bearerAuth: []}, {}] + responses: {'200': {description: Full file}, '206': {description: Partial content}, '404': {description: Unavailable ID}, '409': {description: File changed}, '416': {description: Unsatisfiable range}, '429': {description: Concurrency limit}} + head: + summary: Return metadata without a response body + security: [{bearerAuth: []}, {}] + responses: {'200': {description: Metadata headers}} + /api/v1/catalog/{id}: + delete: + summary: Suppress an entry without deleting the host file + security: [{bearerAuth: []}] + parameters: [{in: path, name: id, required: true, schema: {type: string}}] + responses: {'204': {description: Suppressed}, '403': {description: Missing scope}} +components: + securitySchemes: + basicAuth: {type: http, scheme: basic} + bearerAuth: {type: http, scheme: bearer} diff --git a/home-storage/src/HomeStorage.Api/AdminPanel.cs b/home-storage/src/HomeStorage.Api/AdminPanel.cs new file mode 100644 index 0000000..446f7f8 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/AdminPanel.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; + +namespace HomeStorage; + +public static class AdminPanel +{ + public static void Map(WebApplication app, RuntimeOptions runtime) + { + app.MapGet("/", async (HomeStorageDb db) => await db.Admins.AnyAsync() ? Results.Redirect("/admin") : Results.Redirect("/setup")); + app.MapGet("/setup", async (HttpContext ctx, HomeStorageDb db, IAntiforgery anti) => + { + if (await db.Admins.AnyAsync()) return Results.Redirect("/login"); + var settings = await db.Settings.AsNoTracking().SingleAsync(); + return Page("Initial setup", SetupForm(anti.GetAndStoreTokens(ctx).RequestToken!, settings)); + }); + app.MapPost("/setup", async (HttpContext ctx, HomeStorageDb db, AuthService auth, LibraryPathPolicy paths, IAntiforgery anti) => + { + if (await db.Admins.AnyAsync()) return Results.NotFound(); + try { await anti.ValidateRequestAsync(ctx); } catch { return Results.BadRequest("Invalid anti-forgery token."); } + var f = await ctx.Request.ReadFormAsync(); + if (!SecretEquals(runtime.SetupToken, f["setupToken"].ToString())) return Results.BadRequest("Invalid setup token."); + var admin = f["admin"].ToString(); var adminPassword = f["adminPassword"].ToString(); var user = f["libraryUser"].ToString(); var password = f["libraryPassword"].ToString(); var library = f["libraryPath"].ToString(); + if (admin.Length < 3 || adminPassword.Length < 12 || user.Length < 3 || password.Length < 12) return Results.BadRequest("Usernames need 3 characters and passwords need 12 characters."); + if (!paths.TryValidateLibraryPath(library, out var canonical, out var error)) return Results.BadRequest(error); + db.Admins.Add(new() { Username = admin, PasswordHash = auth.HashPassword(admin, adminPassword) }); + db.LibraryCredentials.Add(new() { Username = user, PasswordHash = auth.HashPassword(user, password), AllowCatalogManage = f["allowManage"] == "on" }); + var settings = await db.Settings.SingleAsync(); settings.InstanceName = Clean(f["instanceName"], "Home Storage"); settings.LibraryPath = canonical; settings.AuthRequired = f["anonymous"] != "on"; + await db.SaveChangesAsync(); return Results.Redirect("/login"); + }).RequireRateLimiting("auth"); + + app.MapGet("/login", async (HttpContext ctx, IAntiforgery anti) => Page("Login", $"
{Token(anti.GetAndStoreTokens(ctx).RequestToken!)}
")); + app.MapPost("/login", async (HttpContext ctx, HomeStorageDb db, AuthService auth, IAntiforgery anti, ILogger logger) => + { + try { await anti.ValidateRequestAsync(ctx); } catch { return Results.BadRequest(); } + var f = await ctx.Request.ReadFormAsync(); var user = f["user"].ToString(); var admin = await db.Admins.SingleOrDefaultAsync(); + if (admin is null || admin.Username != user || !auth.VerifyPassword(user, admin.PasswordHash, f["password"].ToString())) { logger.LogWarning("Administrative login failed from {RemoteIp}", ctx.Connection.RemoteIpAddress); return Results.Unauthorized(); } + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, user) }, CookieAuthenticationDefaults.AuthenticationScheme))); + return Results.Redirect("/admin"); + }).RequireRateLimiting("auth"); + app.MapPost("/logout", async (HttpContext ctx, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); await ctx.SignOutAsync(); return Results.Redirect("/login"); }).RequireAuthorization(); + + app.MapGet("/admin", async (HttpContext ctx, HomeStorageDb db, IAntiforgery anti) => + { + var settings = await db.Settings.AsNoTracking().SingleAsync(); + var files = await db.Catalog.CountAsync(x => x.Kind == CatalogKind.File && x.Active && !x.Suppressed); var hidden = await db.Catalog.CountAsync(x => x.Suppressed); var tokenCount = await db.DeviceTokens.CountAsync(x => x.RevokedAt == null); + var catalog = await db.Catalog.AsNoTracking().OrderBy(x => x.RelativePath).Take(500).ToListAsync(); var devices = await db.DeviceTokens.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(100).ToListAsync(); var csrf = anti.GetAndStoreTokens(ctx).RequestToken!; + var body = $"

Status: {H(settings.LastScanStatus)} · Files: {files} · Hidden: {hidden} · Devices: {tokenCount}

Library: {H(settings.LibraryPath)}

" + + $"
{Token(csrf)}
" + + $"
{Token(csrf)}
" + + $"

Rotate Switch credentials

{Token(csrf)}
" + + $"

Catalog

Showing at most 500 entries. Hiding changes only SQLite; the physical file is never deleted.

{CatalogRows(catalog, csrf)}

Device tokens

{TokenRows(devices, csrf)}" + + $"

Catalog JSON · Device tokens JSON

{Token(csrf)}
"; + return Page("Home Storage", body); + }).RequireAuthorization(); + + app.MapPost("/admin/scan", async (HttpContext ctx, IAntiforgery anti, ScanTrigger trigger) => { await anti.ValidateRequestAsync(ctx); trigger.Request(); return Results.Redirect("/admin"); }).RequireAuthorization(); + app.MapPost("/admin/settings", async (HttpContext ctx, HomeStorageDb db, LibraryPathPolicy paths, IAntiforgery anti, ScanTrigger trigger) => { await anti.ValidateRequestAsync(ctx); var f = await ctx.Request.ReadFormAsync(); if (!paths.TryValidateLibraryPath(f["path"].ToString(), out var path, out var error)) return Results.BadRequest(error); var s = await db.Settings.SingleAsync(); s.InstanceName = Clean(f["name"], "Home Storage"); s.LibraryPath = path; s.AuthRequired = f["anonymous"] != "on"; await db.SaveChangesAsync(); trigger.Request(); return Results.Redirect("/admin"); }).RequireAuthorization(); + app.MapPost("/admin/library-credentials", async (HttpContext ctx, HomeStorageDb db, AuthService auth, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var f = await ctx.Request.ReadFormAsync(); var user = f["user"].ToString(); var password = f["password"].ToString(); if (user.Length < 3 || password.Length < 12) return Results.BadRequest("Username needs 3 characters and password needs 12 characters."); var credential = await db.LibraryCredentials.SingleAsync(); credential.Username = user; credential.PasswordHash = auth.HashPassword(user, password); credential.AllowCatalogManage = f["allowManage"] == "on"; credential.Version++; foreach (var token in await db.DeviceTokens.Where(x => x.RevokedAt == null).ToListAsync()) token.RevokedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return Results.Redirect("/admin"); }).RequireAuthorization(); + app.MapPost("/admin/catalog/{id}/hide", async (HttpContext ctx, string id, HomeStorageDb db, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var e = await db.Catalog.SingleOrDefaultAsync(x => x.Id == id); if (e is null) return Results.NotFound(); await SetSuppressed(db, e, true); return Results.Redirect("/admin"); }).RequireAuthorization(); + app.MapPost("/admin/catalog/{id}/restore", async (HttpContext ctx, string id, HomeStorageDb db, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var e = await db.Catalog.SingleOrDefaultAsync(x => x.Id == id); if (e is null) return Results.NotFound(); await SetSuppressed(db, e, false); return Results.Redirect("/admin"); }).RequireAuthorization(); + app.MapPost("/admin/tokens/{id}/revoke", async (HttpContext ctx, string id, HomeStorageDb db, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var token = await db.DeviceTokens.SingleOrDefaultAsync(x => x.Id == id); if (token is null) return Results.NotFound(); token.RevokedAt ??= DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return Results.Redirect("/admin"); }).RequireAuthorization(); + + app.MapGet("/api/admin/status", async (HomeStorageDb db) => Results.Json(await db.Settings.AsNoTracking().SingleAsync())).RequireAuthorization(); + app.MapGet("/api/admin/catalog", async (HomeStorageDb db) => Results.Json(await db.Catalog.AsNoTracking().OrderBy(x => x.RelativePath).Select(x => new { x.Id, x.Kind, x.RelativePath, x.Size, x.Active, x.Suppressed, x.ETag }).ToListAsync())).RequireAuthorization(); + app.MapGet("/api/admin/tokens", async (HomeStorageDb db) => Results.Json(await db.DeviceTokens.AsNoTracking().Select(x => new { x.Id, x.Name, x.CanManageCatalog, x.CreatedAt, x.LastUsedAt, x.RevokedAt }).ToListAsync())).RequireAuthorization(); + app.MapPost("/api/admin/catalog/{id}/restore", async (HttpContext ctx, string id, HomeStorageDb db, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var e = await db.Catalog.SingleOrDefaultAsync(x => x.Id == id); if (e is null) return Results.NotFound(); await SetSuppressed(db, e, false); return Results.NoContent(); }).RequireAuthorization(); + app.MapDelete("/api/admin/tokens/{id}", async (HttpContext ctx, string id, HomeStorageDb db, IAntiforgery anti) => { await anti.ValidateRequestAsync(ctx); var token = await db.DeviceTokens.SingleOrDefaultAsync(x => x.Id == id); if (token is null) return Results.NotFound(); token.RevokedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return Results.NoContent(); }).RequireAuthorization(); + } + + private static string SetupForm(string token, ServiceSettings settings) => $"
{Token(token)}
"; + private static string CatalogRows(IEnumerable entries, string csrf) => "
" + string.Concat(entries.Select(e => $"")) + "
PathStateSizeAction
{H(e.RelativePath)}{(e.Suppressed ? "hidden" : e.Active ? "active" : "inactive")}{e.Size}
{Token(csrf)}
"; + private static string TokenRows(IEnumerable entries, string csrf) => "
" + string.Concat(entries.Select(e => $"")) + "
DeviceCreatedLast usedState
{H(e.Name)}{H(e.CreatedAt)}{H(e.LastUsedAt)}{(e.RevokedAt is null ? $"
{Token(csrf)}
" : "revoked")}
"; + private static IResult Page(string title, string body) => Results.Content($"{H(title)}

{H(title)}

{body}", "text/html; charset=utf-8"); + private static string Token(string value) => $""; + private static string H(object? value) => WebUtility.HtmlEncode(value?.ToString() ?? ""); + private static string Clean(object value, string fallback) { var text = value.ToString()?.Trim(); return string.IsNullOrEmpty(text) ? fallback : text[..Math.Min(text.Length, 100)]; } + private static bool SecretEquals(string expected, string actual) { if (string.IsNullOrEmpty(expected)) return false; var a = Encoding.UTF8.GetBytes(expected); var b = Encoding.UTF8.GetBytes(actual); return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b); } + private static async Task SetSuppressed(HomeStorageDb db, CatalogEntry entry, bool suppressed) { if (entry.Kind == CatalogKind.Folder) { var prefix = entry.RelativePath + "/"; foreach (var child in await db.Catalog.Where(x => x.RelativePath == entry.RelativePath || x.RelativePath.StartsWith(prefix)).ToListAsync()) child.Suppressed = suppressed; } else entry.Suppressed = suppressed; await db.SaveChangesAsync(); } +} diff --git a/home-storage/src/HomeStorage.Api/AuthService.cs b/home-storage/src/HomeStorage.Api/AuthService.cs new file mode 100644 index 0000000..6ac55b7 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/AuthService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace HomeStorage; + +public sealed record ApiIdentity(bool Authenticated, bool CanManageCatalog, string? TokenId); +public sealed class AuthService(IDbContextFactory factory, IPasswordHasher hasher) +{ + public string HashPassword(string username, string password) => hasher.HashPassword(username, password); + public bool VerifyPassword(string username, string hash, string password) => hasher.VerifyHashedPassword(username, hash, password) != PasswordVerificationResult.Failed; + + public async Task<(string? Token, bool CanManage, string? Error)> ExchangeAsync(string username, string password, CancellationToken ct) + { + await using var db = await factory.CreateDbContextAsync(ct); var credential = await db.LibraryCredentials.SingleOrDefaultAsync(ct); + if (credential is null || !FixedEquals(credential.Username, username) || !VerifyPassword(credential.Username, credential.PasswordHash, password)) return (null, false, "invalid_credentials"); + var token = Base64Url(RandomNumberGenerator.GetBytes(32)); db.DeviceTokens.Add(new() { TokenHash = HashToken(token), CanManageCatalog = credential.AllowCatalogManage, CredentialVersion = credential.Version }); await db.SaveChangesAsync(ct); return (token, credential.AllowCatalogManage, null); + } + public async Task AuthenticateAsync(HttpContext context, bool allowAnonymous, CancellationToken ct) + { + await using var db = await factory.CreateDbContextAsync(ct); var settings = await db.Settings.SingleAsync(ct); + if (!settings.AuthRequired && allowAnonymous) return new(false, false, null); + var header = context.Request.Headers.Authorization.ToString(); if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return null; + var raw = header[7..].Trim(); if (raw.Length < 32) return null; var hash = HashToken(raw); + var token = await db.DeviceTokens.SingleOrDefaultAsync(x => x.TokenHash == hash && x.RevokedAt == null, ct); var credential = await db.LibraryCredentials.SingleOrDefaultAsync(ct); + if (token is null || credential is null || token.CredentialVersion != credential.Version) return null; + token.LastUsedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); return new(true, token.CanManageCatalog, token.Id); + } + public static bool TryReadBasic(HttpRequest request, out string username, out string password) + { + username = password = ""; var header = request.Headers.Authorization.ToString(); if (!header.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) return false; + try { var text = Encoding.UTF8.GetString(Convert.FromBase64String(header[6..].Trim())); var split = text.IndexOf(':'); if (split <= 0) return false; username = text[..split]; password = text[(split + 1)..]; return username.Length <= 128 && password.Length <= 1024; } catch { return false; } + } + public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); + private static bool FixedEquals(string a, string b) { var x = Encoding.UTF8.GetBytes(a); var y = Encoding.UTF8.GetBytes(b); return x.Length == y.Length && CryptographicOperations.FixedTimeEquals(x, y); } + private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/home-storage/src/HomeStorage.Api/CatalogIndexer.cs b/home-storage/src/HomeStorage.Api/CatalogIndexer.cs new file mode 100644 index 0000000..309a0e9 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/CatalogIndexer.cs @@ -0,0 +1,80 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; + +namespace HomeStorage; + +public sealed class ScanTrigger +{ + private readonly SemaphoreSlim signal = new(0, 1); + public void Request() { if (signal.CurrentCount == 0) signal.Release(); } + public Task WaitAsync(CancellationToken ct) => signal.WaitAsync(ct); +} + +public sealed class CatalogIndexer(IDbContextFactory factory, LibraryPathPolicy paths, MetadataPipeline metadata, ScanState state, ILogger logger) +{ + private static readonly HashSet Supported = new(StringComparer.OrdinalIgnoreCase) { ".nro", ".nsp", ".nsz", ".xci", ".zip" }; + public async Task ScanAsync(CancellationToken ct) + { + if (!state.TryBegin()) return; var scanId = Guid.NewGuid().ToString("N"); + try + { + await using var db = await factory.CreateDbContextAsync(ct); var settings = await db.Settings.SingleAsync(ct); settings.LastScanStatus = "scanning"; settings.LastScanError = null; await db.SaveChangesAsync(ct); + if (!paths.TryValidateLibraryPath(settings.LibraryPath, out var root, out var error)) throw new IOException(error); + logger.LogInformation("Library scan {ScanId} started at {LibraryPath}", scanId, settings.LibraryPath); + var directories = new List(); var files = new List(); Walk(root, root, directories, files); + var byPath = await db.Catalog.ToDictionaryAsync(x => x.RelativePath, StringComparer.Ordinal, ct); var seen = new HashSet(StringComparer.Ordinal); var parentIds = new Dictionary(StringComparer.Ordinal) { { "", "root" } }; + foreach (var directory in directories.OrderBy(x => x.Count(c => c == '/'))) + { + ct.ThrowIfCancellationRequested(); var relative = Relative(root, directory); var parent = Parent(relative); var entry = byPath.GetValueOrDefault(relative); + if (entry is null) { entry = new() { Kind = CatalogKind.Folder, RelativePath = relative, Name = Path.GetFileName(directory) }; db.Catalog.Add(entry); byPath[relative] = entry; logger.LogInformation("Folder indexed {RelativePath}", relative); } + entry.Kind = CatalogKind.Folder; entry.ParentId = parent.Length == 0 ? null : parentIds[parent]; entry.Active = true; entry.LastSeenScanId = scanId; parentIds[relative] = entry.Id; seen.Add(relative); + } + await db.SaveChangesAsync(ct); + foreach (var file in files) + { + ct.ThrowIfCancellationRequested(); var info = new FileInfo(file); var relative = Relative(root, file); var parent = Parent(relative); var extension = info.Extension.ToLowerInvariant(); var entry = byPath.GetValueOrDefault(relative); + var changed = entry is null || entry.Size != info.Length || entry.ModifiedUtcTicks != info.LastWriteTimeUtc.Ticks || string.IsNullOrEmpty(entry.Sha256); + string? hash = null; if (changed) hash = await HashAsync(file, ct); + if (entry is null && hash is not null) + { + var candidates = await db.Catalog.Where(x => x.Kind == CatalogKind.File && x.RelativePath != relative && x.Size == info.Length && x.Sha256 == hash).ToListAsync(ct); + var unique = candidates.Where(x => !seen.Contains(x.RelativePath) && !File.Exists(Path.Combine(root, x.RelativePath))).ToList(); + if (unique.Count == 1) { entry = unique[0]; byPath.Remove(entry.RelativePath); entry.RelativePath = relative; byPath[relative] = entry; logger.LogInformation("File moved, retaining {FileId}: {RelativePath}", entry.Id, relative); } + } + if (entry is null) { entry = new() { Kind = CatalogKind.File, RelativePath = relative }; db.Catalog.Add(entry); byPath[relative] = entry; logger.LogInformation("File found {FileId}: {RelativePath}", entry.Id, relative); } + entry.Kind = CatalogKind.File; entry.ParentId = parent.Length == 0 ? null : parentIds.GetValueOrDefault(parent); entry.Name = info.Name; entry.Extension = extension; entry.Size = info.Length; entry.ModifiedUtcTicks = info.LastWriteTimeUtc.Ticks; + if (hash is not null) { entry.Sha256 = hash; entry.ETag = "\"sha256-" + hash + "\""; await metadata.ExtractAsync(entry, file, ct); } + entry.Active = true; entry.LastSeenScanId = scanId; seen.Add(relative); + } + await db.SaveChangesAsync(ct); + foreach (var entry in await db.Catalog.Where(x => x.LastSeenScanId != scanId && x.Active).ToListAsync(ct)) { entry.Active = false; logger.LogInformation("Catalog entry removed {FileId}: {RelativePath}", entry.Id, entry.RelativePath); } + settings.LastScanAt = DateTimeOffset.UtcNow; settings.LastScanStatus = "ready"; settings.LastScanError = null; await db.SaveChangesAsync(ct); + logger.LogInformation("Library scan {ScanId} completed with {Files} files", scanId, files.Count); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) { logger.LogError(ex, "Library scan {ScanId} failed", scanId); await using var db = await factory.CreateDbContextAsync(CancellationToken.None); var settings = await db.Settings.SingleAsync(); settings.LastScanAt = DateTimeOffset.UtcNow; settings.LastScanStatus = "degraded"; settings.LastScanError = ex.Message; await db.SaveChangesAsync(); } + finally { state.End(); } + } + private void Walk(string root, string directory, List directories, List files) + { + string[] entries; try { entries = Directory.GetFileSystemEntries(directory); } catch (Exception ex) { logger.LogError(ex, "Cannot enumerate {RelativePath}", Relative(root, directory)); return; } + foreach (var path in entries) { var relative = Relative(root, path); if (relative.Contains('\\')) { logger.LogWarning("Skipping path with an invalid separator {RelativePath}", relative); continue; } if (paths.IsReparsePoint(path)) { logger.LogWarning("Skipping symbolic link or reparse point {RelativePath}", relative); continue; } if (Directory.Exists(path)) { directories.Add(path); Walk(root, path, directories, files); } else if (File.Exists(path) && Supported.Contains(Path.GetExtension(path))) files.Add(path); } + } + private static string Relative(string root, string path) => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/'); + private static string Parent(string path) { var index = path.LastIndexOf('/'); return index < 0 ? "" : path[..index]; } + private static async Task HashAsync(string path, CancellationToken ct) { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var hash = await SHA256.HashDataAsync(stream, ct); return Convert.ToHexString(hash).ToLowerInvariant(); } +} + +public sealed class CatalogIndexerService(CatalogIndexer indexer, ScanTrigger trigger, RuntimeOptions options) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await indexer.ScanAsync(stoppingToken); + while (!stoppingToken.IsCancellationRequested) + { + using var wakeup = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + var delay = Task.Delay(TimeSpan.FromMinutes(options.ScanIntervalMinutes), wakeup.Token); var requested = trigger.WaitAsync(wakeup.Token); + try { await Task.WhenAny(delay, requested); wakeup.Cancel(); try { await Task.WhenAll(delay, requested); } catch (OperationCanceledException) { } await indexer.ScanAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } + } + } +} diff --git a/home-storage/src/HomeStorage.Api/DiscoveryService.cs b/home-storage/src/HomeStorage.Api/DiscoveryService.cs new file mode 100644 index 0000000..ac3be62 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/DiscoveryService.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +namespace HomeStorage; + +public sealed class DiscoveryService(IDbContextFactory factory, RuntimeOptions options, ILogger logger) : BackgroundService +{ + private static readonly byte[] Probe = Encoding.ASCII.GetBytes("SWITCHDRIVE_HOME_DISCOVER_V1"); + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var udp = new UdpClient(new IPEndPoint(IPAddress.Any, options.DiscoveryPort)); logger.LogInformation("LAN discovery listening on UDP {Port}", options.DiscoveryPort); + while (!ct.IsCancellationRequested) + { + try + { + var received = await udp.ReceiveAsync(ct); if (!received.Buffer.AsSpan().SequenceEqual(Probe)) continue; + await using var db = await factory.CreateDbContextAsync(ct); var settings = await db.Settings.SingleAsync(ct); + var payload = JsonSerializer.SerializeToUtf8Bytes(new { service = "switch-drive-home-storage", protocolVersion = 1, instanceId = settings.InstanceId, name = settings.InstanceName, httpPort = options.HttpPort, authRequired = settings.AuthRequired }); + await udp.SendAsync(payload, received.RemoteEndPoint, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) { logger.LogError(ex, "LAN discovery error"); } + } + } +} diff --git a/home-storage/src/HomeStorage.Api/DownloadGate.cs b/home-storage/src/HomeStorage.Api/DownloadGate.cs new file mode 100644 index 0000000..3ae7a58 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/DownloadGate.cs @@ -0,0 +1,18 @@ +namespace HomeStorage; + +public sealed class DownloadGate(RuntimeOptions options) +{ + private readonly SemaphoreSlim semaphore = new(options.MaxConcurrentDownloads, options.MaxConcurrentDownloads); + public async Task OpenAsync(string path, CancellationToken ct) + { + if (!await semaphore.WaitAsync(TimeSpan.Zero, ct)) return null; + try { return new LeaseStream(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan), semaphore); } catch { semaphore.Release(); throw; } + } + private sealed class LeaseStream(Stream inner, SemaphoreSlim lease) : Stream + { + private int disposed; public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => false; public override long Length => inner.Length; public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); public override int Read(byte[] b, int o, int c) => inner.Read(b, o, c); public override long Seek(long o, SeekOrigin s) => inner.Seek(o, s); public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); public override ValueTask ReadAsync(Memory b, CancellationToken ct = default) => inner.ReadAsync(b, ct); + protected override void Dispose(bool disposing) { if (Interlocked.Exchange(ref disposed, 1) == 0) { if (disposing) inner.Dispose(); lease.Release(); } base.Dispose(disposing); } + public override async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 0) { await inner.DisposeAsync(); lease.Release(); } GC.SuppressFinalize(this); } + } +} diff --git a/home-storage/src/HomeStorage.Api/HomeStorage.Api.csproj b/home-storage/src/HomeStorage.Api/HomeStorage.Api.csproj new file mode 100644 index 0000000..64d8ab6 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/HomeStorage.Api.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + true + + + + + + diff --git a/home-storage/src/HomeStorage.Api/LibraryPathPolicy.cs b/home-storage/src/HomeStorage.Api/LibraryPathPolicy.cs new file mode 100644 index 0000000..abf6b09 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/LibraryPathPolicy.cs @@ -0,0 +1,41 @@ +namespace HomeStorage; + +public sealed class LibraryPathPolicy(RuntimeOptions options) +{ + private readonly string mountRoot = Canonical(options.LibraryMountRoot); + private readonly StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + public string MountRoot => mountRoot; + + public bool TryValidateLibraryPath(string value, out string canonical, out string error) + { + canonical = error = ""; + try { canonical = Canonical(value); } catch (Exception ex) { error = ex.Message; return false; } + if (!Contains(mountRoot, canonical)) { error = "Library path must be inside the mounted root."; return false; } + if (!Directory.Exists(canonical)) { error = "Library directory does not exist or is not readable."; return false; } + if (HasReparseComponent(mountRoot, canonical)) { error = "Symbolic links and reparse points are not allowed."; return false; } + return true; + } + + public bool TryResolve(string libraryPath, string relativePath, out string fullPath) + { + fullPath = ""; + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) || relativePath.Contains('\\') || relativePath.StartsWith('/') || relativePath.EndsWith('/') || relativePath.Contains("//", StringComparison.Ordinal)) return false; + var segments = relativePath.Split('/'); + if (segments.Length == 0 || segments.Any(x => x.Length == 0 || x is "." or "..")) return false; + if (!TryValidateLibraryPath(libraryPath, out var root, out _)) return false; + try { fullPath = Canonical(Path.Combine(root, Path.Combine(segments))); } catch { return false; } + return Contains(root, fullPath) && !HasReparseComponent(root, fullPath); + } + + public bool IsReparsePoint(string path) { try { return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; } catch { return true; } } + private bool Contains(string root, string candidate) => candidate.Equals(root, comparison) || candidate.StartsWith(root + Path.DirectorySeparatorChar, comparison); + private bool HasReparseComponent(string root, string candidate) + { + if (IsReparsePoint(root)) return true; + var relative = Path.GetRelativePath(root, candidate); if (relative == ".") return false; + var current = root; + foreach (var segment in relative.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)) { current = Path.Combine(current, segment); if ((Directory.Exists(current) || File.Exists(current)) && IsReparsePoint(current)) return true; } + return false; + } + private static string Canonical(string path) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); +} diff --git a/home-storage/src/HomeStorage.Api/Migrations/202609130001_Initial.cs b/home-storage/src/HomeStorage.Api/Migrations/202609130001_Initial.cs new file mode 100644 index 0000000..97dc783 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/Migrations/202609130001_Initial.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace HomeStorage.Migrations; + +[DbContext(typeof(HomeStorageDb))] +[Migration("202609130001_Initial")] +public sealed class Initial : Migration +{ + protected override void Up(MigrationBuilder m) + { + m.CreateTable("Admins", t => new { Id = t.Column(nullable: false), Username = t.Column(nullable: false), PasswordHash = t.Column(nullable: false) }, constraints: t => t.PrimaryKey("PK_Admins", x => x.Id)); + m.CreateTable("LibraryCredentials", t => new { Id = t.Column(nullable: false), Username = t.Column(nullable: false), PasswordHash = t.Column(nullable: false), AllowCatalogManage = t.Column(nullable: false), Version = t.Column(nullable: false) }, constraints: t => t.PrimaryKey("PK_LibraryCredentials", x => x.Id)); + m.CreateTable("Settings", t => new { Id = t.Column(nullable: false), InstanceId = t.Column(nullable: false), InstanceName = t.Column(nullable: false), LibraryPath = t.Column(nullable: false), AuthRequired = t.Column(nullable: false), LastScanAt = t.Column(nullable: true), LastScanStatus = t.Column(nullable: false), LastScanError = t.Column(nullable: true) }, constraints: t => t.PrimaryKey("PK_Settings", x => x.Id)); + m.CreateTable("DeviceTokens", t => new { Id = t.Column(nullable: false), TokenHash = t.Column(nullable: false), Name = t.Column(nullable: false), CanManageCatalog = t.Column(nullable: false), CredentialVersion = t.Column(nullable: false), CreatedAt = t.Column(nullable: false), LastUsedAt = t.Column(nullable: true), RevokedAt = t.Column(nullable: true) }, constraints: t => t.PrimaryKey("PK_DeviceTokens", x => x.Id)); + m.CreateTable("Catalog", t => new { Id = t.Column(nullable: false), ParentId = t.Column(nullable: true), Kind = t.Column(nullable: false), RelativePath = t.Column(nullable: false), Name = t.Column(nullable: false), Extension = t.Column(nullable: false), Size = t.Column(nullable: false), ModifiedUtcTicks = t.Column(nullable: false), Sha256 = t.Column(nullable: false), ETag = t.Column(nullable: false), Active = t.Column(nullable: false), Suppressed = t.Column(nullable: false), LastSeenScanId = t.Column(nullable: false), TitleId = t.Column(nullable: true), Title = t.Column(nullable: true), Publisher = t.Column(nullable: true), Version = t.Column(nullable: true), ContentType = t.Column(nullable: true), RequiredFirmware = t.Column(nullable: true), RelatedBaseTitleId = t.Column(nullable: true), IconRelativePath = t.Column(nullable: true) }, constraints: t => t.PrimaryKey("PK_Catalog", x => x.Id)); + m.CreateIndex("IX_Catalog_RelativePath", "Catalog", "RelativePath", unique: true); m.CreateIndex("IX_Catalog_ParentId_Active_Suppressed", "Catalog", new[] { "ParentId", "Active", "Suppressed" }); m.CreateIndex("IX_Catalog_Sha256_Size", "Catalog", new[] { "Sha256", "Size" }); m.CreateIndex("IX_DeviceTokens_TokenHash", "DeviceTokens", "TokenHash", unique: true); + } + protected override void Down(MigrationBuilder m) { m.DropTable("Admins"); m.DropTable("Catalog"); m.DropTable("DeviceTokens"); m.DropTable("LibraryCredentials"); m.DropTable("Settings"); } +} diff --git a/home-storage/src/HomeStorage.Api/Migrations/HomeStorageDbModelSnapshot.cs b/home-storage/src/HomeStorage.Api/Migrations/HomeStorageDbModelSnapshot.cs new file mode 100644 index 0000000..c67ecc0 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/Migrations/HomeStorageDbModelSnapshot.cs @@ -0,0 +1,222 @@ +// +using System; +using HomeStorage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace HomeStorage.Migrations +{ + [DbContext(typeof(HomeStorageDb))] + partial class HomeStorageDbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("HomeStorage.AdminCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("HomeStorage.CatalogEntry", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .HasColumnType("TEXT"); + + b.Property("ETag") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IconRelativePath") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenScanId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ModifiedUtcTicks") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("RelatedBaseTitleId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RequiredFirmware") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Suppressed") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RelativePath") + .IsUnique(); + + b.HasIndex("Sha256", "Size"); + + b.HasIndex("ParentId", "Active", "Suppressed"); + + b.ToTable("Catalog"); + }); + + modelBuilder.Entity("HomeStorage.DeviceToken", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CanManageCatalog") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CredentialVersion") + .HasColumnType("INTEGER"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("HomeStorage.LibraryCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowCatalogManage") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("LibraryCredentials"); + }); + + modelBuilder.Entity("HomeStorage.ServiceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthRequired") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastScanAt") + .HasColumnType("TEXT"); + + b.Property("LastScanError") + .HasColumnType("TEXT"); + + b.Property("LastScanStatus") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LibraryPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/home-storage/src/HomeStorage.Api/Models.cs b/home-storage/src/HomeStorage.Api/Models.cs new file mode 100644 index 0000000..7de68d2 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/Models.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; + +namespace HomeStorage; + +public enum CatalogKind { Folder, File } + +public sealed class CatalogEntry +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string? ParentId { get; set; } + public CatalogKind Kind { get; set; } + public string RelativePath { get; set; } = ""; + public string Name { get; set; } = ""; + public string Extension { get; set; } = ""; + public long Size { get; set; } + public long ModifiedUtcTicks { get; set; } + public string Sha256 { get; set; } = ""; + public string ETag { get; set; } = ""; + public bool Active { get; set; } = true; + public bool Suppressed { get; set; } + public string LastSeenScanId { get; set; } = ""; + public string? TitleId { get; set; } + public string? Title { get; set; } + public string? Publisher { get; set; } + public long? Version { get; set; } + public string? ContentType { get; set; } + public long? RequiredFirmware { get; set; } + public string? RelatedBaseTitleId { get; set; } + public string? IconRelativePath { get; set; } +} + +public sealed class ServiceSettings +{ + public int Id { get; set; } = 1; + public string InstanceId { get; set; } = Guid.NewGuid().ToString("N"); + public string InstanceName { get; set; } = "Home Storage"; + public string LibraryPath { get; set; } = "/library"; + public bool AuthRequired { get; set; } = true; + public DateTimeOffset? LastScanAt { get; set; } + public string LastScanStatus { get; set; } = "pending"; + public string? LastScanError { get; set; } +} + +public sealed class AdminCredential { public int Id { get; set; } = 1; public string Username { get; set; } = ""; public string PasswordHash { get; set; } = ""; } +public sealed class LibraryCredential { public int Id { get; set; } = 1; public string Username { get; set; } = ""; public string PasswordHash { get; set; } = ""; public bool AllowCatalogManage { get; set; } public int Version { get; set; } = 1; } +public sealed class DeviceToken +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string TokenHash { get; set; } = ""; + public string Name { get; set; } = "Nintendo Switch"; + public bool CanManageCatalog { get; set; } + public int CredentialVersion { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastUsedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} + +public sealed class HomeStorageDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Catalog => Set(); + public DbSet Settings => Set(); + public DbSet Admins => Set(); + public DbSet LibraryCredentials => Set(); + public DbSet DeviceTokens => Set(); + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity(e => { e.HasKey(x => x.Id); e.HasIndex(x => x.RelativePath).IsUnique(); e.HasIndex(x => new { x.ParentId, x.Active, x.Suppressed }); e.HasIndex(x => new { x.Sha256, x.Size }); e.Property(x => x.Kind).HasConversion(); }); + model.Entity().HasKey(x => x.Id); model.Entity().HasKey(x => x.Id); + model.Entity().HasKey(x => x.Id); model.Entity().HasKey(x => x.Id); + model.Entity().HasIndex(x => x.TokenHash).IsUnique(); + } +} + +public sealed record RuntimeOptions(string LibraryMountRoot, string InitialLibraryPath, string DataPath, int HttpPort, int DiscoveryPort, int ScanIntervalMinutes, int MaxConcurrentDownloads, string InitialInstanceName, string SetupToken) +{ + public static RuntimeOptions FromConfiguration(IConfiguration c) + { + static int N(IConfiguration c, string key, int fallback, int min, int max) => int.TryParse(c[key], out var value) ? Math.Clamp(value, min, max) : fallback; + return new(c["LIBRARY_MOUNT_ROOT"] ?? "/library", c["LIBRARY_PATH"] ?? "/library", c["DATA_PATH"] ?? "/data", N(c, "HOME_STORAGE_PORT", 8080, 1, 65535), N(c, "DISCOVERY_PORT", 8080, 1, 65535), N(c, "SCAN_INTERVAL_MINUTES", 5, 1, 1440), N(c, "MAX_CONCURRENT_DOWNLOADS", 4, 1, 32), c["INSTANCE_NAME"] ?? "Home Storage", c["SETUP_TOKEN"] ?? ""); + } +} + +public sealed class ScanState { private int scanning; public bool IsScanning => Volatile.Read(ref scanning) != 0; public bool TryBegin() => Interlocked.CompareExchange(ref scanning, 1, 0) == 0; public void End() => Volatile.Write(ref scanning, 0); } +public interface IFileMetadataExtractor { bool Supports(string extension); Task ExtractAsync(CatalogEntry entry, Stream stream, CancellationToken cancellationToken); } +public sealed class MetadataPipeline(IEnumerable extractors) +{ + public async Task ExtractAsync(CatalogEntry entry, string path, CancellationToken ct) { foreach (var x in extractors.Where(x => x.Supports(entry.Extension))) { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); await x.ExtractAsync(entry, stream, ct); } } +} diff --git a/home-storage/src/HomeStorage.Api/Program.cs b/home-storage/src/HomeStorage.Api/Program.cs new file mode 100644 index 0000000..4fb5d72 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/Program.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Net.Http.Headers; + +using HomeStorage; + +var builder = WebApplication.CreateBuilder(args); +var runtime = RuntimeOptions.FromConfiguration(builder.Configuration); +Directory.CreateDirectory(runtime.DataPath); +builder.WebHost.UseUrls($"http://0.0.0.0:{runtime.HttpPort}"); +builder.Services.AddSingleton(runtime); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddDbContextFactory(o => o.UseSqlite($"Data Source={Path.Combine(runtime.DataPath, "home-storage.db")}")); +builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddSingleton(); +if (!builder.Configuration.GetValue("DISABLE_BACKGROUND_SERVICES")) { builder.Services.AddHostedService(); builder.Services.AddHostedService(); } +builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(runtime.DataPath, "keys"))); +builder.Services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN"); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { o.LoginPath = "/login"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict; o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; o.ExpireTimeSpan = TimeSpan.FromHours(8); }); +builder.Services.AddAuthorization(); +builder.Services.AddRateLimiter(o => { o.AddFixedWindowLimiter("auth", x => { x.PermitLimit = 10; x.Window = TimeSpan.FromMinutes(1); x.QueueLimit = 0; }); o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); +var app = builder.Build(); +app.Use(async (ctx, next) => +{ + if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/drive-health")) + ctx.Response.OnStarting(() => { ctx.Response.Headers.CacheControl = "private, no-store"; return Task.CompletedTask; }); + await next(); +}); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); + +await using (var scope = app.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); + await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); + if (!await db.Settings.AnyAsync()) { db.Settings.Add(new() { InstanceName = runtime.InitialInstanceName, LibraryPath = runtime.InitialLibraryPath }); await db.SaveChangesAsync(); } +} +app.Logger.LogInformation("Home Storage starting on port {Port}; library mount root {Root}", runtime.HttpPort, runtime.LibraryMountRoot); + +app.MapGet("/health", async (HomeStorageDb db, ScanState scan) => { var s = await db.Settings.AsNoTracking().SingleAsync(); return Results.Json(new { status = s.LastScanStatus == "degraded" ? "degraded" : scan.IsScanning ? "scanning" : "ready", database = "ready", lastScanAt = s.LastScanAt }); }); +app.MapGet("/drive-health", async (HomeStorageDb db) => { var s = await db.Settings.AsNoTracking().SingleAsync(); return Results.Json(new { service = "switch-drive-home-storage", protocolVersion = 1, instanceId = s.InstanceId, name = s.InstanceName, httpPort = runtime.HttpPort, authRequired = s.AuthRequired }); }); + +app.MapPost("/api/v1/auth/token", async (HttpContext ctx, AuthService auth, ILogger log) => +{ + if (!AuthService.TryReadBasic(ctx.Request, out var username, out var password)) { log.LogWarning("Authorization failed from {RemoteIp}", ctx.Connection.RemoteIpAddress); return Results.Unauthorized(); } + var (token, canManage, error) = await auth.ExchangeAsync(username, password, ctx.RequestAborted); if (token is null) { log.LogWarning("Authorization failed from {RemoteIp}: {Reason}", ctx.Connection.RemoteIpAddress, error); return Results.Unauthorized(); } + var scopes = canManage ? new[] { "catalog:read", "files:read", "catalog:manage" } : new[] { "catalog:read", "files:read" }; return Results.Json(new { accessToken = token, tokenType = "Bearer", scopes }); +}).RequireRateLimiting("auth"); + +app.MapGet("/api/v1/catalog", async (HttpContext ctx, HomeStorageDb db, AuthService auth, ILogger log, string? parentId, string? cursor, int? limit) => +{ + var identity = await auth.AuthenticateAsync(ctx, true, ctx.RequestAborted); if (identity is null) { log.LogWarning("Authorization failed for catalog from {RemoteIp}", ctx.Connection.RemoteIpAddress); return Results.Unauthorized(); } + var take = Math.Clamp(limit ?? 100, 1, 200); var offset = Cursor.Decode(cursor); var parent = string.IsNullOrEmpty(parentId) || parentId == "root" ? null : parentId; + if (parent is not null && !await db.Catalog.AnyAsync(x => x.Id == parent && x.Kind == CatalogKind.Folder && x.Active && !x.Suppressed)) return Results.NotFound(); + var query = db.Catalog.AsNoTracking().Where(x => x.ParentId == parent && x.Active && !x.Suppressed).OrderBy(x => x.Kind == CatalogKind.File).ThenBy(x => x.Name); + var rows = await query.Skip(offset).Take(take + 1).ToListAsync(); var more = rows.Count > take; if (more) rows.RemoveAt(take); + return Results.Json(new { items = rows.Select(x => new { id = x.Id, parentId = x.ParentId ?? "root", kind = x.Kind == CatalogKind.File ? "file" : "folder", name = x.Name, extension = x.Extension, size = x.Size.ToString(), modifiedAt = new DateTimeOffset(x.ModifiedUtcTicks, TimeSpan.Zero), etag = x.ETag, sha256 = x.Sha256, canDownload = x.Kind == CatalogKind.File, canHide = identity.CanManageCatalog }), nextCursor = more ? Cursor.Encode(offset + take) : null }); +}); + +app.MapMethods("/api/v1/files/{fileId}/content", new[] { "GET", "HEAD" }, async (HttpContext ctx, string fileId, HomeStorageDb db, AuthService auth, LibraryPathPolicy paths, DownloadGate gate, ScanTrigger trigger, ILogger log) => +{ + var identity = await auth.AuthenticateAsync(ctx, true, ctx.RequestAborted); if (identity is null) { log.LogWarning("Authorization failed for download {FileId} from {RemoteIp}", fileId, ctx.Connection.RemoteIpAddress); return Results.Unauthorized(); } + var entry = await db.Catalog.AsNoTracking().SingleOrDefaultAsync(x => x.Id == fileId && x.Kind == CatalogKind.File && x.Active && !x.Suppressed); if (entry is null) return Results.NotFound(); + var settings = await db.Settings.AsNoTracking().SingleAsync(); if (!paths.TryResolve(settings.LibraryPath, entry.RelativePath, out var path) || !File.Exists(path)) return Results.NotFound(); + var info = new FileInfo(path); if (info.Length != entry.Size || info.LastWriteTimeUtc.Ticks != entry.ModifiedUtcTicks) { trigger.Request(); return Results.Conflict(); } + if (HttpMethods.IsHead(ctx.Request.Method)) + { + ctx.Response.StatusCode = StatusCodes.Status200OK; ctx.Response.ContentType = "application/octet-stream"; ctx.Response.ContentLength = entry.Size; + ctx.Response.Headers.AcceptRanges = "bytes"; ctx.Response.Headers.ETag = entry.ETag; ctx.Response.Headers.LastModified = new DateTimeOffset(entry.ModifiedUtcTicks, TimeSpan.Zero).ToString("R"); ctx.Response.Headers.CacheControl = "private, no-store"; + return Results.Empty; + } + Stream? stream; try { stream = await gate.OpenAsync(path, ctx.RequestAborted); } catch (Exception ex) { log.LogError(ex, "Filesystem error opening {FileId}", fileId); return Results.Problem(statusCode: 500); } + if (stream is null) return Results.StatusCode(StatusCodes.Status429TooManyRequests); + var range = ctx.Request.Headers.Range.ToString(); log.LogInformation("Download started {FileId} {Method} range {Range}", fileId, ctx.Request.Method, string.IsNullOrEmpty(range) ? "none" : range); + var cancellationLogged = 0; + ctx.Response.Headers.CacheControl = "private, no-store"; ctx.RequestAborted.Register(() => { if (Interlocked.Exchange(ref cancellationLogged, 1) == 0) log.LogWarning("Download canceled {FileId}", fileId); }); + ctx.Response.OnCompleted(() => + { + if (Volatile.Read(ref cancellationLogged) != 0) return Task.CompletedTask; + if (ctx.Response.StatusCode is >= 200 and < 300) log.LogInformation("Download completed {FileId} with status {Status}", fileId, ctx.Response.StatusCode); + else log.LogWarning("Download failed {FileId} with status {Status}", fileId, ctx.Response.StatusCode); + return Task.CompletedTask; + }); + return Results.File(stream, "application/octet-stream", entry.Name, new DateTimeOffset(entry.ModifiedUtcTicks, TimeSpan.Zero), EntityTagHeaderValue.Parse(entry.ETag), enableRangeProcessing: true); +}); + +app.MapDelete("/api/v1/catalog/{id}", async (HttpContext ctx, string id, HomeStorageDb db, AuthService auth, ILogger log) => +{ + var identity = await auth.AuthenticateAsync(ctx, false, ctx.RequestAborted); if (identity is null) { log.LogWarning("Authorization failed for catalog management from {RemoteIp}", ctx.Connection.RemoteIpAddress); return Results.Unauthorized(); } + if (!identity.CanManageCatalog) { log.LogWarning("Catalog management denied for token {TokenId}", identity.TokenId); return Results.Forbid(); } + var entry = await db.Catalog.SingleOrDefaultAsync(x => x.Id == id && x.Active); if (entry is null) return Results.NotFound(); + if (entry.Kind == CatalogKind.Folder) { var prefix = entry.RelativePath + "/"; foreach (var child in await db.Catalog.Where(x => x.RelativePath == entry.RelativePath || x.RelativePath.StartsWith(prefix)).ToListAsync()) child.Suppressed = true; } else entry.Suppressed = true; + await db.SaveChangesAsync(); return Results.NoContent(); +}); + +AdminPanel.Map(app, runtime); +app.Run(); + +public partial class Program { } + +static class Cursor +{ + public static int Decode(string? value) { if (string.IsNullOrEmpty(value)) return 0; try { var text = Encoding.ASCII.GetString(WebEncoders.Base64UrlDecode(value)); return int.TryParse(text, out var n) && n >= 0 ? n : 0; } catch { return 0; } } + public static string Encode(int value) => WebEncoders.Base64UrlEncode(Encoding.ASCII.GetBytes(value.ToString())); +} diff --git a/home-storage/src/HomeStorage.Api/appsettings.json b/home-storage/src/HomeStorage.Api/appsettings.json new file mode 100644 index 0000000..2e95688 --- /dev/null +++ b/home-storage/src/HomeStorage.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + } +} diff --git a/home-storage/tests/HomeStorage.Tests/ApiTests.cs b/home-storage/tests/HomeStorage.Tests/ApiTests.cs new file mode 100644 index 0000000..3d23521 --- /dev/null +++ b/home-storage/tests/HomeStorage.Tests/ApiTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using HomeStorage; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace HomeStorage.Tests; + +public sealed class ApiTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "home-storage-api-" + Guid.NewGuid().ToString("N")); + private readonly string data = Path.Combine(Path.GetTempPath(), "home-storage-data-" + Guid.NewGuid().ToString("N")); + public ApiTests() { Directory.CreateDirectory(root); Directory.CreateDirectory(data); } + + [Fact] + public async Task StreamsHeadAndRangesFromSparseLargeFile() + { + var length = OperatingSystem.IsWindows() ? 16L * 1024 * 1024 : 35L * 1024 * 1024 * 1024; + var path = Path.Combine(root, "large.bin"); await using (var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read)) { file.SetLength(length); file.Position = file.Length - 1; file.WriteByte(0x5a); } + await using var factory = Factory(); var client = factory.CreateClient(); + string id; + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var settings = await db.Settings.SingleAsync(); settings.AuthRequired = false; var info = new FileInfo(path); var entry = new CatalogEntry { Kind = CatalogKind.File, RelativePath = "large.bin", Name = "large.bin", Extension = ".bin", Size = info.Length, ModifiedUtcTicks = info.LastWriteTimeUtc.Ticks, Sha256 = new string('a', 64), ETag = "\"sha256-" + new string('a', 64) + "\"", Active = true }; id = entry.Id; db.Catalog.Add(entry); await db.SaveChangesAsync(); } + using var head = new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{id}/content"); using var headResponse = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.OK, headResponse.StatusCode); Assert.Equal(length, headResponse.Content.Headers.ContentLength); Assert.Empty(await headResponse.Content.ReadAsByteArrayAsync()); Assert.Contains("bytes", headResponse.Headers.AcceptRanges); Assert.NotNull(headResponse.Headers.ETag); Assert.NotNull(headResponse.Content.Headers.LastModified); var etag = headResponse.Headers.ETag!; + using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); request.Headers.Range = new RangeHeaderValue(length - 16, null); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); Assert.Equal(16, response.Content.Headers.ContentLength); Assert.Equal(length - 16, response.Content.Headers.ContentRange!.From); var bytes = await response.Content.ReadAsByteArrayAsync(); Assert.Equal(16, bytes.Length); Assert.Equal(0x5a, bytes[^1]); + using var suffix = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); suffix.Headers.Range = new RangeHeaderValue(null, 4); using var suffixResponse = await client.SendAsync(suffix, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, suffixResponse.StatusCode); Assert.Equal(4, suffixResponse.Content.Headers.ContentLength); Assert.Equal(length - 4, suffixResponse.Content.Headers.ContentRange!.From); + using var resumed = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); resumed.Headers.Range = new RangeHeaderValue(0, 7); resumed.Headers.IfRange = new RangeConditionHeaderValue(etag); using var resumedResponse = await client.SendAsync(resumed, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, resumedResponse.StatusCode); Assert.Equal(8, resumedResponse.Content.Headers.ContentLength); + using var invalid = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); invalid.Headers.Range = new RangeHeaderValue(length + 1024, null); using var invalidResponse = await client.SendAsync(invalid); Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, invalidResponse.StatusCode); + } + + [Fact] + public async Task StaleIfRangeFallsBackToAFullSmallResponse() + { + var path = Path.Combine(root, "small.zip"); await File.WriteAllBytesAsync(path, Enumerable.Range(0, 32).Select(x => (byte)x).ToArray(), TestContext.Current.CancellationToken); await using var factory = Factory(); var client = factory.CreateClient(); string id; + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); (await db.Settings.SingleAsync()).AuthRequired = false; var info = new FileInfo(path); var hash = new string('c', 64); var entry = new CatalogEntry { Kind = CatalogKind.File, RelativePath = "small.zip", Name = "small.zip", Extension = ".zip", Size = info.Length, ModifiedUtcTicks = info.LastWriteTimeUtc.Ticks, Sha256 = hash, ETag = "\"sha256-" + hash + "\"", Active = true }; id = entry.Id; db.Catalog.Add(entry); await db.SaveChangesAsync(); } + using var stale = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); stale.Headers.Range = new RangeHeaderValue(0, 7); stale.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"different\"")); using var response = await client.SendAsync(stale); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(32, response.Content.Headers.ContentLength); Assert.Equal(32, (await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)).Length); + } + + [Fact] + public async Task IssuesRevocableBearerAndEnforcesCatalogManageScope() + { + await using var factory = Factory(); var client = factory.CreateClient(); string id; + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var auth = scope.ServiceProvider.GetRequiredService(); db.LibraryCredentials.Add(new() { Username = "switch", PasswordHash = auth.HashPassword("switch", "correct-password"), AllowCatalogManage = true }); var entry = new CatalogEntry { Kind = CatalogKind.File, RelativePath = "hidden.bin", Name = "hidden.bin", Active = true }; id = entry.Id; db.Catalog.Add(entry); await db.SaveChangesAsync(); } + using var bad = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/token"); bad.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("switch:wrong-password"))); Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(bad)).StatusCode); + using var login = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/token"); login.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("switch:correct-password"))); using var loginResponse = await client.SendAsync(login); Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); var json = JsonDocument.Parse(await loginResponse.Content.ReadAsStringAsync()); var token = json.RootElement.GetProperty("accessToken").GetString(); Assert.False(string.IsNullOrEmpty(token)); Assert.Contains(json.RootElement.GetProperty("scopes").EnumerateArray(), x => x.GetString() == "catalog:manage"); + using var hide = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/catalog/{id}"); hide.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(hide)).StatusCode); + using (var scope2 = factory.Services.CreateScope()) { var db = scope2.ServiceProvider.GetRequiredService(); Assert.True((await db.Catalog.SingleAsync(x => x.Id == id)).Suppressed); var device = await db.DeviceTokens.SingleAsync(); Assert.NotEqual(token, device.TokenHash); device.RevokedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); } + using var afterRevoke = new HttpRequestMessage(HttpMethod.Get, "/api/v1/catalog?parentId=root"); afterRevoke.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(afterRevoke)).StatusCode); + } + + [Fact] + public async Task RefusesAFileThatChangedAfterIndexing() + { + var path = Path.Combine(root, "changed.nsp"); await File.WriteAllTextAsync(path, "before"); await using var factory = Factory(); var client = factory.CreateClient(); string id; + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); (await db.Settings.SingleAsync()).AuthRequired = false; var info = new FileInfo(path); var entry = new CatalogEntry { Kind = CatalogKind.File, RelativePath = "changed.nsp", Name = "changed.nsp", Extension = ".nsp", Size = info.Length, ModifiedUtcTicks = info.LastWriteTimeUtc.Ticks, Sha256 = new string('b', 64), ETag = "\"sha256-" + new string('b', 64) + "\"", Active = true }; id = entry.Id; db.Catalog.Add(entry); await db.SaveChangesAsync(); } + await File.AppendAllTextAsync(path, "-changed"); using var response = await client.GetAsync($"/api/v1/files/{id}/content"); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task DownloadGateKeepsTheConfiguredConcurrencyBound() + { + var path = Path.Combine(root, "gate.bin"); await File.WriteAllBytesAsync(path, new byte[16]); var gate = new DownloadGate(new(root, root, data, 8080, 8080, 5, 1, "test", "setup")); + await using var first = await gate.OpenAsync(path, TestContext.Current.CancellationToken); Assert.NotNull(first); Assert.Null(await gate.OpenAsync(path, TestContext.Current.CancellationToken)); + await first.DisposeAsync(); await using var reopened = await gate.OpenAsync(path, TestContext.Current.CancellationToken); Assert.NotNull(reopened); + } + + [Fact] + public async Task ScannerKeepsOpaqueIdAcrossRenameAndMarksRemovalInactive() + { + var games = Path.Combine(root, "Games"); Directory.CreateDirectory(games); var first = Path.Combine(games, "first.nsp"); await File.WriteAllTextAsync(first, "package"); await File.WriteAllTextAsync(Path.Combine(root, "ignored.txt"), "ignore"); + await using var factory = Factory(); _ = factory.CreateClient(); var indexer = factory.Services.GetRequiredService(); await indexer.ScanAsync(TestContext.Current.CancellationToken); string id; + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var file = await db.Catalog.SingleAsync(x => x.Kind == CatalogKind.File); id = file.Id; Assert.Equal(64, file.Sha256.Length); Assert.Contains("sha256-", file.ETag); Assert.Single(await db.Catalog.Where(x => x.Kind == CatalogKind.Folder).ToListAsync()); } + var renamed = Path.Combine(games, "renamed.nsp"); File.Move(first, renamed); await indexer.ScanAsync(TestContext.Current.CancellationToken); + using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var file = await db.Catalog.SingleAsync(x => x.Kind == CatalogKind.File); Assert.Equal(id, file.Id); Assert.Equal("Games/renamed.nsp", file.RelativePath); Assert.True(file.Active); file.Suppressed = true; await db.SaveChangesAsync(); } + await indexer.ScanAsync(TestContext.Current.CancellationToken); using (var scope = factory.Services.CreateScope()) { Assert.True((await scope.ServiceProvider.GetRequiredService().Catalog.SingleAsync(x => x.Id == id)).Suppressed); } + File.Delete(renamed); await indexer.ScanAsync(TestContext.Current.CancellationToken); using var last = factory.Services.CreateScope(); Assert.False((await last.ServiceProvider.GetRequiredService().Catalog.SingleAsync(x => x.Id == id)).Active); + } + + [Fact] + public async Task FirstSetupRequiresTokenAndStoresOnlyPasswordHashes() + { + await using var factory = Factory(); var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = true }); + var setup = await client.GetStringAsync("/setup", TestContext.Current.CancellationToken); var csrf = Regex.Match(setup, "name=__RequestVerificationToken value=\"([^\"]+)\"").Groups[1].Value; Assert.NotEmpty(csrf); + var fields = new Dictionary { { "__RequestVerificationToken", csrf }, { "setupToken", "test-only-setup-token" }, { "admin", "admin" }, { "adminPassword", "administrator-password" }, { "instanceName", "Test Storage" }, { "libraryPath", root }, { "libraryUser", "switch" }, { "libraryPassword", "library-password" }, { "allowManage", "on" } }; + using var response = await client.PostAsync("/setup", new FormUrlEncodedContent(fields), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var admin = await db.Admins.SingleAsync(TestContext.Current.CancellationToken); var library = await db.LibraryCredentials.SingleAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain("administrator-password", admin.PasswordHash); Assert.DoesNotContain("library-password", library.PasswordHash); Assert.True(library.AllowCatalogManage); + } + + private WebApplicationFactory Factory() + { + var port = Random.Shared.Next(20000, 50000); return new WebApplicationFactory().WithWebHostBuilder(builder => builder.UseSetting("LIBRARY_MOUNT_ROOT", root).UseSetting("LIBRARY_PATH", root).UseSetting("DATA_PATH", data).UseSetting("HOME_STORAGE_PORT", port.ToString()).UseSetting("DISCOVERY_PORT", port.ToString()).UseSetting("SETUP_TOKEN", "test-only-setup-token").UseSetting("DISABLE_BACKGROUND_SERVICES", "true")); + } + public void Dispose() { try { Directory.Delete(root, true); } catch { } try { Directory.Delete(data, true); } catch { } } +} diff --git a/home-storage/tests/HomeStorage.Tests/HomeStorage.Tests.csproj b/home-storage/tests/HomeStorage.Tests/HomeStorage.Tests.csproj new file mode 100644 index 0000000..5af30ec --- /dev/null +++ b/home-storage/tests/HomeStorage.Tests/HomeStorage.Tests.csproj @@ -0,0 +1,10 @@ + + net10.0enableenablefalse$(NoWarn);xUnit1051 + + + + + + + + diff --git a/home-storage/tests/HomeStorage.Tests/LibraryPathPolicyTests.cs b/home-storage/tests/HomeStorage.Tests/LibraryPathPolicyTests.cs new file mode 100644 index 0000000..fd94b56 --- /dev/null +++ b/home-storage/tests/HomeStorage.Tests/LibraryPathPolicyTests.cs @@ -0,0 +1,15 @@ +using HomeStorage; +using Xunit; + +namespace HomeStorage.Tests; + +public sealed class LibraryPathPolicyTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "home-storage-tests-" + Guid.NewGuid().ToString("N")); + private LibraryPathPolicy Policy() => new(new(root, root, root, 8080, 8080, 5, 4, "test", "setup")); + public LibraryPathPolicyTests() { Directory.CreateDirectory(Path.Combine(root, "Games")); File.WriteAllText(Path.Combine(root, "Games", "game.nsp"), "test"); } + [Fact] public void ResolvesOnlyRelativeChildren() { var policy = Policy(); Assert.True(policy.TryResolve(root, "Games/game.nsp", out var path)); Assert.Equal(Path.Combine(root, "Games", "game.nsp"), path); Assert.False(policy.TryResolve(root, "../secret.nsp", out _)); Assert.False(policy.TryResolve(root, "Games\\game.nsp", out _)); Assert.False(policy.TryResolve(root, "Games//game.nsp", out _)); Assert.False(policy.TryResolve(root, Path.GetFullPath(Path.Combine(root, "..", "secret.nsp")), out _)); } + [Fact] public void LibraryMustStayInsideMount() { var policy = Policy(); Assert.True(policy.TryValidateLibraryPath(Path.Combine(root, "Games"), out _, out _)); Assert.False(policy.TryValidateLibraryPath(Path.GetTempPath(), out _, out _)); } + [Fact] public void RejectsSymlinks() { var target = Path.Combine(root, "Games"); var link = Path.Combine(root, "Linked"); try { Directory.CreateSymbolicLink(link, target); } catch { return; } Assert.False(Policy().TryResolve(root, "Linked/game.nsp", out _)); } + public void Dispose() { try { Directory.Delete(root, true); } catch { } } +} diff --git a/switch/include/switchdrive/core.hpp b/switch/include/switchdrive/core.hpp index 96f8612..e426f07 100644 --- a/switch/include/switchdrive/core.hpp +++ b/switch/include/switchdrive/core.hpp @@ -17,6 +17,8 @@ enum class TaskState { Queued, Downloading, Paused, Verifying, Installing, Compl enum class LocalState { Present, RemovedAfterInstall, Missing, NotDownloaded }; enum class InstallKind { None, Nro, Nsp }; enum class StorageKind { Regular, Concatenated }; +enum class ProviderKind { GoogleDrive, HomeStorage }; +enum class ChecksumKind { None, Md5, Sha256 }; enum class NspContentKind { Unknown, BaseGame, Update, Dlc }; enum class NspInstallStorage { SdCard, InternalUser }; enum class NspInstallState { None, Pending, Installing, Installed, Failed, Unverified }; @@ -25,13 +27,20 @@ enum class NspInstallDecision { Install, AlreadyInstalled, DowngradeBlocked, Uns constexpr uint64_t kFat32FileLimit = 4ULL * 1024ULL * 1024ULL * 1024ULL; struct Account { std::string id, email, displayName; }; -struct RemoteFile { - std::string id, name, mimeType, md5, revision, resourceKey, shortcutTargetId; +struct Checksum { ChecksumKind kind{ChecksumKind::None}; std::string value; }; +struct RemoteEntry { + std::string id, providerId, name, mimeType, revision, etag, resourceKey, shortcutTargetId; + Checksum checksum; uint64_t size{}; - bool folder{}, shortcut{}, canDownload{true}; + bool folder{}, shortcut{}, canDownload{true}, canHide{}; +}; +struct ProviderConfig { + std::string id, name, baseUrl, accessToken, lastFolderId; + ProviderKind kind{ProviderKind::HomeStorage}; + bool canManageCatalog{}; }; struct Task { - std::string id, accountId, remoteId, displayName, localPath, md5, revision, etag; + std::string id, providerId, accountId, remoteId, displayName, localPath, md5, sha256, revision, etag; uint64_t expectedSize{}, committedBytes{}; TaskState state{TaskState::Queued}; LocalState localState{LocalState::NotDownloaded}; @@ -41,7 +50,7 @@ struct Task { std::string error; }; struct LibraryItem { - std::string id, accountId, remoteId, name, localPath, md5; + std::string id, providerId, accountId, remoteId, name, localPath, md5, sha256; uint64_t size{}; LocalState localState{LocalState::NotDownloaded}; InstallKind installed{InstallKind::None}; @@ -85,10 +94,11 @@ struct NspInstallJournal { bool deletePackage{}, ticketWasPresent{}, ticketImported{}; }; struct State { - int schemaVersion{4}; - std::string serviceUrl, consolePublicKey, sessionToken, lastAccountId, lastFolderId, language{"en-US"}; + int schemaVersion{5}; + std::string serviceUrl, consolePublicKey, sessionToken, lastAccountId, lastFolderId, activeProviderId{"google-drive"}, language{"en-US"}; bool deleteAfterInstall{true}; std::vector accounts; + std::vector providers{{"google-drive","","","","root",ProviderKind::GoogleDrive,false}}; std::vector tasks; std::vector library; }; @@ -99,6 +109,7 @@ bool isNro(const std::string& name); bool isNsp(const std::string& name); bool isNsz(const std::string& name); bool isInstallablePackage(const std::string& name); +bool normalizeHomeStorageUrl(const std::string& input, std::string& output); std::string makeId(); bool fileExists(const std::string& path); uint64_t fileSize(const std::string& path); diff --git a/switch/include/switchdrive/i18n.hpp b/switch/include/switchdrive/i18n.hpp index cbfd98a..30bba7f 100644 --- a/switch/include/switchdrive/i18n.hpp +++ b/switch/include/switchdrive/i18n.hpp @@ -52,7 +52,7 @@ enum class TextId { MetadataOpenFailed, ManagedVersionMissing, ContentEnumerateFailed, MetadataRemovalFailed, HttpWriteNotAllowed, ServerRangeNotConfirmed, ResponseSaveFailed, - InvalidServiceJson, CurlUnavailable, DownloadPaused, RangeDenied, + InvalidServiceJson, HttpRequestFailed, CurlUnavailable, DownloadPaused, RangeDenied, DownloadSizeMismatch, PairingResponseIncomplete, AwaitingAuthorization, AccessTokenMissing, SaveFailed, AccountLabel, BrowseDrive, OpenLibrary, LibraryItemCount, InstallingBytes, NspInstalledSuffix, PartialIdentityRestart, @@ -76,6 +76,10 @@ enum class TextId { PreparingDownload, VerifyingDownload, DownloadPauseHint, DeleteDownload, DeleteDownloadWarning, LibraryInstalledHint, DownloadRemovalPending, DownloadRemovalUnsafePath, + StorageProviders, HomeStorage, DetectNetwork, ManualSetup, ServerAddress, + Username, Password, DetectingStorage, NoStorageFound, InvalidAddress, + HomeBrowseHint, HideCatalogEntry, HideCatalogConfirm, ProviderConnected, + ButtonZL, Count }; diff --git a/switch/include/switchdrive/network.hpp b/switch/include/switchdrive/network.hpp index f6248ff..3f2203c 100644 --- a/switch/include/switchdrive/network.hpp +++ b/switch/include/switchdrive/network.hpp @@ -48,11 +48,30 @@ class HttpClient { explicit HttpClient(ActivityCallback activity = {}) : activity_(std::move(activity)) {} bool get(const std::string& url, const std::vector& headers, Response& out, std::string& error) const; bool post(const std::string& url, const std::string& body, const std::vector& headers, Response& out, std::string& error) const; + bool del(const std::string& url, const std::vector& headers, Response& out, std::string& error) const; bool download(const std::string& url, const std::vector& headers, LocalFile& output, uint64_t resumeAt, uint64_t expectedSize, const std::string& ifRange, std::function headersAccepted, std::function progress, DownloadResult& result, std::string& error) const; private: ActivityCallback activity_; }; +struct HomeStorageHealth { std::string instanceId, name; int protocolVersion{}, httpPort{8080}; bool authRequired{}; }; +struct DiscoveredHomeStorage { std::string baseUrl; HomeStorageHealth health; }; +bool normalizeHomeStorageUrl(const std::string& input, std::string& output); +bool parseHomeStorageHealthPayload(const std::string& payload, HomeStorageHealth& health, std::string& error); +bool parseHomeStorageCatalogPayload(const std::string& payload, const std::string& providerId, std::vector& files, std::string& next, std::string& error); +void deduplicateHomeStorageDiscoveries(std::vector& results); +class HomeStorageClient { + public: + explicit HomeStorageClient(HttpClient http) : http_(std::move(http)) {} + bool health(const std::string& baseUrl, HomeStorageHealth& health, std::string& error) const; + bool authenticate(const std::string& baseUrl, const std::string& username, const std::string& password, std::string& token, bool& canManage, std::string& error) const; + bool list(const ProviderConfig& provider, const std::string& folderId, const std::string& cursor, std::vector& files, std::string& next, std::string& error) const; + bool hide(const ProviderConfig& provider, const std::string& id, std::string& error) const; + std::string mediaUrl(const ProviderConfig& provider, const RemoteEntry& file) const; + private: HttpClient http_; +}; +bool discoverHomeStorage(std::vector& results, std::string& error); + class AuthClient { public: AuthClient(HttpClient http, std::string serviceUrl) : http_(std::move(http)), serviceUrl_(std::move(serviceUrl)) {} @@ -67,8 +86,33 @@ class AuthClient { class DriveClient { public: explicit DriveClient(HttpClient http) : http_(std::move(http)) {} - bool list(const std::string& accessToken, const std::string& folderId, bool sharedWithMe, const std::string& pageToken, std::vector& files, std::string& nextPage, std::string& error) const; - std::string mediaUrl(const RemoteFile& file) const; + bool list(const std::string& accessToken, const std::string& folderId, bool sharedWithMe, const std::string& pageToken, std::vector& files, std::string& nextPage, std::string& error) const; + std::string mediaUrl(const RemoteEntry& file) const; private: HttpClient http_; }; +struct DownloadRequest { std::string url; std::vector headers; }; +struct ProviderCapabilities { bool folders{}, resumableDownloads{}, catalogManage{}, lanDiscovery{}; ChecksumKind checksum{ChecksumKind::None}; }; +class IStorageProvider { + public: + virtual ~IStorageProvider() = default; + virtual ProviderCapabilities capabilities() const = 0; + virtual bool list(const std::string&, bool, const std::string&, std::vector&, std::string&, std::string&) const = 0; + virtual DownloadRequest downloadRequest(const RemoteEntry&) const = 0; +}; +class GoogleStorageProvider final : public IStorageProvider { + public: + GoogleStorageProvider(HttpClient http, std::string token) : drive_(std::move(http)), token_(std::move(token)) {} + ProviderCapabilities capabilities() const override { return {true,true,false,false,ChecksumKind::Md5}; } + bool list(const std::string&, bool, const std::string&, std::vector&, std::string&, std::string&) const override; + DownloadRequest downloadRequest(const RemoteEntry&) const override; + private: DriveClient drive_; std::string token_; +}; +class HomeStorageProvider final : public IStorageProvider { + public: + HomeStorageProvider(HttpClient http, ProviderConfig config) : home_(std::move(http)), config_(std::move(config)) {} + ProviderCapabilities capabilities() const override { return {true,true,config_.canManageCatalog,true,ChecksumKind::Sha256}; } + bool list(const std::string&, bool, const std::string&, std::vector&, std::string&, std::string&) const override; + DownloadRequest downloadRequest(const RemoteEntry&) const override; + private: HomeStorageClient home_; ProviderConfig config_; +}; } // namespace switchdrive diff --git a/switch/source/core.cpp b/switch/source/core.cpp index 8a1e010..65a454c 100644 --- a/switch/source/core.cpp +++ b/switch/source/core.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -111,6 +112,9 @@ InstallKind parseInstallKind(const std::string& value) { return InstallKind::None; } +const char* providerKindName(ProviderKind kind) { return kind == ProviderKind::GoogleDrive ? "google-drive" : "home-storage"; } +ProviderKind parseProviderKind(const std::string& value) { return value == "google-drive" ? ProviderKind::GoogleDrive : ProviderKind::HomeStorage; } + std::string escape(const std::string& value) { std::string result; for (char c : value) { @@ -227,6 +231,47 @@ bool truncateFile(std::FILE* file, uint64_t size, std::string& error) { } // namespace +bool normalizeHomeStorageUrl(const std::string& input, std::string& output) { + const auto begin = input.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return false; + const auto end = input.find_last_not_of(" \t\r\n"); + const std::string value = input.substr(begin, end - begin + 1); + std::string scheme, authority; + if (value.rfind("http://", 0) == 0) { scheme = "http://"; authority = value.substr(7); } + else if (value.rfind("https://", 0) == 0) { scheme = "https://"; authority = value.substr(8); } + else { + if (value.find("://") != std::string::npos) return false; + authority = value; + } + if (authority.empty() || authority.size() > 500 || authority.find_first_of("/@?#\\ \t\r\n") != std::string::npos) return false; + const auto colon = authority.find(':'); + if (colon != std::string::npos && authority.find(':', colon + 1) != std::string::npos) return false; + const std::string host = authority.substr(0, colon); + if (host.empty() || host.front() == '.' || host.back() == '.') return false; + for (const unsigned char c : host) if (!std::isalnum(c) && c != '.' && c != '-') return false; + if (colon != std::string::npos) { + const std::string port = authority.substr(colon + 1); + if (port.empty() || port.size() > 5 || !std::all_of(port.begin(), port.end(), [](unsigned char c){ return std::isdigit(c); })) return false; + const long value = std::strtol(port.c_str(), nullptr, 10); if (value < 1 || value > 65535) return false; + } + unsigned octets[4]{}; size_t offset = 0; bool ipv4 = true; + for (size_t i = 0; i < 4; ++i) { + const auto dot = host.find('.', offset); const auto stop = i == 3 ? host.size() : dot; + if (stop == std::string::npos || stop == offset || (i == 3 && dot != std::string::npos)) { ipv4 = false; break; } + const std::string part = host.substr(offset, stop - offset); + if (!std::all_of(part.begin(), part.end(), [](unsigned char c){ return std::isdigit(c); })) { ipv4 = false; break; } + const long n = std::strtol(part.c_str(), nullptr, 10); if (n > 255) { ipv4 = false; break; } octets[i] = static_cast(n); offset = stop + 1; + } + const bool numericHost = std::all_of(host.begin(), host.end(), [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (numericHost && !ipv4) return false; + if (scheme.empty()) { + const bool privateIp = ipv4 && (octets[0] == 10 || octets[0] == 127 || (octets[0] == 192 && octets[1] == 168) || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] == 169 && octets[1] == 254)); + const bool localName = host == "localhost" || host.ends_with(".local"); scheme = privateIp || localName ? "http://" : "https://"; + } + output = scheme + authority; + return true; +} + std::string sanitizeFileName(const std::string& name) { std::string out; for (unsigned char c : name) { @@ -617,14 +662,16 @@ State StateStore::load() { if (!input.good()) return state; const std::string json((std::istreambuf_iterator(input)), {}); const int schemaVersion = static_cast(numberField(json, "schemaVersion")); - if (schemaVersion != 1 && schemaVersion != 2 && schemaVersion != 3 && schemaVersion != 4) return state; + if (schemaVersion < 1 || schemaVersion > 5) return state; - state.schemaVersion = 4; + state.schemaVersion = 5; state.serviceUrl = stringField(json, "serviceUrl"); state.consolePublicKey = stringField(json, "consolePublicKey"); state.sessionToken = stringField(json, "sessionToken"); state.lastAccountId = stringField(json, "lastAccountId"); state.lastFolderId = stringField(json, "lastFolderId"); + state.activeProviderId = schemaVersion >= 5 ? stringField(json, "activeProviderId") : "google-drive"; + if (state.activeProviderId.empty()) state.activeProviderId = "google-drive"; state.language = std::string(i18n::languageCode(i18n::parseLanguage(stringField(json, "language")))); state.deleteAfterInstall = boolField(json, "deleteAfterInstall", true); @@ -632,14 +679,28 @@ State StateStore::load() { Account account{stringField(row, "id"), stringField(row, "email"), stringField(row, "displayName")}; if (!account.id.empty()) state.accounts.push_back(std::move(account)); } + if (schemaVersion >= 5) { + state.providers.clear(); + for (const auto& row : objectRows(json, "providers")) { + ProviderConfig provider; + provider.id = stringField(row, "id"); provider.kind = parseProviderKind(stringField(row, "kind")); + provider.name = stringField(row, "name"); provider.baseUrl = stringField(row, "baseUrl"); + provider.accessToken = stringField(row, "accessToken"); provider.lastFolderId = stringField(row, "lastFolderId"); + provider.canManageCatalog = boolField(row, "canManageCatalog", false); + if (!provider.id.empty()) state.providers.push_back(std::move(provider)); + } + if (std::none_of(state.providers.begin(), state.providers.end(), [](const ProviderConfig& p){ return p.kind == ProviderKind::GoogleDrive; })) state.providers.insert(state.providers.begin(),{"google-drive","","","","root",ProviderKind::GoogleDrive,false}); + } for (const auto& row : objectRows(json, "library")) { LibraryItem item; item.id = stringField(row, "id"); + item.providerId = schemaVersion >= 5 ? stringField(row, "providerId") : "google-drive"; item.accountId = stringField(row, "accountId"); item.remoteId = stringField(row, "remoteId"); item.name = stringField(row, "name"); item.localPath = stringField(row, "localPath"); item.md5 = stringField(row, "md5"); + item.sha256 = stringField(row, "sha256"); item.size = numberField(row, "size"); item.localState = parseLocalState(stringField(row, "localState")); item.installed = parseInstallKind(stringField(row, "installed")); @@ -663,11 +724,13 @@ State StateStore::load() { for (const auto& row : objectRows(json, "tasks")) { Task task; task.id = stringField(row, "id"); + task.providerId = schemaVersion >= 5 ? stringField(row, "providerId") : "google-drive"; task.accountId = stringField(row, "accountId"); task.remoteId = stringField(row, "remoteId"); task.displayName = stringField(row, "displayName"); task.localPath = stringField(row, "localPath"); task.md5 = stringField(row, "md5"); + task.sha256 = stringField(row, "sha256"); task.revision = stringField(row, "revision"); task.etag = stringField(row, "etag"); task.expectedSize = numberField(row, "expectedSize"); @@ -701,11 +764,12 @@ bool StateStore::save(const State& state, std::string& error) { return false; } const std::string language(i18n::languageCode(i18n::parseLanguage(state.language))); - output << "{\"schemaVersion\":4,\"serviceUrl\":\"" << escape(state.serviceUrl) + output << "{\"schemaVersion\":5,\"serviceUrl\":\"" << escape(state.serviceUrl) << "\",\"consolePublicKey\":\"" << escape(state.consolePublicKey) << "\",\"sessionToken\":\"" << escape(state.sessionToken) << "\",\"lastAccountId\":\"" << escape(state.lastAccountId) << "\",\"lastFolderId\":\"" << escape(state.lastFolderId) + << "\",\"activeProviderId\":\"" << escape(state.activeProviderId) << "\",\"language\":\"" << language << "\",\"deleteAfterInstall\":" << (state.deleteAfterInstall ? "true" : "false") << ",\"accounts\":["; for (size_t i = 0; i < state.accounts.size(); ++i) { @@ -714,13 +778,21 @@ bool StateStore::save(const State& state, std::string& error) { output << "{\"id\":\"" << escape(account.id) << "\",\"email\":\"" << escape(account.email) << "\",\"displayName\":\"" << escape(account.displayName) << "\"}"; } + output << "],\"providers\":["; + for (size_t i = 0; i < state.providers.size(); ++i) { + const auto& provider = state.providers[i]; if (i) output << ','; + output << "{\"id\":\"" << escape(provider.id) << "\",\"kind\":\"" << providerKindName(provider.kind) + << "\",\"name\":\"" << escape(provider.name) << "\",\"baseUrl\":\"" << escape(provider.baseUrl) + << "\",\"accessToken\":\"" << escape(provider.accessToken) << "\",\"lastFolderId\":\"" << escape(provider.lastFolderId) + << "\",\"canManageCatalog\":" << (provider.canManageCatalog ? "true" : "false") << "}"; + } output << "],\"tasks\":["; for (size_t i = 0; i < state.tasks.size(); ++i) { const auto& task = state.tasks[i]; if (i) output << ','; - output << "{\"id\":\"" << escape(task.id) << "\",\"accountId\":\"" << escape(task.accountId) + output << "{\"id\":\"" << escape(task.id) << "\",\"providerId\":\"" << escape(task.providerId) << "\",\"accountId\":\"" << escape(task.accountId) << "\",\"remoteId\":\"" << escape(task.remoteId) << "\",\"displayName\":\"" << escape(task.displayName) - << "\",\"localPath\":\"" << escape(task.localPath) << "\",\"md5\":\"" << escape(task.md5) + << "\",\"localPath\":\"" << escape(task.localPath) << "\",\"md5\":\"" << escape(task.md5) << "\",\"sha256\":\"" << escape(task.sha256) << "\",\"revision\":\"" << escape(task.revision) << "\",\"etag\":\"" << escape(task.etag) << "\",\"expectedSize\":" << task.expectedSize << ",\"committedBytes\":" << task.committedBytes << ",\"state\":\"" << taskStateName(task.state) << "\",\"localState\":\"" << localStateName(task.localState) @@ -733,9 +805,9 @@ bool StateStore::save(const State& state, std::string& error) { for (size_t i = 0; i < state.library.size(); ++i) { const auto& item = state.library[i]; if (i) output << ','; - output << "{\"id\":\"" << escape(item.id) << "\",\"accountId\":\"" << escape(item.accountId) + output << "{\"id\":\"" << escape(item.id) << "\",\"providerId\":\"" << escape(item.providerId) << "\",\"accountId\":\"" << escape(item.accountId) << "\",\"remoteId\":\"" << escape(item.remoteId) << "\",\"name\":\"" << escape(item.name) - << "\",\"localPath\":\"" << escape(item.localPath) << "\",\"md5\":\"" << escape(item.md5) + << "\",\"localPath\":\"" << escape(item.localPath) << "\",\"md5\":\"" << escape(item.md5) << "\",\"sha256\":\"" << escape(item.sha256) << "\",\"size\":" << item.size << ",\"localState\":\"" << localStateName(item.localState) << "\",\"installed\":\"" << installKindName(item.installed) << "\",\"storageKind\":\"" << storageKindName(item.storageKind) << "\",\"installedPath\":\"" << escape(item.installedPath) << "\",\"installedContentId\":\"" << escape(item.installedContentId) diff --git a/switch/source/i18n.cpp b/switch/source/i18n.cpp index 7db457f..be4651e 100644 --- a/switch/source/i18n.cpp +++ b/switch/source/i18n.cpp @@ -41,7 +41,7 @@ using Catalog = std::array; X("The partial file lacks enough metadata to resume safely.", "O parcial não tem metadados suficientes para retomar com segurança.", "El archivo parcial no tiene metadatos suficientes para reanudar con seguridad.") \ X("Invalid partial: larger than remote file", "Parcial inválido: maior que o arquivo remoto", "Parcial inválido: mayor que el archivo remoto") \ X("The download destination already exists", "O destino de download já existe", "El destino de descarga ya existe") \ - X("MD5 checksum does not match", "checksum MD5 não confere", "La suma MD5 no coincide") \ + X("Checksum does not match", "O checksum não confere", "La suma de comprobación no coincide") \ X("Installation failed: %s", "Instalação falhou: %s", "La instalación falló: %s") \ X("Installed — cleanup pending: %s", "Instalado — limpeza pendente: %s", "Instalado — limpieza pendiente: %s") \ X("Restart download", "Reiniciar download", "Reiniciar descarga") \ @@ -162,6 +162,7 @@ using Catalog = std::array; X("Server did not confirm download range", "servidor não confirmou a faixa do download", "el servidor no confirmó el rango de descarga") \ X("Could not record response", "não foi possível registrar a resposta do download", "no se pudo registrar la respuesta de descarga") \ X("Invalid service JSON", "JSON inválido do serviço", "JSON de servicio inválido") \ + X("HTTP request failed (status %ld)", "Falha na requisição HTTP (status %ld)", "La solicitud HTTP falló (estado %ld)") \ X("curl unavailable", "curl indisponível", "curl no disponible") \ X("download paused", "download pausado", "descarga pausada") \ X("download range rejected", "faixa de download recusada", "rango de descarga rechazado") \ @@ -248,15 +249,32 @@ using Catalog = std::array; X("Installation recovery is pending. Reopen the app before deleting this download.", "Há uma recuperação de instalação pendente. Reabra o app antes de excluir este download.", "Hay una recuperación de instalación pendiente. Abre de nuevo la app antes de eliminar esta descarga.") \ X("The file is outside this download's folder; deletion was blocked.", "O arquivo está fora da pasta deste download; a exclusão foi bloqueada.", "El archivo está fuera de la carpeta de esta descarga; se bloqueó su eliminación.") +#define SD_HOME_TEXTS(X) \ + X("Storage providers", "Provedores de armazenamento", "Proveedores de almacenamiento") \ + X("Home Storage", "Home Storage", "Home Storage") \ + X("Detect on network", "Detectar na rede", "Detectar en la red") \ + X("Manual setup", "Configuração manual", "Configuración manual") \ + X("Server address", "Endereço do servidor", "Dirección del servidor") \ + X("Username", "Usuário", "Usuario") \ + X("Password", "Senha", "Contraseña") \ + X("Searching for Home Storage...", "Procurando Home Storage...", "Buscando Home Storage...") \ + X("No Home Storage service was found.", "Nenhum serviço Home Storage foi encontrado.", "No se encontró ningún servicio Home Storage.") \ + X("Invalid server address.", "Endereço de servidor inválido.", "Dirección de servidor inválida.") \ + X("A: open X: download Y: download and install ZL: hide B: back", "A: abrir X: baixar Y: baixar e instalar ZL: ocultar B: voltar", "A: abrir X: descargar Y: descargar e instalar ZL: ocultar B: volver") \ + X("Hide catalog entry", "Ocultar item do catálogo", "Ocultar elemento del catálogo") \ + X("Hide this item from Home Storage? The PC file will not be deleted.", "Ocultar este item do Home Storage? O arquivo do PC não será excluído.", "¿Ocultar este elemento de Home Storage? El archivo del PC no se eliminará.") \ + X("Home Storage connected: %s", "Home Storage conectado: %s", "Home Storage conectado: %s") \ + X("ZL", "ZL", "ZL") + #define EN(en, pt, es) en, -constexpr const char* kEnglishKeys[] = { SD_TEXTS(EN) }; -constexpr Catalog kEnglish = { SD_TEXTS(EN) }; +constexpr const char* kEnglishKeys[] = { SD_TEXTS(EN) SD_HOME_TEXTS(EN) }; +constexpr Catalog kEnglish = { SD_TEXTS(EN) SD_HOME_TEXTS(EN) }; #define PT(en, pt, es) pt, -constexpr const char* kPortugueseKeys[] = { SD_TEXTS(PT) }; -constexpr Catalog kPortuguese = { SD_TEXTS(PT) }; +constexpr const char* kPortugueseKeys[] = { SD_TEXTS(PT) SD_HOME_TEXTS(PT) }; +constexpr Catalog kPortuguese = { SD_TEXTS(PT) SD_HOME_TEXTS(PT) }; #define ES(en, pt, es) es, -constexpr const char* kSpanishKeys[] = { SD_TEXTS(ES) }; -constexpr Catalog kSpanish = { SD_TEXTS(ES) }; +constexpr const char* kSpanishKeys[] = { SD_TEXTS(ES) SD_HOME_TEXTS(ES) }; +constexpr Catalog kSpanish = { SD_TEXTS(ES) SD_HOME_TEXTS(ES) }; #undef EN #undef PT #undef ES diff --git a/switch/source/main.cpp b/switch/source/main.cpp index ba0d0b9..05163e9 100644 --- a/switch/source/main.cpp +++ b/switch/source/main.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -50,6 +51,16 @@ std::string accountName(const State& state) { return tr(TextId::NoAccountConnected); } +ProviderConfig* providerById(State& state, const std::string& id) { + for (auto& provider : state.providers) if (provider.id == id) return &provider; + return nullptr; +} + +std::string activeProviderName(State& state) { + auto* provider = providerById(state, state.activeProviderId); + return !provider || provider->kind == ProviderKind::GoogleDrive ? tr(TextId::MyDrive) : provider->name; +} + std::string fileSize(uint64_t bytes) { char text[64]{}; std::snprintf(text, sizeof(text), tr(TextId::FileSize), static_cast(bytes) / (1024.0 * 1024.0)); @@ -162,6 +173,14 @@ bool md5File(const fs::path& path, StorageKind kind, std::string& digest, std::s return true; } +bool sha256File(const fs::path& path, StorageKind kind, std::string& digest, std::string& error) { + LocalFile file; uint64_t size{}; if (!file.open(path, kind, false, error) || !file.size(size, error)) return false; + mbedtls_sha256_context context; mbedtls_sha256_init(&context); mbedtls_sha256_starts(&context, 0); + std::array buffer{}; + for (uint64_t offset = 0; offset < size;) { const size_t chunk = static_cast(std::min(buffer.size(), size - offset)); if (!file.readAt(offset, buffer.data(), chunk, error)) { mbedtls_sha256_free(&context); return false; } mbedtls_sha256_update(&context, buffer.data(), chunk); offset += chunk; ui::instance().setProgress(offset, size); if (!pumpUi()) { mbedtls_sha256_free(&context); error = tr(TextId::OperationCancelled); return false; } } + std::array raw{}; mbedtls_sha256_finish(&context, raw.data()); mbedtls_sha256_free(&context); char output[65]{}; for (size_t i = 0; i < raw.size(); ++i) std::snprintf(output + i * 2, 3, "%02x", raw[i]); digest = output; return true; +} + bool connectAccount(StateStore& store, State& state) { title(tr(TextId::ConnectDrive)); if (!networkReady) { printf("%s\n", networkError().c_str()); waitForButton(); return false; } @@ -213,17 +232,18 @@ bool acquireToken(const State& state, std::string& token, std::string& error) { return AuthClient(activeHttp(), state.serviceUrl).accessToken(state.sessionToken, state.lastAccountId, token, error); } -bool sameRemote(const Task& task, const RemoteFile& remote, const std::string& accountId) { - return task.accountId == accountId && task.remoteId == remote.id && task.revision == remote.revision && task.expectedSize == remote.size && task.md5 == remote.md5; +bool sameRemote(const Task& task, const RemoteEntry& remote, const std::string& accountId) { + const bool checksumMatches = remote.checksum.kind == ChecksumKind::Sha256 ? task.sha256 == remote.checksum.value : remote.checksum.kind == ChecksumKind::Md5 ? task.md5 == remote.checksum.value : task.md5.empty() && task.sha256.empty(); + return task.providerId == remote.providerId && task.accountId == accountId && task.remoteId == remote.id && task.revision == remote.revision && task.expectedSize == remote.size && checksumMatches; } bool hasResumeIdentity(const Task& task) { - return !task.accountId.empty() && !task.remoteId.empty() && !task.revision.empty() && task.expectedSize > 0 && !task.localPath.empty(); + return !task.providerId.empty() && !task.remoteId.empty() && !task.revision.empty() && task.expectedSize > 0 && !task.localPath.empty(); } -Task* findIncompleteTask(State& state, const std::string& accountId, const std::string& remoteId) { +Task* findIncompleteTask(State& state, const std::string& providerId, const std::string& accountId, const std::string& remoteId) { for (auto& task : state.tasks) { - if (task.accountId == accountId && task.remoteId == remoteId && task.state != TaskState::Completed && task.state != TaskState::Cancelled) return &task; + if (task.providerId == providerId && task.accountId == accountId && task.remoteId == remoteId && task.state != TaskState::Completed && task.state != TaskState::Cancelled) return &task; } return nullptr; } @@ -295,12 +315,14 @@ bool hasEnoughSpace(uint64_t needed) { return !ec && space.available >= needed; } -void applyRemote(Task& task, const RemoteFile& remote, const std::string& accountId, const StateStore& store) { +void applyRemote(Task& task, const RemoteEntry& remote, const std::string& accountId, const StateStore& store) { + task.providerId = remote.providerId.empty() ? "google-drive" : remote.providerId; task.accountId = accountId; task.remoteId = remote.id; task.displayName = remote.name; task.expectedSize = remote.size; - task.md5 = remote.md5; + task.md5 = remote.checksum.kind == ChecksumKind::Md5 ? remote.checksum.value : ""; + task.sha256 = remote.checksum.kind == ChecksumKind::Sha256 ? remote.checksum.value : ""; task.revision = remote.revision; task.etag.clear(); task.storageKind = storageKindForSize(remote.size); @@ -314,8 +336,9 @@ bool verifyAndRecord(StateStore& store, State& state, Task& task, std::string& e task.state = TaskState::Verifying; saveOrShow(store, state); std::string digest; - if (!md5File(task.localPath, task.storageKind, digest, error)) return false; - if (!task.md5.empty() && digest != task.md5) { + const bool useSha256 = !task.sha256.empty(); + if (!(useSha256 ? sha256File(task.localPath, task.storageKind, digest, error) : md5File(task.localPath, task.storageKind, digest, error))) return false; + if ((useSha256 && digest != task.sha256) || (!useSha256 && !task.md5.empty() && digest != task.md5)) { error = tr(TextId::ChecksumMismatch); return false; } @@ -326,11 +349,13 @@ bool verifyAndRecord(StateStore& store, State& state, Task& task, std::string& e if (existing == state.library.end()) { LibraryItem item; item.id = task.id; + item.providerId = task.providerId; item.accountId = task.accountId; item.remoteId = task.remoteId; item.name = task.displayName; item.localPath = task.localPath; item.md5 = task.md5; + item.sha256 = task.sha256; item.size = task.expectedSize; item.localState = LocalState::Present; item.storageKind = task.storageKind; @@ -402,21 +427,26 @@ void installDownloaded(StateStore& store, State& state, Task& task) { if (library->installed == InstallKind::Nsp && library->nspContentKind != NspContentKind::BaseGame) printf("%s\n", tr(TextId::NonBaseHomeHint)); } -void downloadFile(StateStore& store, State& state, const RemoteFile& remote, bool installAfter) { +void downloadFile(StateStore& store, State& state, const RemoteEntry& remote, bool installAfter) { title(tr(TextId::Transfers)); printf(tr(TextId::Downloading), remote.name.c_str()); printf("\n%s\n", tr(TextId::PreparingDownload)); consoleUpdate(nullptr); std::string token, error; - if (!acquireToken(state, token, error)) { + const std::string providerId = remote.providerId.empty() ? "google-drive" : remote.providerId; + const bool homeStorage = providerId != "google-drive"; + ProviderConfig* home = homeStorage ? providerById(state, providerId) : nullptr; + if (homeStorage && !home) { printf("\n%s", tr(TextId::ConfigMissing)); waitForButton(); return; } + if (!homeStorage && !acquireToken(state, token, error)) { printf("\n%s", error.c_str()); waitForButton(); return; } - Task* task = findIncompleteTask(state, state.lastAccountId, remote.id); + const std::string sourceAccount = homeStorage ? "" : state.lastAccountId; + Task* task = findIncompleteTask(state, providerId, sourceAccount, remote.id); bool restart = false; if (task) { - const bool changed = !sameRemote(*task, remote, state.lastAccountId); + const bool changed = !sameRemote(*task, remote, sourceAccount); const bool missingIdentity = !hasResumeIdentity(*task); if (changed || missingIdentity || task->committedBytes) { const ResumeChoice choice = askResumeChoice(*task, changed, missingIdentity); @@ -427,7 +457,7 @@ void downloadFile(StateStore& store, State& state, const RemoteFile& remote, boo Task newTask; newTask.id = makeId(); newTask.deleteAfterInstall = state.deleteAfterInstall; - applyRemote(newTask, remote, state.lastAccountId, store); + applyRemote(newTask, remote, sourceAccount, store); state.tasks.push_back(std::move(newTask)); task = &state.tasks.back(); } @@ -439,7 +469,7 @@ void downloadFile(StateStore& store, State& state, const RemoteFile& remote, boo waitForButton(); return; } - applyRemote(*task, remote, state.lastAccountId, store); + applyRemote(*task, remote, sourceAccount, store); } const bool installRequested = task->installAfterDownload || installAfter; task->installAfterDownload = installRequested; @@ -500,9 +530,10 @@ void downloadFile(StateStore& store, State& state, const RemoteFile& remote, boo auto lastCheckpoint = task->committedBytes; auto lastCheckpointAt = std::chrono::steady_clock::now(); DownloadResult result; + const DownloadRequest request = homeStorage && home ? HomeStorageProvider(activeHttp(), *home).downloadRequest(remote) : GoogleStorageProvider(activeHttp(), token).downloadRequest(remote); const bool downloaded = task->committedBytes == task->expectedSize || activeHttp().download( - DriveClient(activeHttp()).mediaUrl(remote), - {"Authorization: Bearer " + token}, + request.url, + request.headers, output, task->committedBytes, task->expectedSize, @@ -597,6 +628,82 @@ void recoverInstallJournal(StateStore& store, State& state) { saveOrShow(store, state); } +bool promptText(const char* label, std::string& value, bool password = false) { +#ifdef __SWITCH__ + SwkbdConfig keyboard; if (R_FAILED(swkbdCreate(&keyboard, 0))) return false; + if (password) swkbdConfigMakePresetPassword(&keyboard); else swkbdConfigMakePresetDefault(&keyboard); swkbdConfigSetHeaderText(&keyboard, label); + swkbdConfigSetInitialText(&keyboard, value.c_str()); + std::array buffer{}; const Result result = swkbdShow(&keyboard, buffer.data(), buffer.size()); swkbdClose(&keyboard); + if (R_FAILED(result)) return false; + value = buffer.data(); + return !value.empty(); +#else + (void)label; (void)value; (void)password; return false; +#endif +} + +bool selectDiscovered(const std::vector& found, size_t& selected) { + selected = 0; + while (appletMainLoop()) { + title(tr(TextId::DetectNetwork)); std::vector rows; for (const auto& item : found) rows.push_back({item.health.name, item.baseUrl, ui::Icon::Cloud}); ui::instance().setRows(std::move(rows), selected); hint(tr(TextId::NavigationHint)); + hidScanInput(); const auto pressed = hidKeysDown(CONTROLLER_P1_AUTO); const int touched = ui::instance().takeRowSelection(); if (touched >= 0 && static_cast(touched) < found.size()) selected = static_cast(touched); + if (pressed & HidNpadButton_Down) selected = (selected + 1) % found.size(); + if (pressed & HidNpadButton_Up) selected = (selected + found.size() - 1) % found.size(); + if (pressed & HidNpadButton_A) return true; + if (pressed & HidNpadButton_B) return false; + consoleUpdate(nullptr); + } + return false; +} + +void configureHomeStorage(StateStore& store, State& state) { + if (!networkReady) { title(tr(TextId::HomeStorage)); printf("%s\n", networkError().c_str()); waitForButton(); return; } + size_t choice = 0; std::string address; + while (appletMainLoop()) { + title(tr(TextId::HomeStorage)); ui::instance().setRows({{tr(TextId::DetectNetwork), tr(TextId::DetectingStorage), ui::Icon::Cloud}, {tr(TextId::ManualSetup), tr(TextId::ServerAddress), ui::Icon::Settings}}, choice); hint(tr(TextId::NavigationHint)); + hidScanInput(); const auto pressed = hidKeysDown(CONTROLLER_P1_AUTO); const int touched = ui::instance().takeRowSelection(); if (touched >= 0) choice = static_cast(touched); if (pressed & (HidNpadButton_Up | HidNpadButton_Down)) choice = 1 - choice; if (pressed & HidNpadButton_B) return; + if (pressed & HidNpadButton_A) { + if (choice == 0) { title(tr(TextId::DetectNetwork)); printf("%s\n", tr(TextId::DetectingStorage)); consoleUpdate(nullptr); std::vector found, validated; std::string error; if (discoverHomeStorage(found, error)) for (const auto& candidate : found) { HomeStorageHealth checked; if (HomeStorageClient(activeHttp()).health(candidate.baseUrl, checked, error) && checked.instanceId == candidate.health.instanceId) validated.push_back({candidate.baseUrl,checked}); } if (validated.empty()) { printf("%s\n", tr(TextId::NoStorageFound)); waitForButton(); return; } size_t selected{}; if (!selectDiscovered(validated, selected)) return; address = validated[selected].baseUrl; } + else if (!promptText(tr(TextId::ServerAddress), address) || !normalizeHomeStorageUrl(address, address)) { title(tr(TextId::HomeStorage)); printf("%s\n", tr(TextId::InvalidAddress)); waitForButton(); return; } + break; + } + consoleUpdate(nullptr); + } + HomeStorageClient client(activeHttp()); HomeStorageHealth health; std::string error; if (!client.health(address, health, error)) { title(tr(TextId::HomeStorage)); printf("%s\n", error.c_str()); waitForButton(); return; } + std::string token;bool canManage=false;if (health.authRequired) { std::string username, password; if (!promptText(tr(TextId::Username), username) || !promptText(tr(TextId::Password), password, true)) return; if (!client.authenticate(address, username, password, token, canManage, error)) { title(tr(TextId::HomeStorage)); printf("%s\n", error.c_str()); waitForButton(); return; } } + const std::string id = "home-" + health.instanceId; auto* existing = providerById(state, id); if (!existing) { state.providers.push_back({}); existing = &state.providers.back(); } + existing->id=id; existing->kind=ProviderKind::HomeStorage; existing->name=health.name; existing->baseUrl=address; existing->accessToken=token; existing->lastFolderId="root";existing->canManageCatalog=canManage; state.activeProviderId=id; saveOrShow(store,state); + title(tr(TextId::HomeStorage)); printf(tr(TextId::ProviderConnected), health.name.c_str()); printf("\n"); waitForButton(); +} + +bool confirmHideHome(const ProviderConfig& provider, const RemoteEntry& file) { + title(tr(TextId::HideCatalogEntry)); printf("%s\n\n%s\n", file.name.c_str(), tr(TextId::HideCatalogConfirm)); hint(tr(TextId::RemoveConfirm)); consoleUpdate(nullptr); + while(appletMainLoop()){hidScanInput();const auto pressed=hidKeysDown(CONTROLLER_P1_AUTO);if(pressed&HidNpadButton_B)return false;if(pressed&HidNpadButton_X){std::string error;if(!HomeStorageClient(activeHttp()).hide(provider,file.id,error)){printf("\n%s\n",error.c_str());waitForButton();}return true;}consoleUpdate(nullptr);}return false; +} + +void browseHome(StateStore& store, State& state, ProviderConfig& provider) { + std::string folder=provider.lastFolderId.empty()?"root":provider.lastFolderId;std::vector parents;if(folder!="root")parents.push_back("root");size_t selected=0;bool reload=true;std::vector files;std::string next,error;HomeStorageProvider home(activeHttp(),provider); + while(appletMainLoop()){ + if(reload){files.clear();next.clear();if(!home.list(folder,false,"",files,next,error)){title(tr(TextId::HomeStorage));printf("%s\n",error.c_str());waitForButton();return;}selected=0;reload=false;} + title(provider.name.c_str());if(files.empty())printf("%s\n",tr(TextId::EmptyFolder));std::vector rows;for(const auto& file:files)rows.push_back({file.name,file.folder?tr(TextId::Folder):fileSize(file.size),file.folder?ui::Icon::Folder:ui::Icon::File});ui::instance().setRows(std::move(rows),selected);hint(tr(TextId::HomeBrowseHint)); + hidScanInput();const auto pressed=hidKeysDown(CONTROLLER_P1_AUTO);const int touched=ui::instance().takeRowSelection();if(touched>=0&&static_cast(touched)(touched); + if(pressed&HidNpadButton_B){if(parents.empty())break;folder=parents.back();parents.pop_back();reload=true;continue;}if(files.empty()){consoleUpdate(nullptr);continue;} + if(pressed&HidNpadButton_Down){if(selected+1 page;std::string following;if(!home.list(folder,false,next,page,following,error)){printf("%s\n",error.c_str());waitForButton();return;}files.insert(files.end(),page.begin(),page.end());next=following;if(selected+1 rows;for(const auto& p:state.providers)rows.push_back({p.kind==ProviderKind::GoogleDrive?tr(TextId::MyDrive):p.name,p.kind==ProviderKind::GoogleDrive?accountName(state):p.baseUrl,ui::Icon::Cloud});ui::instance().setRows(std::move(rows),selected);hint(tr(TextId::NavigationHint));hidScanInput();const auto pressed=hidKeysDown(CONTROLLER_P1_AUTO);const int touched=ui::instance().takeRowSelection();if(touched>=0&&static_cast(touched)(touched);if(pressed&HidNpadButton_Down)selected=(selected+1)%state.providers.size();if(pressed&HidNpadButton_Up)selected=(selected+state.providers.size()-1)%state.providers.size();if(pressed&HidNpadButton_B)return;if(pressed&HidNpadButton_A){auto& provider=state.providers[selected];state.activeProviderId=provider.id;if(provider.kind==ProviderKind::GoogleDrive)browse(store,state);else browseHome(store,state,provider);return;}consoleUpdate(nullptr);} +} + void browse(StateStore& store, State& state) { std::string token, error; if (!acquireToken(state, token, error)) { @@ -605,19 +712,19 @@ void browse(StateStore& store, State& state) { waitForButton(); return; } - DriveClient drive(activeHttp()); + GoogleStorageProvider drive(activeHttp(), token); std::string folder = state.lastFolderId.empty() ? "root" : state.lastFolderId; std::vector parents; bool shared = false; size_t selected = 0; - std::vector files; + std::vector files; std::string next; bool reload = true; while (appletMainLoop()) { if (reload) { files.clear(); next.clear(); - if (!drive.list(token, folder, shared, "", files, next, error)) { + if (!drive.list(folder, shared, "", files, next, error)) { title(tr(TextId::Files)); printf(tr(TextId::DriveError), error.c_str()); printf("\n"); waitForButton(); @@ -644,9 +751,9 @@ void browse(StateStore& store, State& state) { if (!files.empty()) { if (selected + 1 < files.size()) { ++selected; refresh = true; } else if (!next.empty()) { - std::vector page; + std::vector page; std::string following; - if (!drive.list(token, folder, shared, next, page, following, error)) { title(tr(TextId::Files)); printf(tr(TextId::DriveError), error.c_str()); printf("\n"); waitForButton(); return; } + if (!drive.list(folder, shared, next, page, following, error)) { title(tr(TextId::Files)); printf(tr(TextId::DriveError), error.c_str()); printf("\n"); waitForButton(); return; } files.insert(files.end(), page.begin(), page.end()); next = following; if (selected + 1 < files.size()) ++selected; @@ -823,11 +930,11 @@ int main(int argc, char* argv[]) { if (page == 0) { ui::instance().setCards({ {tr(TextId::ConnectDrive), accountName(state), HidNpadButton_A, ui::Icon::Cloud}, - {tr(TextId::Files), tr(TextId::MyDrive), HidNpadButton_X, ui::Icon::Folder}, + {tr(TextId::Files), activeProviderName(state), HidNpadButton_X, ui::Icon::Folder}, {tr(TextId::Library), tr(TextId::LibrarySubtitle), HidNpadButton_Y, ui::Icon::Library}, }); } else if (page == 1) { - ui::instance().setCards({{tr(TextId::MyDrive), accountName(state), HidNpadButton_A, ui::Icon::Folder}}); + ui::instance().setCards({{tr(TextId::StorageProviders), tr(TextId::FilesSubtitle), HidNpadButton_A, ui::Icon::Folder}}); } else if (page == 2) { char detail[96]{}; std::snprintf(detail, sizeof(detail), tr(TextId::OpenLibrary), state.library.size()); @@ -837,6 +944,7 @@ int main(int argc, char* argv[]) { {tr(TextId::AutoCleanup), state.deleteAfterInstall ? tr(TextId::Yes) : tr(TextId::No), HidNpadButton_A, ui::Icon::Settings}, {tr(TextId::ConnectDrive), accountName(state), HidNpadButton_X, ui::Icon::Cloud}, {tr(TextId::Language), std::string(languageName(currentLanguage())), HidNpadButton_Y, ui::Icon::Language}, + {tr(TextId::HomeStorage), tr(TextId::StorageProviders), HidNpadButton_ZL, ui::Icon::Cloud}, }); } mainHint(tr(TextId::NavigationHint)); @@ -847,17 +955,18 @@ int main(int argc, char* argv[]) { const int touchedTab = ui::instance().takeTabSelection(); const uint64_t action = ui::instance().takeCardAction(); if (touchedTab >= 0) { page = touchedTab; continue; } - // The UI resolves A to the selected card; X/Y remain direct shortcuts. + // The UI resolves A to the selected card; X/Y/ZL remain direct shortcuts. // Dispatch exactly one action, then rebuild the main screen after any // nested dialog so stale dialog state cannot receive the next input. if (page == 0 && action == HidNpadButton_A) connectAccount(store, state); else if (page == 0 && action == HidNpadButton_Y) library(store, state); - else if (page == 0 && action == HidNpadButton_X) browse(store, state); - else if (page == 1 && action == HidNpadButton_A) browse(store, state); + else if (page == 0 && action == HidNpadButton_X) chooseStorageProvider(store, state); + else if (page == 1 && action == HidNpadButton_A) chooseStorageProvider(store, state); else if (page == 2 && action == HidNpadButton_A) library(store, state); else if (page == 3 && action == HidNpadButton_A) { state.deleteAfterInstall = !state.deleteAfterInstall; saveOrShow(store, state); } else if (page == 3 && action == HidNpadButton_X) connectAccount(store, state); else if (page == 3 && action == HidNpadButton_Y) { setLanguage(nextLanguage(currentLanguage())); state.language = languageCode(currentLanguage()); saveOrShow(store, state); } + else if (page == 3 && action == HidNpadButton_ZL) configureHomeStorage(store, state); } curl_global_cleanup(); if (R_SUCCEEDED(socketResult)) socketExit(); diff --git a/switch/source/network.cpp b/switch/source/network.cpp index 9980708..400f7fa 100644 --- a/switch/source/network.cpp +++ b/switch/source/network.cpp @@ -7,6 +7,16 @@ #include #include #include +#include +#include +#include +#include +#ifdef __SWITCH__ +#include +#include +#include +#include +#endif namespace switchdrive { namespace { @@ -183,6 +193,26 @@ uint64_t integerOrString(json_t* object, const char* key) { return 0; } +std::string base64(const std::string& input) { + static constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + for (size_t i = 0; i < input.size(); i += 3) { + const uint32_t value = (static_cast(input[i]) << 16) | + (i + 1 < input.size() ? static_cast(input[i + 1]) << 8 : 0) | + (i + 2 < input.size() ? static_cast(input[i + 2]) : 0); + out += alphabet[(value >> 18) & 63]; out += alphabet[(value >> 12) & 63]; + out += i + 1 < input.size() ? alphabet[(value >> 6) & 63] : '='; + out += i + 2 < input.size() ? alphabet[value & 63] : '='; + } + return out; +} + +void setHttpStatusError(long status, std::string& error) { + char message[96]{}; + std::snprintf(message, sizeof(message), i18n::tr(i18n::TextId::HttpRequestFailed), status); + error = message; +} + } // namespace bool HttpClient::get(const std::string& url, const std::vector& headers, Response& out, std::string& error) const { @@ -207,7 +237,8 @@ bool HttpClient::get(const std::string& url, const std::vector& hea else error = curl_easy_strerror(result); return false; } - return out.status >= 200 && out.status < 300; + if (out.status < 200 || out.status >= 300) { setHttpStatusError(out.status, error); return false; } + return true; } bool HttpClient::post(const std::string& url, const std::string& body, const std::vector& headers, Response& out, std::string& error) const { @@ -235,7 +266,21 @@ bool HttpClient::post(const std::string& url, const std::string& body, const std else error = curl_easy_strerror(result); return false; } - return out.status >= 200 && out.status < 300; + if (out.status < 200 || out.status >= 300) { setHttpStatusError(out.status, error); return false; } + return true; +} + +bool HttpClient::del(const std::string& url, const std::vector& headers, Response& out, std::string& error) const { + CURL* curl = curl_easy_init(); + if (!curl) { error = i18n::tr(i18n::TextId::CurlUnavailable); return false; } + curl_slist* list = nullptr; configure(curl, headers, list, error, &activity_); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, append); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out.body); + const auto result = curl_easy_perform(curl); curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out.status); + curl_slist_free_all(list); curl_easy_cleanup(curl); + if (result != CURLE_OK) { error = result == CURLE_ABORTED_BY_CALLBACK ? i18n::tr(i18n::TextId::OperationCancelled) : curl_easy_strerror(result); return false; } + if (out.status < 200 || out.status >= 300) { setHttpStatusError(out.status, error); return false; } + return true; } bool HttpClient::download(const std::string& url, const std::vector& headers, LocalFile& output, uint64_t resumeAt, uint64_t expectedSize, const std::string& ifRange, std::function headersAccepted, std::function progress, DownloadResult& result, std::string& error) const { @@ -359,7 +404,7 @@ bool AuthClient::accessToken(const std::string& session, const std::string& acco return true; } -bool DriveClient::list(const std::string& accessToken, const std::string& folderId, bool sharedWithMe, const std::string& pageToken, std::vector& files, std::string& nextPage, std::string& error) const { +bool DriveClient::list(const std::string& accessToken, const std::string& folderId, bool sharedWithMe, const std::string& pageToken, std::vector& files, std::string& nextPage, std::string& error) const { CURL* curl = curl_easy_init(); if (!curl) { error = i18n::tr(i18n::TextId::CurlUnavailable); @@ -377,9 +422,9 @@ bool DriveClient::list(const std::string& accessToken, const std::string& folder json_t* rows = json_object_get(root, "files"); size_t index; json_t* row; json_array_foreach(rows, index, row) { - RemoteFile file; + RemoteEntry file; file.id = str(row, "id"); file.name = str(row, "name"); file.mimeType = str(row, "mimeType"); - file.size = integerOrString(row, "size"); file.revision = stringOrInteger(row, "version"); file.md5 = str(row, "md5Checksum"); + file.size = integerOrString(row, "size"); file.revision = stringOrInteger(row, "version"); file.checksum = {ChecksumKind::Md5,str(row, "md5Checksum")}; file.resourceKey = str(row, "resourceKey"); file.folder = file.mimeType == "application/vnd.google-apps.folder"; file.shortcut = file.mimeType == "application/vnd.google-apps.shortcut"; json_t* capabilities = json_object_get(row, "capabilities"); @@ -392,8 +437,70 @@ bool DriveClient::list(const std::string& accessToken, const std::string& folder return true; } -std::string DriveClient::mediaUrl(const RemoteFile& file) const { +std::string DriveClient::mediaUrl(const RemoteEntry& file) const { return "https://www.googleapis.com/drive/v3/files/" + file.id + "?alt=media" + (file.resourceKey.empty() ? "" : "&resourceKey=" + file.resourceKey); } +bool parseHomeStorageHealthPayload(const std::string& payload, HomeStorageHealth& health, std::string& error) { + json_t* root = parse(payload, error); if (!root) return false; + const bool valid = json_is_object(root) && str(root, "service") == "switch-drive-home-storage"; + HomeStorageHealth parsed; parsed.instanceId = str(root, "instanceId"); parsed.name = str(root, "name"); parsed.protocolVersion = static_cast(integerOrString(root, "protocolVersion")); parsed.httpPort = static_cast(integerOrString(root, "httpPort")); parsed.authRequired = json_is_true(json_object_get(root, "authRequired")); json_decref(root); + if (!valid || parsed.protocolVersion != 1 || parsed.instanceId.empty() || parsed.name.empty() || parsed.httpPort < 1 || parsed.httpPort > 65535) { error = i18n::tr(i18n::TextId::InvalidServiceJson); return false; } + health = std::move(parsed); return true; +} + +bool parseHomeStorageCatalogPayload(const std::string& payload, const std::string& providerId, std::vector& files, std::string& next, std::string& error) { + json_t* root = parse(payload, error); if (!root) return false; + json_t* rows = json_object_get(root, "items"); + if (!json_is_object(root) || !json_is_array(rows)) { json_decref(root); error = i18n::tr(i18n::TextId::InvalidServiceJson); return false; } + std::vector parsed; const auto nextCursor = str(root, "nextCursor"); size_t index; json_t* row; + json_array_foreach(rows, index, row) { + RemoteEntry file; file.id = str(row, "id"); file.providerId = providerId; file.name = str(row, "name"); file.mimeType = str(row, "kind"); file.folder = file.mimeType == "folder"; file.size = integerOrString(row, "size"); file.revision = str(row, "etag"); file.etag = file.revision; file.checksum = {ChecksumKind::Sha256, str(row, "sha256")}; file.canDownload = json_is_true(json_object_get(row, "canDownload")); file.canHide = json_is_true(json_object_get(row, "canHide")); + if (!json_is_object(row) || file.id.empty() || file.name.empty() || (file.mimeType != "folder" && file.mimeType != "file") || (!file.folder && (file.etag.empty() || file.checksum.value.size() != 64))) { json_decref(root); error = i18n::tr(i18n::TextId::InvalidServiceJson); return false; } + parsed.push_back(std::move(file)); + } + json_decref(root); files.insert(files.end(), std::make_move_iterator(parsed.begin()), std::make_move_iterator(parsed.end())); next = nextCursor; return true; +} + +void deduplicateHomeStorageDiscoveries(std::vector& results) { + std::set seen; + results.erase(std::remove_if(results.begin(), results.end(), [&](const DiscoveredHomeStorage& item) { return item.health.instanceId.empty() || item.health.protocolVersion != 1 || !seen.insert(item.health.instanceId).second; }), results.end()); +} + +bool HomeStorageClient::health(const std::string& baseUrl, HomeStorageHealth& health, std::string& error) const { + HttpClient::Response response; if (!http_.get(baseUrl + "/drive-health", {}, response, error)) return false; + return parseHomeStorageHealthPayload(response.body, health, error); +} + +bool HomeStorageClient::authenticate(const std::string& baseUrl, const std::string& username, const std::string& password, std::string& token, bool& canManage, std::string& error) const { + HttpClient::Response response; if (!http_.post(baseUrl + "/api/v1/auth/token", "", {"Authorization: Basic " + base64(username + ":" + password)}, response, error)) return false; + json_t* root = parse(response.body, error); if (!root) return false; token = str(root, "accessToken");canManage=false;json_t* scopes=json_object_get(root,"scopes");size_t index;json_t* scope;json_array_foreach(scopes,index,scope)if(json_is_string(scope)&&std::string(json_string_value(scope))=="catalog:manage")canManage=true;json_decref(root); return !token.empty(); +} + +bool HomeStorageClient::list(const ProviderConfig& provider, const std::string& folderId, const std::string& cursor, std::vector& files, std::string& next, std::string& error) const { + std::string url = provider.baseUrl + "/api/v1/catalog?parentId=" + (folderId.empty() ? "root" : folderId); if (!cursor.empty()) url += "&cursor=" + cursor; + std::vector headers; if (!provider.accessToken.empty()) headers.push_back("Authorization: Bearer " + provider.accessToken); + HttpClient::Response response; if (!http_.get(url, headers, response, error)) return false; return parseHomeStorageCatalogPayload(response.body, provider.id, files, next, error); +} + +bool HomeStorageClient::hide(const ProviderConfig& provider, const std::string& id, std::string& error) const { HttpClient::Response response; return http_.del(provider.baseUrl + "/api/v1/catalog/" + id,{"Authorization: Bearer " + provider.accessToken},response,error); } +std::string HomeStorageClient::mediaUrl(const ProviderConfig& provider, const RemoteEntry& file) const { return provider.baseUrl + "/api/v1/files/" + file.id + "/content"; } + +bool GoogleStorageProvider::list(const std::string& folder, bool shared, const std::string& cursor, std::vector& files, std::string& next, std::string& error) const { const size_t begin=files.size();if(!drive_.list(token_,folder,shared,cursor,files,next,error))return false;for(size_t i=begin;i{}:std::vector{"Authorization: Bearer "+token_}}; } +bool HomeStorageProvider::list(const std::string& folder, bool, const std::string& cursor, std::vector& files, std::string& next, std::string& error) const { return home_.list(config_,folder,cursor,files,next,error); } +DownloadRequest HomeStorageProvider::downloadRequest(const RemoteEntry& file) const { return {home_.mediaUrl(config_,file),config_.accessToken.empty()?std::vector{}:std::vector{"Authorization: Bearer "+config_.accessToken}}; } + +bool discoverHomeStorage(std::vector& results, std::string& error) { +#ifdef __SWITCH__ + const int socketFd = socket(AF_INET, SOCK_DGRAM, 0); if (socketFd < 0) { error = i18n::tr(i18n::TextId::NoStorageFound); return false; } + int enabled=1;setsockopt(socketFd,SOL_SOCKET,SO_BROADCAST,&enabled,sizeof(enabled));fcntl(socketFd,F_SETFL,O_NONBLOCK); + sockaddr_in target{};target.sin_family=AF_INET;target.sin_port=htons(8080);target.sin_addr.s_addr=INADDR_BROADCAST;const char probe[]="SWITCHDRIVE_HOME_DISCOVER_V1";sendto(socketFd,probe,sizeof(probe)-1,0,reinterpret_cast(&target),sizeof(target)); + const auto end=std::chrono::steady_clock::now()+std::chrono::seconds(3);std::array buffer{}; + while(std::chrono::steady_clock::now()(&source),&length);if(size<=0){usleep(50000);continue;}buffer[size]=0;HomeStorageHealth health;std::string parseError;if(parseHomeStorageHealthPayload(std::string(buffer.data(),size),health,parseError)){char address[INET_ADDRSTRLEN]{};inet_ntop(AF_INET,&source.sin_addr,address,sizeof(address));results.push_back({std::string("http://")+address+":"+std::to_string(health.httpPort),health});}}close(socketFd);deduplicateHomeStorageDiscoveries(results);return true; +#else + (void)results; error = i18n::tr(i18n::TextId::NoStorageFound); return false; +#endif +} + } // namespace switchdrive diff --git a/switch/source/ui.cpp b/switch/source/ui.cpp index 2725e83..0633f83 100644 --- a/switch/source/ui.cpp +++ b/switch/source/ui.cpp @@ -439,7 +439,7 @@ void Ui::present() { data.drawText(card.detail, x + 24, y + 120, 0, kMuted); SDL_SetClipRect(data.screen, nullptr); if (focused || card.action != HidNpadButton_A) { - const auto button = i18n::tr(focused ? i18n::TextId::ButtonA : card.action == HidNpadButton_X ? i18n::TextId::ButtonX : i18n::TextId::ButtonY); + const auto button = i18n::tr(focused ? i18n::TextId::ButtonA : card.action == HidNpadButton_X ? i18n::TextId::ButtonX : card.action == HidNpadButton_ZL ? i18n::TextId::ButtonZL : i18n::TextId::ButtonY); roundedRect(data.screen, x + 392, y + 22, 40, 40, 20, kSelected); data.drawText(button, x + 404, y + 28, 0, kAccent); } @@ -609,7 +609,7 @@ void Ui::scanInput() { if (data.pressed & HidNpadButton_A) { const int selected = data.focus.activate(data.cards.size()); if (selected >= 0) data.cardAction = data.cards[static_cast(selected)].action; - } else if (data.pressed & (HidNpadButton_X | HidNpadButton_Y)) { + } else if (data.pressed & (HidNpadButton_X | HidNpadButton_Y | HidNpadButton_ZL)) { for (size_t index = 0; index < data.cards.size(); ++index) { if (data.cards[index].action & data.pressed) { data.focus = {index, false}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0b7fe6d..c75bab9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,9 +6,12 @@ find_path(ZSTD_INCLUDE_DIR zstd.h REQUIRED) find_library(ZSTD_LIBRARY NAMES zstd REQUIRED) find_path(MBEDTLS_INCLUDE_DIR mbedtls/aes.h REQUIRED) find_library(MBEDCRYPTO_LIBRARY NAMES mbedcrypto REQUIRED) -add_executable(switch_drive_tests core_tests.cpp ../switch/source/core.cpp ../switch/source/installer.cpp ../switch/source/nsz.cpp ../switch/source/download.cpp ../switch/source/i18n.cpp ../switch/source/ui_model.cpp ../switch/source/qr.cpp) -target_include_directories(switch_drive_tests PRIVATE ../switch/include ${ZSTD_INCLUDE_DIR} ${MBEDTLS_INCLUDE_DIR}) -target_link_libraries(switch_drive_tests PRIVATE ${ZSTD_LIBRARY} ${MBEDCRYPTO_LIBRARY}) +find_package(CURL REQUIRED) +find_path(JANSSON_INCLUDE_DIR jansson.h REQUIRED) +find_library(JANSSON_LIBRARY NAMES jansson REQUIRED) +add_executable(switch_drive_tests core_tests.cpp ../switch/source/core.cpp ../switch/source/installer.cpp ../switch/source/nsz.cpp ../switch/source/download.cpp ../switch/source/network.cpp ../switch/source/i18n.cpp ../switch/source/ui_model.cpp ../switch/source/qr.cpp) +target_include_directories(switch_drive_tests PRIVATE ../switch/include ${ZSTD_INCLUDE_DIR} ${MBEDTLS_INCLUDE_DIR} ${JANSSON_INCLUDE_DIR}) +target_link_libraries(switch_drive_tests PRIVATE ${ZSTD_LIBRARY} ${MBEDCRYPTO_LIBRARY} CURL::libcurl ${JANSSON_LIBRARY}) enable_testing() add_test(NAME switch_drive_tests COMMAND switch_drive_tests) diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index b0488de..aeda8e5 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -294,6 +294,33 @@ void testDownloadRemoval(const fs::path& root) { } int main() { + std::string normalized; + assert(normalizeHomeStorageUrl("192.168.15.50:8080", normalized) && normalized == "http://192.168.15.50:8080"); + assert(normalizeHomeStorageUrl("storage.kore.qzz.io", normalized) && normalized == "https://storage.kore.qzz.io"); + assert(normalizeHomeStorageUrl("https://storage.example:8443", normalized) && normalized == "https://storage.example:8443"); + assert(!normalizeHomeStorageUrl("https://storage.example/library", normalized)); + assert(!normalizeHomeStorageUrl("https://user@storage.example", normalized)); + assert(!normalizeHomeStorageUrl("http://", normalized)); + assert(!normalizeHomeStorageUrl("192.168.1.500:8080", normalized)); + std::string providerError; + HomeStorageHealth parsedHealth; + assert(parseHomeStorageHealthPayload(R"({"service":"switch-drive-home-storage","protocolVersion":1,"instanceId":"abc","name":"Office","httpPort":8080,"authRequired":true})", parsedHealth, providerError)); + assert(parsedHealth.instanceId == "abc" && parsedHealth.name == "Office" && parsedHealth.authRequired); + assert(!parseHomeStorageHealthPayload(R"({"service":"another-service","protocolVersion":1,"instanceId":"abc","name":"Office","httpPort":8080})", parsedHealth, providerError)); + std::vector parsedCatalog; std::string nextCursor; + assert(parseHomeStorageCatalogPayload(R"({"items":[{"id":"opaque-id","parentId":"root","kind":"file","name":"Game.nsp","extension":".nsp","size":"42949672960","modifiedAt":"2026-01-01T00:00:00Z","etag":"\"sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","canDownload":true,"canHide":true}],"nextCursor":"next-page"})", "home-abc", parsedCatalog, nextCursor, providerError)); + assert(parsedCatalog.size() == 1 && parsedCatalog[0].providerId == "home-abc" && parsedCatalog[0].size == 42949672960ULL && parsedCatalog[0].checksum.kind == ChecksumKind::Sha256 && parsedCatalog[0].canHide && nextCursor == "next-page"); + std::vector discovered{{"http://192.168.1.2:8080", parsedHealth}, {"http://192.168.1.3:8080", parsedHealth}}; + deduplicateHomeStorageDiscoveries(discovered); assert(discovered.size() == 1); + RemoteEntry providerFile; providerFile.id = "opaque-id"; + GoogleStorageProvider google(HttpClient{}, "google-token"); + assert(google.capabilities().checksum == ChecksumKind::Md5 && !google.capabilities().catalogManage); + assert(google.downloadRequest(providerFile).url.find("opaque-id") != std::string::npos); + ProviderConfig homeConfig{"home-test", "Home Storage", "http://192.168.1.2:8080", "home-token", "root", ProviderKind::HomeStorage, true}; + HomeStorageProvider home(HttpClient{}, homeConfig); + const auto homeRequest = home.downloadRequest(providerFile); + assert(home.capabilities().checksum == ChecksumKind::Sha256 && home.capabilities().catalogManage && home.capabilities().lanDiscovery); + assert(homeRequest.url == "http://192.168.1.2:8080/api/v1/files/opaque-id/content" && homeRequest.headers.size() == 1); testHttpActivity(); testCnmtFileReading(); const auto placeholderSignature = [](const std::string& value) { @@ -484,6 +511,7 @@ int main() { saved.language = "es-ES"; Task task; task.id = "task1"; + task.providerId = "google-drive"; task.accountId = "a1"; task.remoteId = "r1"; task.displayName = "large.nsp"; @@ -499,6 +527,7 @@ int main() { saved.tasks.push_back(task); LibraryItem library; library.id = "id1"; + library.providerId = "google-drive"; library.accountId = "a1"; library.remoteId = "r1"; library.name = "a file.nro"; @@ -508,15 +537,19 @@ int main() { library.localState = LocalState::Present; library.storageKind = StorageKind::Regular; saved.library.push_back(library); + saved.providers.push_back({"home-test", "Home Storage", "http://192.168.1.2:8080", "revocable-token", "root", ProviderKind::HomeStorage, true}); assert(store.save(saved, error)); saved.sessionToken = "updated-token"; assert(store.save(saved, error)); saved.sessionToken = "latest-token"; assert(store.save(saved, error)); State loaded = store.load(); - assert(loaded.schemaVersion == 4 && loaded.language == "es-ES" && loaded.sessionToken == "latest-token" && loaded.tasks.size() == 1 && loaded.library.size() == 1); - assert(loaded.tasks[0].committedBytes == task.committedBytes && loaded.tasks[0].storageKind == StorageKind::Concatenated); + assert(loaded.schemaVersion == 5 && loaded.language == "es-ES" && loaded.sessionToken == "latest-token" && loaded.tasks.size() == 1 && loaded.library.size() == 1); + assert(loaded.tasks[0].providerId == "google-drive" && loaded.tasks[0].committedBytes == task.committedBytes && loaded.tasks[0].storageKind == StorageKind::Concatenated); assert(loaded.tasks[0].revision == "42" && loaded.tasks[0].etag == "\"etag\""); + assert(loaded.library[0].providerId == "google-drive" && loaded.library[0].md5 == task.md5); + const auto homeProvider = std::find_if(loaded.providers.begin(), loaded.providers.end(), [](const ProviderConfig& p){ return p.id == "home-test"; }); + assert(loaded.providers.size() == 2 && homeProvider != loaded.providers.end() && homeProvider->kind == ProviderKind::HomeStorage && homeProvider->canManageCatalog); { std::ifstream backup(root / "state-v2" / "state.json.bak"); const std::string contents((std::istreambuf_iterator(backup)), {}); @@ -527,7 +560,7 @@ int main() { languageState.language = language; StateStore languageStore(root / (std::string("language-") + language)); assert(languageStore.save(languageState, error)); - assert(languageStore.load().schemaVersion == 4 && languageStore.load().language == language); + assert(languageStore.load().schemaVersion == 5 && languageStore.load().language == language); } // Existing schema v1 state keeps its catalog entries and adopts regular storage. @@ -538,17 +571,23 @@ int main() { v1 << "{\"schemaVersion\":1,\"serviceUrl\":\"https://drive.test\",\"library\":[{\"id\":\"old\",\"accountId\":\"a\",\"remoteId\":\"r\",\"name\":\"old.nsp\",\"localPath\":\"/tmp/old.nsp\",\"md5\":\"x\"}]}"; } State migrated = StateStore(v1Root).load(); - assert(migrated.schemaVersion == 4 && migrated.language == "en-US" && migrated.library.size() == 1 && migrated.library[0].storageKind == StorageKind::Regular && migrated.library[0].nspInstallState == NspInstallState::None && migrated.tasks.empty()); + assert(migrated.schemaVersion == 5 && migrated.activeProviderId == "google-drive" && migrated.providers.size() == 1 && migrated.providers[0].kind == ProviderKind::GoogleDrive && migrated.language == "en-US" && migrated.library.size() == 1 && migrated.library[0].providerId == "google-drive" && migrated.library[0].storageKind == StorageKind::Regular && migrated.library[0].nspInstallState == NspInstallState::None && migrated.tasks.empty()); const auto v2Root = root / "state-v2-migration"; fs::create_directories(v2Root); { std::ofstream v2(v2Root / "state.json"); v2 << "{\"schemaVersion\":2,\"tasks\":[]}"; } - assert(StateStore(v2Root).load().schemaVersion == 4 && StateStore(v2Root).load().language == "en-US"); + assert(StateStore(v2Root).load().schemaVersion == 5 && StateStore(v2Root).load().language == "en-US"); const auto v3Root = root / "state-v3"; fs::create_directories(v3Root); { std::ofstream v3(v3Root / "state.json"); v3 << "{\"schemaVersion\":3}"; } - assert(StateStore(v3Root).load().schemaVersion == 4 && StateStore(v3Root).load().language == "en-US"); + assert(StateStore(v3Root).load().schemaVersion == 5 && StateStore(v3Root).load().language == "en-US"); + const auto v4Root = root / "state-v4"; + fs::create_directories(v4Root); + { std::ofstream v4(v4Root / "state.json"); v4 << "{\"schemaVersion\":4,\"sessionToken\":\"legacy-session\",\"accounts\":[{\"id\":\"a\",\"email\":\"old@example.test\"}],\"tasks\":[{\"id\":\"t\",\"accountId\":\"a\",\"remoteId\":\"remote\",\"displayName\":\"old.nsp\",\"expectedSize\":1}],\"library\":[{\"id\":\"l\",\"accountId\":\"a\",\"remoteId\":\"remote\",\"name\":\"old.nsp\",\"size\":1}]}"; } + const State migratedV4 = StateStore(v4Root).load(); + assert(migratedV4.schemaVersion == 5 && migratedV4.sessionToken == "legacy-session" && migratedV4.accounts.size() == 1 && migratedV4.tasks.size() == 1 && migratedV4.library.size() == 1); + assert(migratedV4.tasks[0].providerId == "google-drive" && migratedV4.library[0].providerId == "google-drive" && migratedV4.providers.size() == 1); const auto invalidLanguageRoot = root / "state-invalid-language"; fs::create_directories(invalidLanguageRoot); { std::ofstream invalid(invalidLanguageRoot / "state.json"); invalid << "{\"schemaVersion\":4,\"language\":\"es-es\"}"; } From f789a3e45ff3511aed6b27c90f09db4eeb856e4f Mon Sep 17 00:00:00 2001 From: "Gabriel A." Date: Sun, 13 Sep 2026 19:43:41 -0300 Subject: [PATCH 2/3] =?UTF-8?q?Aprimar=20a=20l=C3=B3gica=20de=20recupera?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20tokens=20CSRF=20e=20adicionar=20testes=20d?= =?UTF-8?q?e=20login=20no=20painel=20administrativo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- home-storage/src/HomeStorage.Api/AdminPanel.cs | 2 +- home-storage/tests/HomeStorage.Tests/ApiTests.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/home-storage/src/HomeStorage.Api/AdminPanel.cs b/home-storage/src/HomeStorage.Api/AdminPanel.cs index 446f7f8..d9e6fdd 100644 --- a/home-storage/src/HomeStorage.Api/AdminPanel.cs +++ b/home-storage/src/HomeStorage.Api/AdminPanel.cs @@ -50,7 +50,7 @@ public static void Map(WebApplication app, RuntimeOptions runtime) { var settings = await db.Settings.AsNoTracking().SingleAsync(); var files = await db.Catalog.CountAsync(x => x.Kind == CatalogKind.File && x.Active && !x.Suppressed); var hidden = await db.Catalog.CountAsync(x => x.Suppressed); var tokenCount = await db.DeviceTokens.CountAsync(x => x.RevokedAt == null); - var catalog = await db.Catalog.AsNoTracking().OrderBy(x => x.RelativePath).Take(500).ToListAsync(); var devices = await db.DeviceTokens.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(100).ToListAsync(); var csrf = anti.GetAndStoreTokens(ctx).RequestToken!; + var catalog = await db.Catalog.AsNoTracking().OrderBy(x => x.RelativePath).Take(500).ToListAsync(); var devices = (await db.DeviceTokens.AsNoTracking().ToListAsync()).OrderByDescending(x => x.CreatedAt).Take(100).ToList(); var csrf = anti.GetAndStoreTokens(ctx).RequestToken!; var body = $"

Status: {H(settings.LastScanStatus)} · Files: {files} · Hidden: {hidden} · Devices: {tokenCount}

Library: {H(settings.LibraryPath)}

" + $"
{Token(csrf)}
" + $"
{Token(csrf)}
" + diff --git a/home-storage/tests/HomeStorage.Tests/ApiTests.cs b/home-storage/tests/HomeStorage.Tests/ApiTests.cs index 3d23521..efeb5bc 100644 --- a/home-storage/tests/HomeStorage.Tests/ApiTests.cs +++ b/home-storage/tests/HomeStorage.Tests/ApiTests.cs @@ -90,6 +90,9 @@ public async Task FirstSetupRequiresTokenAndStoresOnlyPasswordHashes() using var response = await client.PostAsync("/setup", new FormUrlEncodedContent(fields), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var admin = await db.Admins.SingleAsync(TestContext.Current.CancellationToken); var library = await db.LibraryCredentials.SingleAsync(TestContext.Current.CancellationToken); Assert.DoesNotContain("administrator-password", admin.PasswordHash); Assert.DoesNotContain("library-password", library.PasswordHash); Assert.True(library.AllowCatalogManage); + var login = await client.GetStringAsync("/login", TestContext.Current.CancellationToken); csrf = Regex.Match(login, "name=__RequestVerificationToken value=\"([^\"]+)\"").Groups[1].Value; Assert.NotEmpty(csrf); + using var loginResponse = await client.PostAsync("/login", new FormUrlEncodedContent(new Dictionary { { "__RequestVerificationToken", csrf }, { "user", "admin" }, { "password", "administrator-password" } }), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + using var panelResponse = await client.GetAsync("/admin", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, panelResponse.StatusCode); } private WebApplicationFactory Factory() From 39f1adfc023c973a4938657a8b5910a5a41e5d4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:15:53 +0000 Subject: [PATCH 3/3] Fix large HEAD response buffering in Home Storage test Co-authored-by: korefs <33636512+korefs@users.noreply.github.com> --- home-storage/tests/HomeStorage.Tests/ApiTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/home-storage/tests/HomeStorage.Tests/ApiTests.cs b/home-storage/tests/HomeStorage.Tests/ApiTests.cs index efeb5bc..8024672 100644 --- a/home-storage/tests/HomeStorage.Tests/ApiTests.cs +++ b/home-storage/tests/HomeStorage.Tests/ApiTests.cs @@ -26,7 +26,7 @@ public async Task StreamsHeadAndRangesFromSparseLargeFile() await using var factory = Factory(); var client = factory.CreateClient(); string id; using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var settings = await db.Settings.SingleAsync(); settings.AuthRequired = false; var info = new FileInfo(path); var entry = new CatalogEntry { Kind = CatalogKind.File, RelativePath = "large.bin", Name = "large.bin", Extension = ".bin", Size = info.Length, ModifiedUtcTicks = info.LastWriteTimeUtc.Ticks, Sha256 = new string('a', 64), ETag = "\"sha256-" + new string('a', 64) + "\"", Active = true }; id = entry.Id; db.Catalog.Add(entry); await db.SaveChangesAsync(); } - using var head = new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{id}/content"); using var headResponse = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.OK, headResponse.StatusCode); Assert.Equal(length, headResponse.Content.Headers.ContentLength); Assert.Empty(await headResponse.Content.ReadAsByteArrayAsync()); Assert.Contains("bytes", headResponse.Headers.AcceptRanges); Assert.NotNull(headResponse.Headers.ETag); Assert.NotNull(headResponse.Content.Headers.LastModified); var etag = headResponse.Headers.ETag!; + using var head = new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{id}/content"); using var headResponse = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.OK, headResponse.StatusCode); Assert.Equal(length, headResponse.Content.Headers.ContentLength); await using (var headBody = await headResponse.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken)) { var probe = new byte[1]; Assert.Equal(0, await headBody.ReadAsync(probe, TestContext.Current.CancellationToken)); } Assert.Contains("bytes", headResponse.Headers.AcceptRanges); Assert.NotNull(headResponse.Headers.ETag); Assert.NotNull(headResponse.Content.Headers.LastModified); var etag = headResponse.Headers.ETag!; using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); request.Headers.Range = new RangeHeaderValue(length - 16, null); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); Assert.Equal(16, response.Content.Headers.ContentLength); Assert.Equal(length - 16, response.Content.Headers.ContentRange!.From); var bytes = await response.Content.ReadAsByteArrayAsync(); Assert.Equal(16, bytes.Length); Assert.Equal(0x5a, bytes[^1]); using var suffix = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); suffix.Headers.Range = new RangeHeaderValue(null, 4); using var suffixResponse = await client.SendAsync(suffix, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, suffixResponse.StatusCode); Assert.Equal(4, suffixResponse.Content.Headers.ContentLength); Assert.Equal(length - 4, suffixResponse.Content.Headers.ContentRange!.From); using var resumed = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{id}/content"); resumed.Headers.Range = new RangeHeaderValue(0, 7); resumed.Headers.IfRange = new RangeConditionHeaderValue(etag); using var resumedResponse = await client.SendAsync(resumed, HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.PartialContent, resumedResponse.StatusCode); Assert.Equal(8, resumedResponse.Content.Headers.ContentLength);