From 4a0973b68197351366d85019f238f407b6c1ecc1 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 28 Feb 2026 15:30:57 +0100 Subject: [PATCH] Added avalonia integration and remote proof of concept --- .github/copilot-instructions.md | 90 +++ .../IRaceUploadCommunicationClient.cs | 30 + .../Catalog.Communication.csproj | 17 + .../CatalogCommunicationOptions.cs | 14 + ...ommunicationServiceCollectionExtensions.cs | 46 ++ .../Models/AdminLoginRequest.cs | 10 + .../Models/AdminPhotoEndpoint.cs | 8 + .../Models/MediaFileResponse.cs | 16 + .../Models/RaceFileUploadRequest.cs | 16 + .../Models/RaceImageRemoveRequest.cs | 10 + .../Models/RaceImageUploadRequest.cs | 18 + .../Models/RawEndpointResponse.cs | 12 + .../Models/UploadFileResponse.cs | 18 + .../Models/UploadImageResponse.cs | 15 + .../RaceUploadCommunicationClient.cs | 506 +++++++++++++ Catalog.sln | 15 + docs/api-race-upload-spec.md | 230 ++++++ docs/openapi-race-upload.yaml | 679 ++++++++++++++++++ imagecatalog/AvaloniaApp.axaml.cs | 3 +- imagecatalog/AvaloniaMainWindow.axaml | 31 + imagecatalog/AvaloniaMainWindow.axaml.cs | 228 +++++- imagecatalog/ImageCatalog 2.csproj | 1 + imagecatalog/Program.cs | 36 +- 23 files changed, 2043 insertions(+), 6 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs create mode 100644 Catalog.Communication/Catalog.Communication.csproj create mode 100644 Catalog.Communication/CatalogCommunicationOptions.cs create mode 100644 Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs create mode 100644 Catalog.Communication/Models/AdminLoginRequest.cs create mode 100644 Catalog.Communication/Models/AdminPhotoEndpoint.cs create mode 100644 Catalog.Communication/Models/MediaFileResponse.cs create mode 100644 Catalog.Communication/Models/RaceFileUploadRequest.cs create mode 100644 Catalog.Communication/Models/RaceImageRemoveRequest.cs create mode 100644 Catalog.Communication/Models/RaceImageUploadRequest.cs create mode 100644 Catalog.Communication/Models/RawEndpointResponse.cs create mode 100644 Catalog.Communication/Models/UploadFileResponse.cs create mode 100644 Catalog.Communication/Models/UploadImageResponse.cs create mode 100644 Catalog.Communication/RaceUploadCommunicationClient.cs create mode 100644 docs/api-race-upload-spec.md create mode 100644 docs/openapi-race-upload.yaml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3734723 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,90 @@ +# Copilot Instructions + +## Build & Test Commands + +```powershell +# Build +dotnet build Catalog.sln + +# Run all tests +dotnet test MaddoShared.Tests + +# Run a single test +dotnet test MaddoShared.Tests --filter "FullyQualifiedName~MethodName" + +# Benchmarks (modes: quick | all | parallel | chunks | sizes | stress) +.\run-benchmarks.ps1 quick + +# Publish release build (self-contained Windows EXE) +dotnet publish "imagecatalog\ImageCatalog 2.csproj" -c Release -r win-x64 --self-contained +``` + +## Architecture + +This is a WinForms/WPF image cataloging application targeting .NET 10.0-windows. + +### Projects + +| Project | Purpose | +|---------|---------| +| **imagecatalog** | Main desktop application — WinForms (default), WPF (`--wpf`), or Avalonia (`--avalonia`) | +| **MaddoShared** | Shared image processing library (the core) | +| **MaddoShared.Tests** | Unit tests for MaddoShared | +| **MaddoShared.Benchmarks** | BenchmarkDotNet performance benchmarks | +| **WPFCatalog** | Alternate WPF UI (secondary) | +| **ImageCatalogCS / ImageCatalogParallel** | Legacy/experimental variants | +| **CatalogLib / CatalogLibVb / CatalogVbLib** | Legacy VB.NET libraries | + +The main app selects its UI at startup via command-line flag: +- *(default)* — WinForms (`MainForm`) +- `--wpf` — WPF with MahApps.Metro (`MainWindow`) +- `--avalonia` — Avalonia with Fluent theme (`AvaloniaMainWindow`) + +All three UIs bind to the same `DataModel`. Dialog events (`SelectSourceFolderRequested`, etc.) are subscribed in each window's code-behind. `DataModel.UiInvoker` must be set by the active UI to enable cross-thread UI updates (Avalonia sets this to `Dispatcher.UIThread.Invoke`; WPF uses `Application.Current.Dispatcher`). + +### Core Flow + +1. User configures paths/settings in the UI (`DataModel.cs` — MVVM ViewModel) +2. `ProcessImagesCommand` triggers `ImageCreationService` +3. `ImageCreationService` processes files in parallel chunks, with configurable concurrency and batch size (GC flush between chunks) +4. Each file is handled by an `IImageCreator` implementation (GDI+ or ImageSharp) +5. Output: resized/watermarked/overlaid images written to a destination folder hierarchy + +### Key Abstractions (MaddoShared) + +- **`IImageCreator`** — single async method to process one image; two implementations: `ImageCreatorGDI` (System.Drawing) and `ImageCreatorSharp` (SixLabors.ImageSharp) +- **`ImageCreationService`** — parallel orchestrator; uses `AsyncEnumerator` with chunking; loads logo once, clones per thread for thread safety +- **`ImageState`** — per-file processing context (input path, EXIF orientation, thumbnail sizes, overlays, logo, rotation) +- **`PicSettings`** — 50+ property configuration model (dimensions, fonts, colors, JPEG quality, watermark, logo positioning, `ImageCreatorProvider` selector) +- **`FileHelperSharp`** — recursive file enumeration with folder-per-N-files mapping and counter formatting + +### Implementation Selection + +`PicSettings.ImageCreatorProvider` switches between `"Sharp"` (SixLabors.ImageSharp) and `"Alternate"` (GDI+) at runtime. + +## Conventions + +### C# Style +- File-scoped namespaces everywhere: `namespace MaddoShared;` +- Nullable reference types enabled (`enable`) +- Implicit usings enabled +- `ConfigureAwait(false)` on all `await` calls in library code + +### Dependency Injection +- Constructor injection throughout; loggers typed as `ILogger` +- Main app wires services in `Program.cs` via `IServiceCollection` + +### Testing +- MSTest with `[TestClass]` / `[TestMethod]` +- FluentAssertions for assertions +- Moq for mocking +- Factory helper pattern in tests: `CreateService(Action configure = null)` methods for flexible test setup + +### Async / Parallelism +- All image I/O is `async Task` +- `ImageCreationService` uses configurable `MaxDegreeOfParallelism` and `ChunkSize`; explicit `GC.Collect()` between chunks to manage memory under batch load + +### Versioning & CI +- Semantic versioning via **GitVersion** (mode: `ContinuousDelivery`, current base: `3.2.0`) +- GitLab CI pipeline: builds → single-file self-contained EXE → GitLab Release +- Private NuGet packages scoped to `AIFotoONLUS.*` prefix, routed to the GitLab package registry (see `NuGet.Config`) diff --git a/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs new file mode 100644 index 0000000..799c766 --- /dev/null +++ b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs @@ -0,0 +1,30 @@ +using Catalog.Communication.Models; + +namespace Catalog.Communication.Abstractions; + +public interface IRaceUploadCommunicationClient +{ + Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); + + Task LogoutAdminAsync(CancellationToken cancellationToken = default); + + Task UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default); + + Task RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default); + + Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default); + + Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default); + + Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default); + + Task ExecutePublicLogonAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default); + + Task ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default); + + Task ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default); + + Task DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default); + + Task DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default); +} diff --git a/Catalog.Communication/Catalog.Communication.csproj b/Catalog.Communication/Catalog.Communication.csproj new file mode 100644 index 0000000..cb8dc94 --- /dev/null +++ b/Catalog.Communication/Catalog.Communication.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + Library + enable + enable + + + + + + + + + + diff --git a/Catalog.Communication/CatalogCommunicationOptions.cs b/Catalog.Communication/CatalogCommunicationOptions.cs new file mode 100644 index 0000000..ac83eea --- /dev/null +++ b/Catalog.Communication/CatalogCommunicationOptions.cs @@ -0,0 +1,14 @@ +namespace Catalog.Communication; + +public sealed class CatalogCommunicationOptions +{ + public required Uri BaseUri { get; set; } + + public string AdminPageBasePath { get; set; } = "admin/pg"; + + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + + public int RetryCount { get; set; } = 2; + + public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromMilliseconds(250); +} diff --git a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..0090ca5 --- /dev/null +++ b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using System.Net; +using Catalog.Communication.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Catalog.Communication.DependencyInjection; + +public static class CatalogCommunicationServiceCollectionExtensions +{ + public static IServiceCollection AddCatalogCommunication(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services + .AddOptions() + .Configure(configure) + .Validate(o => o.BaseUri is not null, "CatalogCommunicationOptions.BaseUri is required.") + .Validate(o => !string.IsNullOrWhiteSpace(o.AdminPageBasePath), "AdminPageBasePath is required.") + .Validate(o => o.RequestTimeout > TimeSpan.Zero, "RequestTimeout must be greater than zero.") + .Validate(o => o.RetryCount >= 0 && o.RetryCount <= 10, "RetryCount must be between 0 and 10.") + .Validate(o => o.RetryBaseDelay > TimeSpan.Zero, "RetryBaseDelay must be greater than zero."); + + services.TryAddSingleton(); + + services + .AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = options.BaseUri; + }) + .ConfigurePrimaryHttpMessageHandler(sp => + { + var cookieContainer = sp.GetRequiredService(); + return new HttpClientHandler + { + UseCookies = true, + CookieContainer = cookieContainer, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + }; + }); + + return services; + } +} diff --git a/Catalog.Communication/Models/AdminLoginRequest.cs b/Catalog.Communication/Models/AdminLoginRequest.cs new file mode 100644 index 0000000..170faf4 --- /dev/null +++ b/Catalog.Communication/Models/AdminLoginRequest.cs @@ -0,0 +1,10 @@ +namespace Catalog.Communication.Models; + +public sealed class AdminLoginRequest +{ + public required string Login { get; init; } + + public required string Password { get; init; } + + public string Command { get; init; } = "check"; +} diff --git a/Catalog.Communication/Models/AdminPhotoEndpoint.cs b/Catalog.Communication/Models/AdminPhotoEndpoint.cs new file mode 100644 index 0000000..b1aad98 --- /dev/null +++ b/Catalog.Communication/Models/AdminPhotoEndpoint.cs @@ -0,0 +1,8 @@ +namespace Catalog.Communication.Models; + +public enum AdminPhotoEndpoint +{ + Foto = 0, + TipoGara = 1, + LogFoto = 2, +} diff --git a/Catalog.Communication/Models/MediaFileResponse.cs b/Catalog.Communication/Models/MediaFileResponse.cs new file mode 100644 index 0000000..2bdfecd --- /dev/null +++ b/Catalog.Communication/Models/MediaFileResponse.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace Catalog.Communication.Models; + +public sealed class MediaFileResponse +{ + public required HttpStatusCode StatusCode { get; init; } + + public required byte[] Content { get; init; } + + public string? ContentType { get; init; } + + public string? FileName { get; init; } + + public required IReadOnlyDictionary> Headers { get; init; } +} diff --git a/Catalog.Communication/Models/RaceFileUploadRequest.cs b/Catalog.Communication/Models/RaceFileUploadRequest.cs new file mode 100644 index 0000000..83cfb3b --- /dev/null +++ b/Catalog.Communication/Models/RaceFileUploadRequest.cs @@ -0,0 +1,16 @@ +namespace Catalog.Communication.Models; + +public sealed class RaceFileUploadRequest +{ + public int? CodFile { get; init; } + + public long? Id { get; init; } + + public required string FileName { get; init; } + + public required Stream FileStream { get; init; } + + public string FormFieldName { get; init; } = "fileName"; + + public string? ContentType { get; init; } +} diff --git a/Catalog.Communication/Models/RaceImageRemoveRequest.cs b/Catalog.Communication/Models/RaceImageRemoveRequest.cs new file mode 100644 index 0000000..0e9698a --- /dev/null +++ b/Catalog.Communication/Models/RaceImageRemoveRequest.cs @@ -0,0 +1,10 @@ +namespace Catalog.Communication.Models; + +public sealed class RaceImageRemoveRequest +{ + public required long Id { get; init; } + + public required int CodImage { get; init; } + + public int? TotImgNumber { get; init; } +} diff --git a/Catalog.Communication/Models/RaceImageUploadRequest.cs b/Catalog.Communication/Models/RaceImageUploadRequest.cs new file mode 100644 index 0000000..ee049f1 --- /dev/null +++ b/Catalog.Communication/Models/RaceImageUploadRequest.cs @@ -0,0 +1,18 @@ +namespace Catalog.Communication.Models; + +public sealed class RaceImageUploadRequest +{ + public required long Id { get; init; } + + public required int CodImage { get; init; } + + public int? TotImgNumber { get; init; } + + public required string FileName { get; init; } + + public required Stream FileStream { get; init; } + + public string FormFieldName { get; init; } = "imgFile"; + + public string? ContentType { get; init; } +} diff --git a/Catalog.Communication/Models/RawEndpointResponse.cs b/Catalog.Communication/Models/RawEndpointResponse.cs new file mode 100644 index 0000000..0f0f36a --- /dev/null +++ b/Catalog.Communication/Models/RawEndpointResponse.cs @@ -0,0 +1,12 @@ +using System.Net; + +namespace Catalog.Communication.Models; + +public sealed class RawEndpointResponse +{ + public required HttpStatusCode StatusCode { get; init; } + + public required string Body { get; init; } + + public required IReadOnlyDictionary> Headers { get; init; } +} diff --git a/Catalog.Communication/Models/UploadFileResponse.cs b/Catalog.Communication/Models/UploadFileResponse.cs new file mode 100644 index 0000000..e68e400 --- /dev/null +++ b/Catalog.Communication/Models/UploadFileResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Catalog.Communication.Models; + +public sealed class UploadFileResponse +{ + [JsonPropertyName("result")] + public bool Result { get; init; } + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + [JsonPropertyName("fileName")] + public string FileName { get; init; } = string.Empty; + + [JsonPropertyName("fileNameLink")] + public string FileNameLink { get; init; } = string.Empty; +} diff --git a/Catalog.Communication/Models/UploadImageResponse.cs b/Catalog.Communication/Models/UploadImageResponse.cs new file mode 100644 index 0000000..5ecc353 --- /dev/null +++ b/Catalog.Communication/Models/UploadImageResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Catalog.Communication.Models; + +public sealed class UploadImageResponse +{ + [JsonPropertyName("result")] + public bool Result { get; init; } + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + + [JsonPropertyName("imgPath")] + public string ImgPath { get; init; } = string.Empty; +} diff --git a/Catalog.Communication/RaceUploadCommunicationClient.cs b/Catalog.Communication/RaceUploadCommunicationClient.cs new file mode 100644 index 0000000..a5f9d49 --- /dev/null +++ b/Catalog.Communication/RaceUploadCommunicationClient.cs @@ -0,0 +1,506 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Catalog.Communication.Abstractions; +using Catalog.Communication.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Catalog.Communication; + +public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient +{ + private const string AdminMenuPath = "admin/menu/Menu4.abl"; + private const string PublicLogonPath = "Logon.abl"; + private const string UsersPath = "Users.abl"; + private const string Foto2Path = "Foto2.abl"; + private const string ThumbnailPath = "foto"; + private const string OriginalPath = "fotoOriginali"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IOptions _options; + + public RaceUploadCommunicationClient( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options; + _logger = logger; + } + + public Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var formFields = new Dictionary + { + ["login"] = request.Login, + ["pwd"] = request.Password, + ["cmdIU"] = request.Command, + }; + + return PostFormAsync(AdminMenuPath, formFields, "admin-login", cancellationToken); + } + + public Task LogoutAdminAsync(CancellationToken cancellationToken = default) + { + var formFields = new Dictionary + { + ["cmdIU"] = "login", + }; + + return PostFormAsync(AdminMenuPath, formFields, "admin-logout", cancellationToken); + } + + public Task UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + return PostMultipartAndParseUploadAsync( + GetAdminPagePath("Gara"), + "gara-upload-image", + request.FileStream, + request.FileName, + request.FormFieldName, + request.ContentType, + static fields => + { + fields["cmd"] = "loadImg"; + }, + new Dictionary + { + ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + cancellationToken); + } + + public Task RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var fields = new Dictionary + { + ["cmd"] = "removeImg", + ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + return PostFormAndParseUploadAsync(GetAdminPagePath("Gara"), fields, "gara-remove-image", cancellationToken); + } + + public Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + return PostMultipartAndParseUploadAsync( + GetAdminPagePath("Gara"), + "gara-upload-file", + request.FileStream, + request.FileName, + request.FormFieldName, + request.ContentType, + static fields => + { + fields["cmd"] = "saveFile"; + }, + new Dictionary + { + ["codFile"] = request.CodFile?.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["id"] = request.Id?.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + cancellationToken); + } + + public Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) + { + return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken); + } + + public Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) + { + var path = endpoint switch + { + AdminPhotoEndpoint.Foto => GetAdminPagePath("Foto"), + AdminPhotoEndpoint.TipoGara => GetAdminPagePath("TipoGara"), + AdminPhotoEndpoint.LogFoto => GetAdminPagePath("LogFoto"), + _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Unsupported endpoint."), + }; + + return PostFormAsync(path, formFields, $"photo-admin-{endpoint}", cancellationToken); + } + + public Task ExecutePublicLogonAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) + { + return PostFormAsync(PublicLogonPath, formFields, "public-logon", cancellationToken); + } + + public Task ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default) + { + return SendCommandAsync(method, UsersPath, formFields, "public-users", cancellationToken); + } + + public Task ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default) + { + return SendCommandAsync(method, Foto2Path, formFields, "public-foto2", cancellationToken); + } + + public Task DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filename); + return DownloadFileAsync(ThumbnailPath, filename, idFoto, "media-thumbnail", cancellationToken); + } + + public Task DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filename); + return DownloadFileAsync(OriginalPath, filename, idFoto, "media-original", cancellationToken); + } + + private Task SendCommandAsync(HttpMethod method, string path, IReadOnlyDictionary? formFields, string operationName, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(method); + + if (method == HttpMethod.Get) + { + var relativePath = AppendQuery(path, formFields); + return GetAsync(relativePath, operationName, cancellationToken); + } + + if (method == HttpMethod.Post) + { + return PostFormAsync(path, formFields ?? new Dictionary(), operationName, cancellationToken); + } + + throw new NotSupportedException($"Only GET and POST are supported. Requested method: {method}."); + } + + private Task GetAsync(string relativePath, string operationName, CancellationToken cancellationToken) + { + return ExecuteWithResilienceAsync( + () => new HttpRequestMessage(HttpMethod.Get, relativePath), + ToRawResponseAsync, + operationName, + cancellationToken); + } + + private Task PostFormAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken) + { + return ExecuteWithResilienceAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Post, relativePath) + { + Content = BuildFormContent(formFields), + }; + + return request; + }, + ToRawResponseAsync, + operationName, + cancellationToken); + } + + private Task PostFormAndParseUploadAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken) + { + return ExecuteWithResilienceAsync( + () => + { + var request = new HttpRequestMessage(HttpMethod.Post, relativePath) + { + Content = BuildFormContent(formFields), + }; + + return request; + }, + async (response, token) => + { + var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false); + return ParseSingleItemArray(raw.Body); + }, + operationName, + cancellationToken); + } + + private async Task PostMultipartAndParseUploadAsync( + string relativePath, + string operationName, + Stream fileStream, + string fileName, + string formFieldName, + string? contentType, + Action> configureRequiredFields, + IReadOnlyDictionary optionalFields, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fileStream); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(formFieldName); + + var payload = await ReadAllBytesAsync(fileStream, cancellationToken).ConfigureAwait(false); + + return await ExecuteWithResilienceAsync( + () => + { + var multipart = new MultipartFormDataContent(); + var fields = new Dictionary(); + configureRequiredFields(fields); + + foreach (var field in fields) + { + if (string.IsNullOrWhiteSpace(field.Value)) + { + continue; + } + + multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key); + } + + foreach (var field in optionalFields) + { + if (string.IsNullOrWhiteSpace(field.Value)) + { + continue; + } + + multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key); + } + + var fileContent = new ByteArrayContent(payload); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream"); + multipart.Add(fileContent, formFieldName, fileName); + + return new HttpRequestMessage(HttpMethod.Post, relativePath) + { + Content = multipart, + }; + }, + async (response, token) => + { + var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false); + return ParseSingleItemArray(raw.Body); + }, + operationName, + cancellationToken).ConfigureAwait(false); + } + + private Task DownloadFileAsync(string basePath, string filename, long? idFoto, string operationName, CancellationToken cancellationToken) + { + var escapedFileName = Uri.EscapeDataString(filename); + var relativePath = idFoto.HasValue + ? $"{basePath}/{escapedFileName}?id_foto={idFoto.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + : $"{basePath}/{escapedFileName}"; + + return ExecuteWithResilienceAsync( + () => new HttpRequestMessage(HttpMethod.Get, relativePath), + async (response, token) => + { + var content = await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false); + return new MediaFileResponse + { + StatusCode = response.StatusCode, + Content = content, + ContentType = response.Content.Headers.ContentType?.MediaType, + FileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName, + Headers = BuildHeaders(response), + }; + }, + operationName, + cancellationToken); + } + + private async Task ExecuteWithResilienceAsync( + Func requestFactory, + Func> responseFactory, + string operationName, + CancellationToken cancellationToken) + { + var options = _options.Value; + Exception? lastException = null; + + for (var attempt = 0; attempt <= options.RetryCount; attempt++) + { + using var request = requestFactory(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(options.RequestTimeout); + + HttpResponseMessage? response = null; + + try + { + response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token) + .ConfigureAwait(false); + + if (IsRetryableStatusCode(response.StatusCode) && attempt < options.RetryCount) + { + _logger.LogWarning( + "Operation {OperationName} received retryable status code {StatusCode} at attempt {Attempt}/{MaxAttempts}.", + operationName, + (int)response.StatusCode, + attempt + 1, + options.RetryCount + 1); + + await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); + continue; + } + + return await responseFactory(response, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + lastException = ex; + + if (attempt < options.RetryCount) + { + _logger.LogWarning(ex, "Operation {OperationName} timed out at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); + await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); + continue; + } + + _logger.LogError(ex, "Operation {OperationName} timed out after {MaxAttempts} attempts.", operationName, options.RetryCount + 1); + throw; + } + catch (HttpRequestException ex) + { + lastException = ex; + + if (attempt < options.RetryCount) + { + _logger.LogWarning(ex, "Operation {OperationName} failed with transient HTTP error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); + await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false); + continue; + } + + _logger.LogError(ex, "Operation {OperationName} failed with HTTP error after {MaxAttempts} attempts.", operationName, options.RetryCount + 1); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Operation {OperationName} failed with unexpected error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1); + throw; + } + finally + { + response?.Dispose(); + } + } + + throw new HttpRequestException($"Operation '{operationName}' failed after {options.RetryCount + 1} attempts.", lastException); + } + + private static bool IsRetryableStatusCode(HttpStatusCode statusCode) + { + return statusCode is HttpStatusCode.RequestTimeout + or HttpStatusCode.TooManyRequests + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout + or HttpStatusCode.InternalServerError; + } + + private static async Task DelayBeforeRetryAsync(CatalogCommunicationOptions options, int attempt, CancellationToken cancellationToken) + { + var delay = TimeSpan.FromMilliseconds(options.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + private static FormUrlEncodedContent BuildFormContent(IReadOnlyDictionary formFields) + { + var pairs = formFields + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) + .Select(kvp => new KeyValuePair(kvp.Key, kvp.Value!)); + + return new FormUrlEncodedContent(pairs); + } + + private static string AppendQuery(string path, IReadOnlyDictionary? query) + { + if (query is null || query.Count == 0) + { + return path; + } + + var encodedPairs = query + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) + .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}") + .ToArray(); + + if (encodedPairs.Length == 0) + { + return path; + } + + var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return string.Concat(path, separator, string.Join("&", encodedPairs)); + } + + private static async Task ToRawResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return new RawEndpointResponse + { + StatusCode = response.StatusCode, + Body = body, + Headers = BuildHeaders(response), + }; + } + + private static IReadOnlyDictionary> BuildHeaders(HttpResponseMessage response) + { + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var header in response.Headers) + { + headers[header.Key] = header.Value.ToArray(); + } + + foreach (var header in response.Content.Headers) + { + headers[header.Key] = header.Value.ToArray(); + } + + return headers; + } + + private static TUpload? ParseSingleItemArray(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return default; + } + + var items = JsonSerializer.Deserialize>(json, JsonOptions); + return items is { Count: > 0 } ? items[0] : default; + } + + private static async Task ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken) + { + if (stream.CanSeek) + { + stream.Position = 0; + } + + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + return memoryStream.ToArray(); + } + + private string GetAdminPagePath(string pageName) + { + var basePath = _options.Value.AdminPageBasePath.Trim('/'); + return $"{basePath}/{pageName}.abl"; + } +} diff --git a/Catalog.sln b/Catalog.sln index a5fd6f7..b92613e 100644 --- a/Catalog.sln +++ b/Catalog.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Tests", "MaddoS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Benchmarks", "MaddoShared.Benchmarks\MaddoShared.Benchmarks.csproj", "{07499348-8C15-4DCC-8316-4AD121A43C38}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Catalog.Communication", "Catalog.Communication\Catalog.Communication.csproj", "{EF5D3B7E-F380-4976-A0A9-085FEA157F79}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,12 +75,25 @@ Global {07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x64.Build.0 = Release|Any CPU {07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.ActiveCfg = Release|Any CPU {07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.Build.0 = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.Build.0 = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.Build.0 = Debug|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.Build.0 = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.ActiveCfg = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.Build.0 = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.ActiveCfg = Release|Any CPU + {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {AEBFE9E3-277C-4A7B-8448-145D1B11998B} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9} + {EF5D3B7E-F380-4976-A0A9-085FEA157F79} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9} {59952BE8-20B4-4BF2-9367-705F41395265} = {5F0BEF23-B1EA-4100-A772-DC455D40B1C1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/docs/api-race-upload-spec.md b/docs/api-race-upload-spec.md new file mode 100644 index 0000000..0748114 --- /dev/null +++ b/docs/api-race-upload-spec.md @@ -0,0 +1,230 @@ +# API Specification (code-backed): races, images, and authentication + +## Scope and evidence + +This document is reverse-engineered from: +- JSP/JS usage in the web app +- `WEB-INF/web.xml` servlet mappings +- decompiled sources in `WEB-INF/lib/*_src` (notably `AblServletSvlt`, `AcServlet`, `GaraSvlt`, `Logon4Svlt`, `Menu4Svlt`, `GetFileTnSvlt`) + +The upload response schemas below are now **confirmed from code**, not inferred. + +## Base URL + +Examples use: +- `https://your-host` + +## Authentication (how to obtain the usable token) + +## Actual auth model + +This app uses **server session authentication**, not JWT bearer tokens. + +Login success sets session attributes: +- `loginUser_id` (Long) +- `utenteLogon` (user object) + +For automation, the practical token is the **session cookie** (`JSESSIONID` and any app cookies). + +## Admin login endpoint + +- `POST /admin/menu/Menu4.abl` +- Servlet mapping: `com.ablia.anag.servlet.Menu4Svlt` (extends `Logon4Svlt`) +- Body (`application/x-www-form-urlencoded`): + - `login` + - `pwd` + - `cmdIU=check` + +Other `cmdIU` values used: +- `cmdIU=login` (logout) +- `cmdIU=np` (password change) +- `cmdIU=checkSso` (SSO branch if enabled) + +### cURL login example (capture session cookie) + +```bash +curl -i -c cookies.txt -X POST "https://your-host/admin/menu/Menu4.abl" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data "login=YOUR_USER&pwd=YOUR_PASSWORD&cmdIU=check" +``` + +Reuse cookie jar for authenticated calls: + +```bash +curl -i -b cookies.txt "https://your-host/admin/pg/Gara.abl?cmd=search" +``` + +## Token note (Bearer/Basic) + +- No `Authorization: Bearer ...` flow is present in inspected sources. +- A helper exists to parse `Authorization: Basic` (`getBasicAuthorizationHeaders`), but no race/photo endpoint uses it directly. + +## Relevant endpoints + +## Admin race/photo endpoints + +- `POST|GET /admin/pg/Gara.abl` +- `POST|GET /admin/pg/Foto.abl` +- `POST|GET /admin/pg/TipoGara.abl` +- `POST|GET /admin/pg/LogFoto.abl` + +Deployment caveat: +- some deployments expose the same admin pages under `POST|GET /admin/pg_RUS/*.abl`. +- On `https://www.regalamiunsorriso.it`, post-login dashboard links point to `/admin/pg_RUS/Gara.abl` (not `/admin/pg/Gara.abl`). + +## Public/web endpoints related to photos/users + +- `POST|GET /Foto2.abl` +- `POST|GET /Logon.abl` (mapped to `com.ablia.pg.servlet.Logon2Svlt`) +- `POST|GET /Users.abl` + +Note: `/Login.abl` in `web.xml` is mapped to cart servlet (`CartSvlt`), not admin login. + +## File serving endpoints + +- `GET /foto/*` → `GetFileTnSvlt` (thumbnail by default) +- `GET /fotoOriginali/*` → `GetFileOrigSvlt` (original file flow) + +## Multipart upload contract (common engine) + +All race-image/file uploads go through `AblServletSvlt.manageImgFileMultipartRequest` + `AcServlet.manageMultipartRequestParameters`. + +Key behavior: +- File fields explicitly recognized: `imgFile`, `nomeFile` +- UI also sends `fileName` for generic file upload; this still works (stored by original filename) +- Temporary target directory: `DOCBASE + tmp/` (`getPathTmp()`) +- Per-upload file size target: about `20000 KB` for this servlet path + +## Upload race image + +Endpoint: +- `POST /admin/pg/Gara.abl` + +Required form fields: +- `cmd=loadImg` +- `imgFile` (binary) +- `id` (race id) +- `codImage` (slot index) +- `totImgNumber` (typically `3` in UI) + +Storage details: +- Race attachment path from `Gara.getPathAttach()` is `_img/_gara/` +- Thumbnail is generated into `_img/_gara/100/` (100x75) + +### Response schema (confirmed) + +Response is JSON array with one object (`JsonUploadImageResponse`): +- `result` (boolean) +- `message` (string) +- `imgPath` (string) + +Example success payload shape: + +```json +[ + { + "result": true, + "message": "...Immagine 1 Salvata...", + "imgPath": "../../_img/_gara/100/" + } +] +``` + +## Delete race image + +Endpoint: +- `POST /admin/pg/Gara.abl` + +Fields: +- `cmd=removeImg` +- `id` +- `codImage` +- `totImgNumber` + +Response schema is the same `JsonUploadImageResponse[]`. + +## Upload CSV/file for race processing + +Endpoint: +- `POST /admin/pg/Gara.abl` + +UI helper (`Ab.saveFile`) sends: +- `cmd=saveFile` +- `fileName` (binary file) +- `codFile` (slot id, usually `1`) +- `id` (often `0` in UI flow) + +### Response schema (confirmed) + +JSON array with one `JsonUploadFileResponse` object: +- `result` (boolean) +- `message` (string) +- `fileName` (stored/original name) +- `fileNameLink` (typically `../../tmp/`) + +Example shape: + +```json +[ + { + "result": true, + "message": "...", + "fileName": "punti-foto.csv", + "fileNameLink": "../../tmp/punti-foto.csv" + } +] +``` + +## Race command API (`/admin/pg/Gara.abl`) + +Confirmed custom commands in `GaraSvlt`: +- `addPuntoFoto` +- `delPuntoFoto` +- `modPuntoFoto` +- `indexFoto` +- `noIndexFoto` +- `creaPuntiFoto` +- `indexCsvPisa` +- `salvaFileCsv` + +Important parameters by command: +- `id_gara` for race-level operations +- `id_puntoFoto` and `id_puntoFotoIdx` for point selection +- point fields such as `descrizionePuntoFoto`, `pathRelativoFoto`, `tipoPuntoFoto` + +CSV flow coupling: +1. `cmd=saveFile` uploads to `tmp/` and returns `fileName` +2. UI stores it into hidden field `fileNameOnServer_1` +3. `cmd=salvaFileCsv` copies `DOCBASE/tmp/` to `DOCBASE/admin/csv/.csv` +4. `cmd=indexCsvPisa` reads `admin/csv/.csv` via `Gara.getImpCsvFileName()` and updates matching photos + +## Image retrieval behavior + +`/foto/*` (`GetFileTnSvlt`): +- accepts `id_foto` query parameter, or extracts id from URL suffix pattern like `name-.jpg` +- if photo not found, serves `_img/_imgNotFound.png` +- returns thumbnail by default in this servlet flow + +`/fotoOriginali/*` (`GetFileOrigSvlt`): +- routes to original-file logic +- applies user/account checks (valid user, not expired, max photos) +- blocks some originals by filename markers (`_X`, `_Y`, `_Z`) depending on profile +- logs photo view events when original is served + +## Known response caveat + +Do not treat `result=true` as universally reliable for success semantics: +- in some error branches (`_loadImg`, `_removeImg`, `_saveFile`), code still returns `result=true` with an error message. + +For robust clients, validate both: +- `result` +- `message` text + expected output field (`imgPath` / `fileName` non-empty) + +## Practical automation sequence + +1. Login on `/admin/menu/Menu4.abl` with `cmdIU=check`, store cookies. +2. Create/update race data on `/admin/pg/Gara.abl` with regular form commands. +3. Upload images with `cmd=loadImg`. +4. (Optional) upload CSV with `cmd=saveFile`. +5. Finalize CSV placement with `cmd=salvaFileCsv` and run `cmd=indexCsvPisa`. +6. Read thumbnails from `/foto/*` and originals from `/fotoOriginali/*`. diff --git a/docs/openapi-race-upload.yaml b/docs/openapi-race-upload.yaml new file mode 100644 index 0000000..3266182 --- /dev/null +++ b/docs/openapi-race-upload.yaml @@ -0,0 +1,679 @@ +openapi: 3.0.3 +info: + title: RUS Reverse-Engineered API (Races, Uploads, Auth) + version: 1.0.0 + description: | + OpenAPI specification inferred from JSP/JS usage, `WEB-INF/web.xml`, and decompiled sources + in `WEB-INF/lib/*_src`. + + This application is command-driven (`*.abl`) and many endpoints return server-rendered HTML. + Where the implementation returns JSON payloads (notably upload operations), schemas are modeled + explicitly from decompiled DTOs: + + - `JsonUploadImageResponse { result, message, imgPath }` + - `JsonUploadFileResponse { result, message, fileName, fileNameLink }` + + ## Important caveats + - Authentication is session-cookie based (typically `JSESSIONID`), not JWT bearer. + - Some error branches in upload handlers still return `result: true` with an error message text. + Clients should validate both `result` and semantic fields (`imgPath`, `fileName`). + - Several command endpoints primarily render JSP/HTML, so response contracts are best-effort. + - Some deployments namespace admin page endpoints as `/admin/pg_RUS/*` instead of `/admin/pg/*`. + Example observed deployment: `https://www.regalamiunsorriso.it` links `/admin/pg_RUS/Gara.abl` from dashboard. + +servers: + - url: https://your-host + description: Replace with your deployed host + +tags: + - name: Authentication + description: Session login/logout and related command flows + - name: RaceAdmin + description: Admin race management (`/admin/pg/Gara.abl`) including uploads and command actions + - name: PhotoAdmin + description: Admin photo/type/log endpoints (command-driven) + - name: Media + description: Thumbnail/original photo retrieval + - name: Public + description: Public-facing login/user/photo endpoints mapped in web.xml + +security: + - SessionCookieAuth: [] + +paths: + /admin/menu/Menu4.abl: + post: + tags: [Authentication] + summary: Admin login/logout/password command endpoint + description: | + Command-driven login servlet (`Menu4Svlt` extending `Logon4Svlt`). + + Primary commands (`cmdIU`): + - `check` => login + - `login` => logout + - `np` => password-change flow + - `checkSso` => SSO branch (when enabled) + security: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Menu4CommandRequest' + examples: + login: + summary: Login + value: + login: YOUR_USER + pwd: YOUR_PASSWORD + cmdIU: check + logout: + summary: Logout + value: + cmdIU: login + passwordChange: + summary: Password change flow + value: + cmdIU: np + login: YOUR_USER + pwd: YOUR_OLD_PASSWORD + responses: + '200': + description: | + Usually HTML/JSP response with redirect or rendered page; on successful login, session cookies are set. + headers: + Set-Cookie: + description: Session cookie(s), usually including `JSESSIONID` + schema: + type: string + content: + text/html: + schema: + type: string + examples: + loginOk: + summary: Typical successful login page/redirect body + value: "...LOGIN_OK..." + '401': + description: Authentication failure semantics are typically encoded in HTML/message, not strict HTTP 401. + + /admin/pg/Gara.abl: + get: + tags: [RaceAdmin] + summary: Race admin endpoint (command/query rendering) + description: | + Command endpoint for race administration. GET is commonly used for page loads/search rendering. + Typical query parameters include `cmd`, `act`, `id_gara`, pagination fields. + parameters: + - $ref: '#/components/parameters/Cmd' + - $ref: '#/components/parameters/Act' + - $ref: '#/components/parameters/IdGara' + responses: + '200': + description: Server-rendered HTML/JSP response + content: + text/html: + schema: + type: string + post: + tags: [RaceAdmin] + summary: Race admin command endpoint (multipart and form-urlencoded) + description: | + Supports both: + 1) `multipart/form-data` upload commands (`loadImg`, `removeImg`, `saveFile`) + 2) `application/x-www-form-urlencoded` action commands (`addPuntoFoto`, `indexCsvPisa`, ...) + + Source evidence: + - `AblServletSvlt._loadImg/_removeImg/_saveFile` + - `GaraSvlt` custom command methods + requestBody: + required: true + content: + multipart/form-data: + schema: + oneOf: + - $ref: '#/components/schemas/GaraLoadImgMultipartRequest' + - $ref: '#/components/schemas/GaraRemoveImgMultipartRequest' + - $ref: '#/components/schemas/GaraSaveFileMultipartRequest' + encoding: + imgFile: + contentType: image/* + fileName: + contentType: '*/*' + examples: + loadImg: + summary: Upload race image slot + value: + cmd: loadImg + id: 123 + codImage: 1 + totImgNumber: 3 + removeImg: + summary: Remove race image slot + value: + cmd: removeImg + id: 123 + codImage: 1 + totImgNumber: 3 + saveFile: + summary: Upload CSV/import file + value: + cmd: saveFile + codFile: 1 + id: 0 + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GaraCommandRequest' + examples: + addPuntoFoto: + value: + cmd: addPuntoFoto + id_gara: 123 + descrizionePuntoFoto: "Start" + pathRelativoFoto: "start/" + tipoPuntoFoto: "A" + indexFoto: + value: + cmd: indexFoto + id_gara: 123 + id_puntoFotoIdx: 987 + noIndexFoto: + value: + cmd: noIndexFoto + id_gara: 123 + id_puntoFotoIdx: 987 + creaPuntiFoto: + value: + cmd: creaPuntiFoto + id_gara: 123 + indexCsvPisa: + value: + cmd: indexCsvPisa + id_gara: 123 + salvaFileCsv: + value: + cmd: salvaFileCsv + id_gara: 123 + fileNameOnServer_1: "punti-foto.csv" + responses: + '200': + description: | + Mixed response style depending on `cmd`. + - Upload commands generally return JSON array with one object. + - Many non-upload commands render HTML/JSP with server messages. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/UploadImageResponseArray' + - $ref: '#/components/schemas/UploadFileResponseArray' + examples: + imageUploadOk: + value: + - result: true + message: "Immagine 1 Salvata" + imgPath: "../../_img/_gara/100/123_1_1700000000000.jpg" + imageUploadErrorStyle: + summary: Known caveat branch + value: + - result: true + message: "Immagine NON Salvata. Utente non valido" + imgPath: "../../" + fileUploadOk: + value: + - result: true + message: "File punti-foto.csv Salvato" + fileName: "punti-foto.csv" + fileNameLink: "../../tmp/punti-foto.csv" + text/html: + schema: + type: string + examples: + htmlCommandResponse: + value: "...messaggi/bean rendering..." + + /admin/pg/Foto.abl: + get: + tags: [PhotoAdmin] + summary: Admin photo listing/search endpoint + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + post: + tags: [PhotoAdmin] + summary: Admin photo command endpoint + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GenericCommandRequest' + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + + /admin/pg/TipoGara.abl: + get: + tags: [PhotoAdmin] + summary: Admin race-type endpoint + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + post: + tags: [PhotoAdmin] + summary: Admin race-type command endpoint + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GenericCommandRequest' + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + + /admin/pg/LogFoto.abl: + get: + tags: [PhotoAdmin] + summary: Admin photo-log endpoint + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + post: + tags: [PhotoAdmin] + summary: Admin photo-log command endpoint + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GenericCommandRequest' + responses: + '200': + description: Server-rendered HTML/JSP + content: + text/html: + schema: + type: string + + /foto/{filename}: + get: + tags: [Media] + summary: Get thumbnail/photo by path; supports id_foto query + description: | + Mapped to `GetFileTnSvlt`. + Behavior: + - If `id_foto` is provided, photo is loaded by id. + - If missing, servlet may parse id from `{filename}` suffix pattern `name-.`. + - If not found, fallback `_img/_imgNotFound.png`. + security: + - SessionCookieAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + description: Requested filename segment (also used for id parsing fallback) + - name: id_foto + in: query + required: false + schema: + type: integer + format: int64 + description: Photo id (preferred) + responses: + '200': + description: Image bytes or HTML error text based on access checks + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + text/html: + schema: + type: string + examples: + blocked: + value: "Attenzione!. Questa Foto non puo' essere scaricata!!" + + /fotoOriginali/{filename}: + get: + tags: [Media] + summary: Get original photo (with profile/account restrictions) + description: | + Mapped to `GetFileOrigSvlt` (delegates to original-file flow). + Includes additional checks: + - account validity/expiry/max-photo constraints + - filename marker restrictions (`_X`, `_Y`, `_Z`) by profile + - logs access events on successful original retrieval + security: + - SessionCookieAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + - name: id_foto + in: query + required: false + schema: + type: integer + format: int64 + responses: + '200': + description: Image bytes or access-denied HTML text + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + text/html: + schema: + type: string + examples: + denied: + value: "Attenzione!. Account scaduto o raggiunto n. foto massimo" + + /Logon.abl: + post: + tags: [Public] + summary: Public login endpoint + description: Mapped to `com.ablia.pg.servlet.Logon2Svlt`. + security: [] + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PublicLogonRequest' + responses: + '200': + description: HTML/JSP response with session handling + content: + text/html: + schema: + type: string + + /Users.abl: + get: + tags: [Public] + summary: Public users endpoint + responses: + '200': + description: Implementation-specific output (usually HTML/JSP) + content: + text/html: + schema: + type: string + post: + tags: [Public] + summary: Public users command endpoint + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GenericCommandRequest' + responses: + '200': + description: Implementation-specific output (usually HTML/JSP) + content: + text/html: + schema: + type: string + +components: + securitySchemes: + SessionCookieAuth: + type: apiKey + in: cookie + name: JSESSIONID + description: | + Session cookie set by login endpoints. Some deployments can set additional cookies. + + parameters: + Cmd: + name: cmd + in: query + required: false + schema: + type: string + description: Command token for the servlet command-dispatch model + Act: + name: act + in: query + required: false + schema: + type: string + description: Secondary action token used by page flows + IdGara: + name: id_gara + in: query + required: false + schema: + type: integer + format: int64 + + schemas: + Menu4CommandRequest: + type: object + properties: + login: + type: string + pwd: + type: string + format: password + cmdIU: + type: string + enum: [check, login, np, checkSso, checkCC, ckcclnk, cmcc, ni] + actIU: + type: string + cmd2: + type: string + act2: + type: string + sso: + type: string + required: [cmdIU] + + PublicLogonRequest: + type: object + properties: + login: + type: string + pwd: + type: string + cmdIU: + type: string + enum: [check, login, np, checkSso, checkCC, ckcclnk, cmcc] + cmd: + type: string + description: May also route custom commands like `logout` in `Logon2Svlt` + + GenericCommandRequest: + type: object + properties: + cmd: + type: string + act: + type: string + cmd2: + type: string + act2: + type: string + _id: + type: string + _id_name: + type: string + pageNumber: + type: integer + totPageNumber: + type: integer + flgReport: + type: string + additionalProperties: true + + GaraCommandRequest: + allOf: + - $ref: '#/components/schemas/GenericCommandRequest' + - type: object + properties: + cmd: + type: string + enum: + - addPuntoFoto + - delPuntoFoto + - modPuntoFoto + - indexFoto + - noIndexFoto + - creaPuntiFoto + - indexCsvPisa + - salvaFileCsv + - aggiornaThreadMsg + - search + - md + - ni + - refresh + id_gara: + type: integer + format: int64 + id_puntoFoto: + type: integer + format: int64 + id_puntoFotoIdx: + type: integer + format: int64 + descrizionePuntoFoto: + type: string + pathRelativoFoto: + type: string + tipoPuntoFoto: + type: string + fileNameOnServer_1: + type: string + pathBase: + type: string + descrizione: + type: string + dataGaraInizio: + type: string + format: date + dataGaraFine: + type: string + format: date + + GaraLoadImgMultipartRequest: + type: object + required: [cmd, imgFile, id, codImage] + properties: + cmd: + type: string + enum: [loadImg] + imgFile: + type: string + format: binary + id: + type: integer + format: int64 + description: Race id (maps to bean primary key in upload flow) + codImage: + type: integer + description: Image slot index (UI uses 1..3) + totImgNumber: + type: integer + description: Total image slots for timestamp rotation logic + + GaraRemoveImgMultipartRequest: + type: object + required: [cmd, id, codImage] + properties: + cmd: + type: string + enum: [removeImg] + id: + type: integer + format: int64 + codImage: + type: integer + totImgNumber: + type: integer + + GaraSaveFileMultipartRequest: + type: object + required: [cmd, fileName] + properties: + cmd: + type: string + enum: [saveFile] + fileName: + type: string + format: binary + description: Field used by JS helper (`Ab.saveFile`) + codFile: + type: integer + id: + type: integer + format: int64 + + JsonUploadImageResponse: + type: object + properties: + result: + type: boolean + message: + type: string + imgPath: + type: string + required: [result, message, imgPath] + + JsonUploadFileResponse: + type: object + properties: + result: + type: boolean + message: + type: string + fileName: + type: string + fileNameLink: + type: string + required: [result, message, fileName, fileNameLink] + + UploadImageResponseArray: + type: array + minItems: 1 + maxItems: 1 + items: + $ref: '#/components/schemas/JsonUploadImageResponse' + + UploadFileResponseArray: + type: array + minItems: 1 + maxItems: 1 + items: + $ref: '#/components/schemas/JsonUploadFileResponse' diff --git a/imagecatalog/AvaloniaApp.axaml.cs b/imagecatalog/AvaloniaApp.axaml.cs index 9a14f4d..474d541 100644 --- a/imagecatalog/AvaloniaApp.axaml.cs +++ b/imagecatalog/AvaloniaApp.axaml.cs @@ -12,8 +12,7 @@ public partial class AvaloniaApp : Avalonia.Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var model = Program.ServiceProvider.GetRequiredService(); - desktop.MainWindow = new AvaloniaMainWindow(model); + desktop.MainWindow = Program.ServiceProvider.GetRequiredService(); } base.OnFrameworkInitializationCompleted(); } diff --git a/imagecatalog/AvaloniaMainWindow.axaml b/imagecatalog/AvaloniaMainWindow.axaml index e47c50d..6229707 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml +++ b/imagecatalog/AvaloniaMainWindow.axaml @@ -270,6 +270,37 @@ + + + + + + + + + + + + + + + + +