From 71e27b5dbb67090d6bdfa36d21dc037273dd82e5 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 22 Nov 2025 22:30:06 -0500 Subject: [PATCH 1/4] [FA-5] Update gitignore for Fusion Gateway builds --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e67f738..6455a38 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,9 @@ $RECYCLE.BIN/ _NCrunch* # Local user appsettings -appsettings.Local.json \ No newline at end of file +appsettings.Local.json + +# Fusion Builds +schema.graphql +*.fsp +gateway.fgp \ No newline at end of file -- 2.49.1 From 1adbb955cf339ef9cf00163ebb00fd4b1407ea80 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 22 Nov 2025 22:30:46 -0500 Subject: [PATCH 2/4] [FA-5] Remove gateway.fgp --- FictionArchive.API/gateway.fgp | Bin 84122 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 FictionArchive.API/gateway.fgp diff --git a/FictionArchive.API/gateway.fgp b/FictionArchive.API/gateway.fgp deleted file mode 100644 index 4eb467af15b78d7f444c422f55154aeda0b3a82a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84122 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 E2NWUzw*UYD -- 2.49.1 From 573a0f6e3f05d02eac3d9f84ccdb23ad9f1c80fa Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 22 Nov 2025 22:36:44 -0500 Subject: [PATCH 3/4] [FA-5] FileService setup, build scripts tweaked to be easier to maintain --- FictionArchive.API/build_gateway.bat | 99 ------------- FictionArchive.API/build_gateway.ps1 | 138 ++++++++++++++++++ FictionArchive.API/build_gateway.sh | 16 +- FictionArchive.API/gateway_skip.txt | 4 + FictionArchive.Common/Enums/RequestStatus.cs | 8 + .../Controllers/S3ProxyController.cs | 49 +++++++ FictionArchive.Service.FileService/Dockerfile | 23 +++ .../FictionArchive.Service.FileService.csproj | 30 ++++ .../FileUploadRequestCreatedEvent.cs | 10 ++ .../FileUploadRequestStatusUpdate.cs | 22 +++ .../Models/ProxyConfiguration.cs | 6 + .../Models/S3Configuration.cs | 9 ++ FictionArchive.Service.FileService/Program.cs | 66 +++++++++ .../Properties/launchSettings.json | 39 +++++ .../FileUploadRequestCreatedEventHandler.cs | 57 ++++++++ .../appsettings.Development.json | 8 + .../appsettings.json | 22 +++ .../FileUploadRequestCreatedEvent.cs | 10 ++ FictionArchive.sln | 6 + 19 files changed, 519 insertions(+), 103 deletions(-) delete mode 100644 FictionArchive.API/build_gateway.bat create mode 100644 FictionArchive.API/build_gateway.ps1 create mode 100644 FictionArchive.API/gateway_skip.txt create mode 100644 FictionArchive.Common/Enums/RequestStatus.cs create mode 100644 FictionArchive.Service.FileService/Controllers/S3ProxyController.cs create mode 100644 FictionArchive.Service.FileService/Dockerfile create mode 100644 FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj create mode 100644 FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs create mode 100644 FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs create mode 100644 FictionArchive.Service.FileService/Models/ProxyConfiguration.cs create mode 100644 FictionArchive.Service.FileService/Models/S3Configuration.cs create mode 100644 FictionArchive.Service.FileService/Program.cs create mode 100644 FictionArchive.Service.FileService/Properties/launchSettings.json create mode 100644 FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs create mode 100644 FictionArchive.Service.FileService/appsettings.Development.json create mode 100644 FictionArchive.Service.FileService/appsettings.json create mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs diff --git a/FictionArchive.API/build_gateway.bat b/FictionArchive.API/build_gateway.bat deleted file mode 100644 index 598d0e6..0000000 --- a/FictionArchive.API/build_gateway.bat +++ /dev/null @@ -1,99 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -set ROOT=%~dp0 - -for %%A in ("%ROOT%..") do set SERVICES_DIR=%%~fA\ - -REM ---------------------------------------- -REM List of project names to skip -REM (space-separated, match folder names exactly) -REM ---------------------------------------- -set SKIP_PROJECTS=FictionArchive.Service.Shared FictionArchive.Service.AuthenticationService - -echo ---------------------------------------- -echo Finding GraphQL services... -echo ---------------------------------------- - -set SERVICE_LIST= - -for /d %%F in ("%SERVICES_DIR%FictionArchive.Service.*") do ( - set "PROJECT_NAME=%%~nxF" - set "SKIP=0" - - REM Check if this project name is in the skip list - for %%X in (%SKIP_PROJECTS%) do ( - if /I "!PROJECT_NAME!"=="%%X" ( - set "SKIP=1" - ) - ) - - if !SKIP!==0 ( - echo Found service: !PROJECT_NAME! - set SERVICE_LIST=!SERVICE_LIST! %%F - ) else ( - echo Skipping service: !PROJECT_NAME! - ) -) - -echo: -echo ---------------------------------------- -echo Exporting schemas and packing subgraphs... -echo ---------------------------------------- - -for %%S in (%SERVICE_LIST%) do ( - echo Processing service folder: %%S - pushd "%%S" - - echo Running schema export... - dotnet run -- schema export --output schema.graphql - if errorlevel 1 ( - echo ERROR during schema export in %%S - popd - exit /b 1 - ) - - echo Running fusion subgraph pack... - fusion subgraph pack - if errorlevel 1 ( - echo ERROR during subgraph pack in %%S - popd - exit /b 1 - ) - - popd - echo Completed: %%S - echo. -) - -echo ---------------------------------------- -echo Running fusion compose... -echo ---------------------------------------- - -pushd "%ROOT%" - -if exist gateway.fgp del gateway.fgp - -for %%S in (%SERVICE_LIST%) do ( - REM Extract the full folder name WITH dots preserved - set "SERVICE_NAME=%%~nxS" - - echo Composing subgraph: !SERVICE_NAME! - - fusion compose -p gateway.fgp -s "..\!SERVICE_NAME!" - if errorlevel 1 ( - echo ERROR during fusion compose - popd - exit /b 1 - ) -) - -popd - - -echo ---------------------------------------- -echo Fusion build complete! -echo ---------------------------------------- - -endlocal -exit /b 0 diff --git a/FictionArchive.API/build_gateway.ps1 b/FictionArchive.API/build_gateway.ps1 new file mode 100644 index 0000000..f06ddf0 --- /dev/null +++ b/FictionArchive.API/build_gateway.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + Export GraphQL schemas, pack subgraphs and compose the gateway (PowerShell). +.DESCRIPTION + - Searches for FictionArchive.Service.* folders one directory above this script. + - Reads skip-projects.txt next to the script. + - Builds each service (Release). + - Runs `dotnet run --no-build --no-launch-profile -- schema export` in each service to avoid running the web host. + - Packs subgraphs. + - Composes the gateway from FictionArchive.API. +#> + +[CmdletBinding()] +param() + +function Write-ErrExit { + param($Message, $Code = 1) + Write-Error $Message + exit $Code +} + +# Resolve directories +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$ServicesDir = Resolve-Path -Path (Join-Path $ScriptDir '..') -ErrorAction Stop +$ApiDir = Join-Path $ServicesDir 'FictionArchive.API' + +Write-Host "Script dir: $ScriptDir" +Write-Host "Services dir: $ServicesDir" + +# Load skip list +$SkipFile = Join-Path $ScriptDir 'gateway_skip.txt' +$SkipList = @() + +Write-Host "----------------------------------------" +Write-Host " Loading skip list..." +Write-Host "----------------------------------------" + +if (Test-Path $SkipFile) { + $SkipList = Get-Content $SkipFile | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('#') } + + Write-Host "Skipping: $($SkipList -join ', ')" +} else { + Write-Warning "skip-projects.txt not found — no services will be skipped." +} + +# Find service directories +Write-Host +Write-Host "----------------------------------------" +Write-Host " Finding GraphQL services..." +Write-Host "----------------------------------------" + +$servicePattern = 'FictionArchive.Service.*' +$serviceDirs = Get-ChildItem -Path $ServicesDir -Directory -Filter 'FictionArchive.Service.*' + +if (-not $serviceDirs) { + Write-ErrExit "No service folders found matching FictionArchive.Service.* under $ServicesDir" +} + +$selectedServices = @() + +foreach ($d in $serviceDirs) { + if ($SkipList -contains $d.Name) { + Write-Host "Skipping: $($d.Name)" + continue + } + + Write-Host "Found: $($d.Name)" + $selectedServices += $d.FullName +} + +if (-not $selectedServices) { + Write-ErrExit "All services skipped — nothing to do." +} + +# Export schemas and pack subgraphs +Write-Host +Write-Host "----------------------------------------" +Write-Host " Exporting schemas & packing subgraphs..." +Write-Host "----------------------------------------" + +foreach ($svcPath in $selectedServices) { + $svcName = Split-Path -Leaf $svcPath + Write-Host "`nProcessing: $svcName" + + Push-Location $svcPath + try { + # Build Release + Write-Host "Building $svcName..." + dotnet build -c Release + if ($LASTEXITCODE -ne 0) { Write-ErrExit "dotnet build failed for $svcName" } + + # Schema export using dotnet run (no server) + Write-Host "Running schema export..." + dotnet run --no-build --no-launch-profile -- schema export --output schema.graphql + if ($LASTEXITCODE -ne 0) { Write-ErrExit "Schema export failed for $svcName" } + + # Pack subgraph + Write-Host "Running fusion subgraph pack..." + fusion subgraph pack + if ($LASTEXITCODE -ne 0) { Write-ErrExit "fusion subgraph pack failed for $svcName" } + + Write-Host "Completed: $svcName" + } + finally { + Pop-Location + } +} + +# Compose gateway +Write-Host +Write-Host "----------------------------------------" +Write-Host " Running fusion compose..." +Write-Host "----------------------------------------" + +if (-not (Test-Path $ApiDir)) { + Write-ErrExit "API directory not found: $ApiDir" +} + +Push-Location $ApiDir +try { + if (Test-Path "gateway.fgp") { Remove-Item "gateway.fgp" -Force } + + foreach ($svcPath in $selectedServices) { + $svcName = Split-Path -Leaf $svcPath + Write-Host "Composing: $svcName" + fusion compose -p gateway.fgp -s ("..\" + $svcName) + if ($LASTEXITCODE -ne 0) { Write-ErrExit "fusion compose failed for $svcName" } + } + + Write-Host "`nFusion build complete!" +} +finally { + Pop-Location +} + +exit 0 diff --git a/FictionArchive.API/build_gateway.sh b/FictionArchive.API/build_gateway.sh index f6ed69a..3a30cd3 100644 --- a/FictionArchive.API/build_gateway.sh +++ b/FictionArchive.API/build_gateway.sh @@ -14,10 +14,18 @@ SERVICES_DIR="$(cd "$ROOT/.." && pwd)" ############################################### # Skip list (folder names, match exactly) ############################################### -SKIP_PROJECTS=( - "FictionArchive.Service.Shared" - "FictionArchive.Service.Legacy" -) +SKIP_FILE="$ROOT/gateway_skip.txt" +SKIP_PROJECTS=() + +if [[ -f "$SKIP_FILE" ]]; then + # Read non-empty lines ignoring comments + while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^# ]] && continue + SKIP_PROJECTS+=("$line") + done < "$SKIP_FILE" +else + echo "WARNING: skip-projects.txt not found — no projects will be skipped." +fi echo "----------------------------------------" echo " Finding GraphQL services..." diff --git a/FictionArchive.API/gateway_skip.txt b/FictionArchive.API/gateway_skip.txt new file mode 100644 index 0000000..779d6de --- /dev/null +++ b/FictionArchive.API/gateway_skip.txt @@ -0,0 +1,4 @@ +# List of service folders to skip +FictionArchive.Service.Shared +FictionArchive.Service.AuthenticationService +FictionArchive.Service.FileService diff --git a/FictionArchive.Common/Enums/RequestStatus.cs b/FictionArchive.Common/Enums/RequestStatus.cs new file mode 100644 index 0000000..9b0fedd --- /dev/null +++ b/FictionArchive.Common/Enums/RequestStatus.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Common.Enums; + +public enum RequestStatus +{ + Failed = -1, + Pending = 0, + Success = 1 +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs new file mode 100644 index 0000000..384095b --- /dev/null +++ b/FictionArchive.Service.FileService/Controllers/S3ProxyController.cs @@ -0,0 +1,49 @@ +using System.Web; +using Amazon.S3; +using Amazon.S3.Model; +using FictionArchive.Service.FileService.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace FictionArchive.Service.FileService.Controllers +{ + [Route("api/{*path}")] + [ApiController] + public class S3ProxyController : ControllerBase + { + private readonly AmazonS3Client _amazonS3Client; + private readonly S3Configuration _s3Configuration; + + public S3ProxyController(AmazonS3Client amazonS3Client, IOptions s3Configuration) + { + _amazonS3Client = amazonS3Client; + _s3Configuration = s3Configuration.Value; + } + + [HttpGet] + public async Task Get(string path) + { + var decodedPath = HttpUtility.UrlDecode(path); + + try + { + var s3Response = await _amazonS3Client.GetObjectAsync(new GetObjectRequest() + { + BucketName = _s3Configuration.Bucket, + Key = decodedPath + }); + + return new FileStreamResult(s3Response.ResponseStream, s3Response.Headers.ContentType); + } + catch (AmazonS3Exception e) + { + if (e.Message == "Key not found") + { + return NotFound(); + } + throw; + } + } + } +} diff --git a/FictionArchive.Service.FileService/Dockerfile b/FictionArchive.Service.FileService/Dockerfile new file mode 100644 index 0000000..f972438 --- /dev/null +++ b/FictionArchive.Service.FileService/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj", "FictionArchive.Service.ImageService/"] +RUN dotnet restore "FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj" +COPY . . +WORKDIR "/src/FictionArchive.Service.ImageService" +RUN dotnet build "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FictionArchive.Service.ImageService.dll"] diff --git a/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj new file mode 100644 index 0000000..7e69402 --- /dev/null +++ b/FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + + + + diff --git a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs new file mode 100644 index 0000000..bf03a56 --- /dev/null +++ b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs @@ -0,0 +1,10 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.FileService.Models.IntegrationEvents; + +public class FileUploadRequestCreatedEvent : IIntegrationEvent +{ + public Guid RequestId { get; set; } + public string FilePath { get; set; } + public byte[] FileData { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs new file mode 100644 index 0000000..b5936de --- /dev/null +++ b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs @@ -0,0 +1,22 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.FileService.Models.IntegrationEvents; + +public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent +{ + public Guid RequestId { get; set; } + public RequestStatus Status { get; set; } + + #region Success + + public string? FileAccessUrl { get; set; } + + #endregion + + #region Failure + + public string? ErrorMessage { get; set; } + + #endregion +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Models/ProxyConfiguration.cs b/FictionArchive.Service.FileService/Models/ProxyConfiguration.cs new file mode 100644 index 0000000..c1ce02c --- /dev/null +++ b/FictionArchive.Service.FileService/Models/ProxyConfiguration.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.FileService.Models; + +public class ProxyConfiguration +{ + public string BaseUrl { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Models/S3Configuration.cs b/FictionArchive.Service.FileService/Models/S3Configuration.cs new file mode 100644 index 0000000..b5c601e --- /dev/null +++ b/FictionArchive.Service.FileService/Models/S3Configuration.cs @@ -0,0 +1,9 @@ +namespace FictionArchive.Service.FileService.Models; + +public class S3Configuration +{ + public string Url { get; set; } + public string Bucket { get; set; } + public string AccessKey { get; set; } + public string SecretKey { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Program.cs b/FictionArchive.Service.FileService/Program.cs new file mode 100644 index 0000000..ed18765 --- /dev/null +++ b/FictionArchive.Service.FileService/Program.cs @@ -0,0 +1,66 @@ +using Amazon.Runtime; +using Amazon.S3; +using FictionArchive.Common.Extensions; +using FictionArchive.Service.FileService.Models; +using FictionArchive.Service.Shared.Extensions; +using FictionArchive.Service.Shared.Services.EventBus.Implementations; +using Microsoft.Extensions.Options; + +namespace FictionArchive.Service.FileService; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + builder.AddLocalAppsettings(); + + builder.Services.AddControllers(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + + builder.Services.AddHealthChecks(); + + #region Event Bus + + builder.Services.AddRabbitMQ(opt => + { + builder.Configuration.GetSection("RabbitMQ").Bind(opt); + }); + + #endregion + + builder.Services.Configure(builder.Configuration.GetSection("ProxyConfiguration")); + + // Add S3 Client + builder.Services.Configure(builder.Configuration.GetSection("S3")); + builder.Services.AddSingleton(provider => + { + var config = provider.GetRequiredService>().Value; + var s3Config = new AmazonS3Config + { + ServiceURL = config.Url, // Garage endpoint + ForcePathStyle = true, // REQUIRED for Garage + AuthenticationRegion = "garage" + }; + return new AmazonS3Client( + new BasicAWSCredentials(config.AccessKey, config.SecretKey), + s3Config); + }); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.MapHealthChecks("/healthz"); + + app.MapControllers(); + + app.Run(); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/Properties/launchSettings.json b/FictionArchive.Service.FileService/Properties/launchSettings.json new file mode 100644 index 0000000..60a6c18 --- /dev/null +++ b/FictionArchive.Service.FileService/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5546", + "sslPort": 44373 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7247;http://localhost:5057", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs b/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs new file mode 100644 index 0000000..b79d65c --- /dev/null +++ b/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs @@ -0,0 +1,57 @@ +using Amazon.S3; +using Amazon.S3.Model; +using FictionArchive.Common.Enums; +using FictionArchive.Service.FileService.Models; +using FictionArchive.Service.FileService.Models.IntegrationEvents; +using FictionArchive.Service.Shared.Services.EventBus; +using Microsoft.Extensions.Options; + +namespace FictionArchive.Service.FileService.Services.EventHandlers; + +public class FileUploadRequestCreatedEventHandler : IIntegrationEventHandler +{ + private readonly ILogger _logger; + private readonly AmazonS3Client _amazonS3Client; + private readonly IEventBus _eventBus; + private readonly S3Configuration _s3Configuration; + private readonly ProxyConfiguration _proxyConfiguration; + + public FileUploadRequestCreatedEventHandler(ILogger logger, AmazonS3Client amazonS3Client, IEventBus eventBus, IOptions s3Configuration, IOptions proxyConfiguration) + { + _logger = logger; + _amazonS3Client = amazonS3Client; + _eventBus = eventBus; + _proxyConfiguration = proxyConfiguration.Value; + _s3Configuration = s3Configuration.Value; + } + + public async Task Handle(FileUploadRequestCreatedEvent @event) + { + var putObjectRequest = new PutObjectRequest(); + putObjectRequest.BucketName = _s3Configuration.Bucket; + putObjectRequest.Key = @event.FilePath; + + using MemoryStream memoryStream = new MemoryStream(@event.FileData); + putObjectRequest.InputStream = memoryStream; + + var s3Response = await _amazonS3Client.PutObjectAsync(putObjectRequest); + if (s3Response.HttpStatusCode != System.Net.HttpStatusCode.OK) + { + _logger.LogError("An error occurred while uploading file to S3. Response code: {responsecode}", s3Response.HttpStatusCode); + await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() + { + RequestId = @event.RequestId, + Status = RequestStatus.Failed, + ErrorMessage = "An error occurred while uploading file to S3." + }); + return; + } + + await _eventBus.Publish(new FileUploadRequestStatusUpdateEvent() + { + Status = RequestStatus.Success, + RequestId = @event.RequestId, + FileAccessUrl = _proxyConfiguration.BaseUrl + "/" + @event.FilePath + }); + } +} \ No newline at end of file diff --git a/FictionArchive.Service.FileService/appsettings.Development.json b/FictionArchive.Service.FileService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/FictionArchive.Service.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FictionArchive.Service.FileService/appsettings.json b/FictionArchive.Service.FileService/appsettings.json new file mode 100644 index 0000000..9ebfc9c --- /dev/null +++ b/FictionArchive.Service.FileService/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Proxy": { + "BaseUrl": "https://localhost:7247/api" + }, + "RabbitMQ": { + "ConnectionString": "amqp://localhost", + "ClientIdentifier": "NovelService" + }, + "S3": { + "Url": "https://s3.orfl.xyz", + "Bucket": "fictionarchive", + "AccessKey": "REPLACE_ME", + "SecretKey": "REPLACE_ME" + }, + "AllowedHosts": "*" +} diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs new file mode 100644 index 0000000..44e3f46 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestCreatedEvent.cs @@ -0,0 +1,10 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.FileService.IntegrationEvents; + +public class FileUploadRequestCreatedEvent : IIntegrationEvent +{ + public Guid RequestId { get; set; } + public string FilePath { get; set; } + public byte[] FileData { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.sln b/FictionArchive.sln index cee3dc2..6a6227c 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -16,6 +16,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.AuthenticationService", "FictionArchive.Service.AuthenticationService\FictionArchive.Service.AuthenticationService.csproj", "{70C4AE82-B01E-421D-B590-C0F47E63CD0C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,5 +56,9 @@ Global {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.Build.0 = Debug|Any CPU {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.ActiveCfg = Release|Any CPU {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.Build.0 = Release|Any CPU + {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal -- 2.49.1 From 16ed16ff62ef7c68773a3b1b2eff0ac130cf657e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 23 Nov 2025 21:16:26 -0500 Subject: [PATCH 4/4] [FA-5] Adds image support with proper S3 upload and replacement after upload --- AGENTS.md | 36 ++ FictionArchive.API/FictionArchive.API.csproj | 5 + FictionArchive.API/build_gateway.ps1 | 138 ----- FictionArchive.API/build_gateway.py | 129 +++++ FictionArchive.API/build_gateway.sh | 112 ---- ... => FileUploadRequestStatusUpdateEvent.cs} | 0 FictionArchive.Service.FileService/Program.cs | 5 +- .../FileUploadRequestCreatedEventHandler.cs | 1 + .../appsettings.json | 4 +- ...nArchive.Service.NovelService.Tests.csproj | 30 + .../NovelUpdateServiceTests.cs | 165 ++++++ ...FictionArchive.Service.NovelService.csproj | 1 + .../20251123203953_AddImages.Designer.cs | 540 ++++++++++++++++++ .../Migrations/20251123203953_AddImages.cs | 79 +++ .../NovelServiceDbContextModelSnapshot.cs | 54 ++ .../NovelUpdateServiceConfiguration.cs | 6 + .../Models/Images/Image.cs | 13 + .../FileUploadRequestStatusUpdateEvent.cs | 22 + .../Models/Novels/Chapter.cs | 4 + .../Models/Novels/Novel.cs | 2 + .../SourceAdapters/ChapterFetchResult.cs | 7 + .../Models/SourceAdapters/ChapterMetadata.cs | 2 +- .../Models/SourceAdapters/ImageData.cs | 7 + .../Models/SourceAdapters/NovelMetadata.cs | 1 + .../Program.cs | 7 +- ...leUploadRequestStatusUpdateEventHandler.cs | 39 ++ .../Services/NovelServiceDbContext.cs | 2 + .../Services/NovelUpdateService.cs | 107 +++- .../Services/SourceAdapters/ISourceAdapter.cs | 2 +- .../Novelpia/NovelpiaAdapter.cs | 52 +- .../Novelpia/NovelpiaAuthMessageHandler.cs | 7 +- .../appsettings.json | 3 + FictionArchive.sln | 6 + 33 files changed, 1321 insertions(+), 267 deletions(-) create mode 100644 AGENTS.md delete mode 100644 FictionArchive.API/build_gateway.ps1 create mode 100644 FictionArchive.API/build_gateway.py delete mode 100644 FictionArchive.API/build_gateway.sh rename FictionArchive.Service.FileService/Models/IntegrationEvents/{FileUploadRequestStatusUpdate.cs => FileUploadRequestStatusUpdateEvent.cs} (100%) create mode 100644 FictionArchive.Service.NovelService.Tests/FictionArchive.Service.NovelService.Tests.csproj create mode 100644 FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs create mode 100644 FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.Designer.cs create mode 100644 FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.cs create mode 100644 FictionArchive.Service.NovelService/Models/Configuration/NovelUpdateServiceConfiguration.cs create mode 100644 FictionArchive.Service.NovelService/Models/Images/Image.cs create mode 100644 FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs create mode 100644 FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterFetchResult.cs create mode 100644 FictionArchive.Service.NovelService/Models/SourceAdapters/ImageData.cs create mode 100644 FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ada212f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `FictionArchive.sln` ties together the gateway and all subgraph services. +- `FictionArchive.API`: Fusion gateway host; GraphQL endpoint at `/graphql`, health at `/healthz`, gateway configuration in `gateway.fgp`, and helper script `build_gateway.py`. +- `FictionArchive.Service.*`: GraphQL subgraphs (`AuthenticationService`, `FileService`, `NovelService`, `SchedulerService`, `TranslationService`, `UserService`) plus shared helpers in `FictionArchive.Service.Shared`. +- `FictionArchive.Common`: shared enums and hosting extensions used across services. +- Environment/config files live beside each service (`appsettings*.json`, `Properties/launchSettings.json`); build outputs under `bin/` and `obj/` should stay untracked. + +## Build, Test, and Development Commands +- `dotnet restore` then `dotnet build FictionArchive.sln` (Debug by default) to validate all projects compile. +- Run the gateway: `dotnet run --project FictionArchive.API` (serves HTTPS; ensure certificates are trusted locally). +- Run a subgraph locally: `dotnet run --project FictionArchive.Service.NovelService` (or any other service) to debug a single domain. +- Rebuild the Fusion gateway config after subgraph changes: `python FictionArchive.API/build_gateway.py` (requires Python 3 and the `fusion` CLI on PATH; uses `gateway_skip.txt` to omit services). +- If tests are added, prefer `dotnet test FictionArchive.sln` to cover the whole solution. + +## Coding Style & Naming Conventions +- Target .NET 8/C# 12; use 4-space indentation and file-scoped namespaces where practical. +- PascalCase for classes, records, interfaces, and public members; camelCase for locals/parameters; suffix async methods with `Async`. +- Favor dependency injection and extension methods for service wiring (see `Program.cs` files and `FictionArchive.Service.Shared/Extensions`). +- Keep GraphQL schema files and other generated artifacts out of commits unless intentionally versioned. + +## Testing Guidelines +- No dedicated test projects exist yet; when adding tests, create `*.Tests` projects aligned to each service (e.g., `FictionArchive.Service.NovelService.Tests`) and name test files `*Tests.cs`. +- Prefer xUnit with fluent assertions; aim for coverage on controllers/resolvers, integration events, and critical extension methods. +- Use in-memory fakes or test containers for external dependencies to keep tests deterministic. + +## Commit & Pull Request Guidelines +- Follow the observed pattern: `[FA-123] Short, imperative summary` (reference the tracker ID and keep scope focused). +- Keep commits small and self-contained; include relevant config/schema updates produced by the gateway build script when behavior changes. +- PRs should describe the problem, the solution, and any follow-up work; link to issues, attach GraphQL schema diffs or sample queries when applicable, and note any manual steps (migrations, secrets). + +## Security & Configuration Tips +- Do not commit secrets; use user secrets or environment variables for API keys and connection strings referenced in `appsettings*.json`. +- Verify HTTPS is enabled locally; adjust `launchSettings.json` only when necessary and document non-default ports. +- Regenerate `gateway.fgp` after changing subgraph schemas to avoid stale compositions. diff --git a/FictionArchive.API/FictionArchive.API.csproj b/FictionArchive.API/FictionArchive.API.csproj index 4246f74..752aa54 100644 --- a/FictionArchive.API/FictionArchive.API.csproj +++ b/FictionArchive.API/FictionArchive.API.csproj @@ -21,6 +21,11 @@ + + + + + diff --git a/FictionArchive.API/build_gateway.ps1 b/FictionArchive.API/build_gateway.ps1 deleted file mode 100644 index f06ddf0..0000000 --- a/FictionArchive.API/build_gateway.ps1 +++ /dev/null @@ -1,138 +0,0 @@ -<# -.SYNOPSIS - Export GraphQL schemas, pack subgraphs and compose the gateway (PowerShell). -.DESCRIPTION - - Searches for FictionArchive.Service.* folders one directory above this script. - - Reads skip-projects.txt next to the script. - - Builds each service (Release). - - Runs `dotnet run --no-build --no-launch-profile -- schema export` in each service to avoid running the web host. - - Packs subgraphs. - - Composes the gateway from FictionArchive.API. -#> - -[CmdletBinding()] -param() - -function Write-ErrExit { - param($Message, $Code = 1) - Write-Error $Message - exit $Code -} - -# Resolve directories -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$ServicesDir = Resolve-Path -Path (Join-Path $ScriptDir '..') -ErrorAction Stop -$ApiDir = Join-Path $ServicesDir 'FictionArchive.API' - -Write-Host "Script dir: $ScriptDir" -Write-Host "Services dir: $ServicesDir" - -# Load skip list -$SkipFile = Join-Path $ScriptDir 'gateway_skip.txt' -$SkipList = @() - -Write-Host "----------------------------------------" -Write-Host " Loading skip list..." -Write-Host "----------------------------------------" - -if (Test-Path $SkipFile) { - $SkipList = Get-Content $SkipFile | - ForEach-Object { $_.Trim() } | - Where-Object { $_ -and -not $_.StartsWith('#') } - - Write-Host "Skipping: $($SkipList -join ', ')" -} else { - Write-Warning "skip-projects.txt not found — no services will be skipped." -} - -# Find service directories -Write-Host -Write-Host "----------------------------------------" -Write-Host " Finding GraphQL services..." -Write-Host "----------------------------------------" - -$servicePattern = 'FictionArchive.Service.*' -$serviceDirs = Get-ChildItem -Path $ServicesDir -Directory -Filter 'FictionArchive.Service.*' - -if (-not $serviceDirs) { - Write-ErrExit "No service folders found matching FictionArchive.Service.* under $ServicesDir" -} - -$selectedServices = @() - -foreach ($d in $serviceDirs) { - if ($SkipList -contains $d.Name) { - Write-Host "Skipping: $($d.Name)" - continue - } - - Write-Host "Found: $($d.Name)" - $selectedServices += $d.FullName -} - -if (-not $selectedServices) { - Write-ErrExit "All services skipped — nothing to do." -} - -# Export schemas and pack subgraphs -Write-Host -Write-Host "----------------------------------------" -Write-Host " Exporting schemas & packing subgraphs..." -Write-Host "----------------------------------------" - -foreach ($svcPath in $selectedServices) { - $svcName = Split-Path -Leaf $svcPath - Write-Host "`nProcessing: $svcName" - - Push-Location $svcPath - try { - # Build Release - Write-Host "Building $svcName..." - dotnet build -c Release - if ($LASTEXITCODE -ne 0) { Write-ErrExit "dotnet build failed for $svcName" } - - # Schema export using dotnet run (no server) - Write-Host "Running schema export..." - dotnet run --no-build --no-launch-profile -- schema export --output schema.graphql - if ($LASTEXITCODE -ne 0) { Write-ErrExit "Schema export failed for $svcName" } - - # Pack subgraph - Write-Host "Running fusion subgraph pack..." - fusion subgraph pack - if ($LASTEXITCODE -ne 0) { Write-ErrExit "fusion subgraph pack failed for $svcName" } - - Write-Host "Completed: $svcName" - } - finally { - Pop-Location - } -} - -# Compose gateway -Write-Host -Write-Host "----------------------------------------" -Write-Host " Running fusion compose..." -Write-Host "----------------------------------------" - -if (-not (Test-Path $ApiDir)) { - Write-ErrExit "API directory not found: $ApiDir" -} - -Push-Location $ApiDir -try { - if (Test-Path "gateway.fgp") { Remove-Item "gateway.fgp" -Force } - - foreach ($svcPath in $selectedServices) { - $svcName = Split-Path -Leaf $svcPath - Write-Host "Composing: $svcName" - fusion compose -p gateway.fgp -s ("..\" + $svcName) - if ($LASTEXITCODE -ne 0) { Write-ErrExit "fusion compose failed for $svcName" } - } - - Write-Host "`nFusion build complete!" -} -finally { - Pop-Location -} - -exit 0 diff --git a/FictionArchive.API/build_gateway.py b/FictionArchive.API/build_gateway.py new file mode 100644 index 0000000..cb6f68e --- /dev/null +++ b/FictionArchive.API/build_gateway.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +import subprocess +import sys +import os +from pathlib import Path + +# ---------------------------------------- +# Helpers +# ---------------------------------------- + +def run(cmd, cwd=None): + """Run a command and exit on failure.""" + print(f"> {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd) + if result.returncode != 0: + print(f"ERROR: command failed in {cwd or os.getcwd()}") + sys.exit(result.returncode) + + +def load_skip_list(skip_file: Path): + if not skip_file.exists(): + print(f"WARNING: skip-projects.txt not found at {skip_file}") + return set() + + lines = skip_file.read_text().splitlines() + skip = {line.strip() for line in lines + if line.strip() and not line.strip().startswith("#")} + print("Skip list:", ", ".join(skip) if skip else "(none)") + return skip + + +# ---------------------------------------- +# Setup paths +# ---------------------------------------- + +script_dir = Path(__file__).parent.resolve() +services_dir = (script_dir / "..").resolve() +api_dir = services_dir / "FictionArchive.API" + +print(f"Script dir: {script_dir}") +print(f"Services dir: {services_dir}") + +skip_file = script_dir / "gateway_skip.txt" +skip_list = load_skip_list(skip_file) + +# ---------------------------------------- +# Find services +# ---------------------------------------- + +print("\n----------------------------------------") +print(" Finding GraphQL services...") +print("----------------------------------------") + +service_dirs = [ + d for d in services_dir.glob("FictionArchive.Service.*") + if d.is_dir() +] + +selected_services = [] + +for d in service_dirs: + name = d.name + if name in skip_list: + print(f"Skipping: {name}") + else: + print(f"Found: {name}") + selected_services.append(d) + +if not selected_services: + print("No services to process. Exiting.") + sys.exit(0) + +# ---------------------------------------- +# Export + pack +# ---------------------------------------- + +print("\n----------------------------------------") +print(" Exporting schemas & packing subgraphs...") +print("----------------------------------------") + +for svc in selected_services: + name = svc.name + print(f"\nProcessing {name}") + + # Build once + run(["dotnet", "build", "-c", "Release"], cwd=svc) + + # Export schema + run([ + "dotnet", "run", + "--no-build", + "--no-launch-profile", + "--", + "schema", "export", + "--output", "schema.graphql" + ], cwd=svc) + + # Pack subgraph + run(["fusion", "subgraph", "pack"], cwd=svc) + +# ---------------------------------------- +# Compose gateway +# ---------------------------------------- + +print("\n----------------------------------------") +print(" Running fusion compose...") +print("----------------------------------------") + +if not api_dir.exists(): + print(f"ERROR: FictionArchive.API not found at {api_dir}") + sys.exit(1) + +gateway_file = api_dir / "gateway.fgp" +if gateway_file.exists(): + gateway_file.unlink() + +for svc in selected_services: + name = svc.name + print(f"Composing: {name}") + + run([ + "fusion", "compose", + "-p", "gateway.fgp", + "-s", f"..{os.sep}{name}" + ], cwd=api_dir) + +print("\n----------------------------------------") +print(" Fusion build complete!") +print("----------------------------------------") diff --git a/FictionArchive.API/build_gateway.sh b/FictionArchive.API/build_gateway.sh deleted file mode 100644 index 3a30cd3..0000000 --- a/FictionArchive.API/build_gateway.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -############################################### -# Resolve important directories -############################################### - -# Directory where this script lives -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Services live one directory above the script's directory -SERVICES_DIR="$(cd "$ROOT/.." && pwd)" - -############################################### -# Skip list (folder names, match exactly) -############################################### -SKIP_FILE="$ROOT/gateway_skip.txt" -SKIP_PROJECTS=() - -if [[ -f "$SKIP_FILE" ]]; then - # Read non-empty lines ignoring comments - while IFS= read -r line; do - [[ -z "$line" || "$line" =~ ^# ]] && continue - SKIP_PROJECTS+=("$line") - done < "$SKIP_FILE" -else - echo "WARNING: skip-projects.txt not found — no projects will be skipped." -fi - -echo "----------------------------------------" -echo " Finding GraphQL services..." -echo "----------------------------------------" - -SERVICE_LIST=() - -# Convert skip projects into a single searchable string -SKIP_STRING=" ${SKIP_PROJECTS[*]} " - -# Find service directories -shopt -s nullglob -for FOLDER in "$SERVICES_DIR"/FictionArchive.Service.*; do - [ -d "$FOLDER" ] || continue - - PROJECT_NAME="$(basename "$FOLDER")" - - # Skip entries that match the skip list - if [[ "$SKIP_STRING" == *" $PROJECT_NAME "* ]]; then - echo "Skipping service: $PROJECT_NAME" - continue - fi - - echo "Found service: $PROJECT_NAME" - SERVICE_LIST+=("$FOLDER") -done -shopt -u nullglob - -echo -echo "----------------------------------------" -echo " Exporting schemas and packing subgraphs..." -echo "----------------------------------------" - -for SERVICE in "${SERVICE_LIST[@]}"; do - PROJECT_NAME="$(basename "$SERVICE")" - - echo "Processing service: $PROJECT_NAME" - pushd "$SERVICE" >/dev/null - - echo "Building service..." - dotnet build -c Release >/dev/null - - # Automatically detect built DLL in bin/Release// - DLL_PATH="$(find "bin/Release" -maxdepth 3 -name '*.dll' | head -n 1)" - if [[ -z "$DLL_PATH" ]]; then - echo "ERROR: Could not locate DLL for $PROJECT_NAME" - popd >/dev/null - exit 1 - fi - - echo "Running schema export..." - dotnet exec "$DLL_PATH" schema export --output schema.graphql - - echo "Running subgraph pack..." - fusion subgraph pack - - popd >/dev/null - echo "Completed: $PROJECT_NAME" - echo -done - -echo "----------------------------------------" -echo " Running fusion compose..." -echo "----------------------------------------" - -pushd "$ROOT" >/dev/null - -# Remove old composition file -rm -f gateway.fgp - -for SERVICE in "${SERVICE_LIST[@]}"; do - SERVICE_NAME="$(basename "$SERVICE")" - - echo "Composing subgraph: $SERVICE_NAME" - - # Note: Fusion compose must reference parent dir (services live above ROOT) - fusion compose -p gateway.fgp -s "../$SERVICE_NAME" -done - -popd >/dev/null - -echo "----------------------------------------" -echo " Fusion build complete!" -echo "----------------------------------------" diff --git a/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs b/FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs similarity index 100% rename from FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdate.cs rename to FictionArchive.Service.FileService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs diff --git a/FictionArchive.Service.FileService/Program.cs b/FictionArchive.Service.FileService/Program.cs index ed18765..3decc95 100644 --- a/FictionArchive.Service.FileService/Program.cs +++ b/FictionArchive.Service.FileService/Program.cs @@ -2,6 +2,8 @@ using Amazon.Runtime; using Amazon.S3; using FictionArchive.Common.Extensions; using FictionArchive.Service.FileService.Models; +using FictionArchive.Service.FileService.Models.IntegrationEvents; +using FictionArchive.Service.FileService.Services.EventHandlers; using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Services.EventBus.Implementations; using Microsoft.Extensions.Options; @@ -27,7 +29,8 @@ public class Program builder.Services.AddRabbitMQ(opt => { builder.Configuration.GetSection("RabbitMQ").Bind(opt); - }); + }) + .Subscribe(); #endregion diff --git a/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs b/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs index b79d65c..0383030 100644 --- a/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs +++ b/FictionArchive.Service.FileService/Services/EventHandlers/FileUploadRequestCreatedEventHandler.cs @@ -30,6 +30,7 @@ public class FileUploadRequestCreatedEventHandler : IIntegrationEventHandler + + + net8.0 + enable + enable + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs new file mode 100644 index 0000000..b82835b --- /dev/null +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -0,0 +1,165 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.FileService.IntegrationEvents; +using FictionArchive.Service.NovelService.Models.Configuration; +using FictionArchive.Service.NovelService.Models.Enums; +using FictionArchive.Service.NovelService.Models.Images; +using FictionArchive.Service.NovelService.Models.Localization; +using FictionArchive.Service.NovelService.Models.Novels; +using FictionArchive.Service.NovelService.Models.SourceAdapters; +using FictionArchive.Service.NovelService.Services; +using FictionArchive.Service.NovelService.Services.SourceAdapters; +using FictionArchive.Service.Shared.Services.EventBus; +using FluentAssertions; +using HtmlAgilityPack; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Xunit; + +namespace FictionArchive.Service.NovelService.Tests; + +public class NovelUpdateServiceTests +{ + private static NovelServiceDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"NovelUpdateServiceTests-{Guid.NewGuid()}") + .Options; + + return new NovelServiceDbContext(options, NullLogger.Instance); + } + + private static NovelCreateResult CreateNovelWithSingleChapter(NovelServiceDbContext dbContext, Source source) + { + var chapter = new Chapter + { + Order = 1, + Revision = 1, + Url = "http://demo/chapter-1", + Name = LocalizationKey.CreateFromText("Chapter 1", Language.En), + Body = new LocalizationKey { Texts = new List() }, + Images = new List() + }; + + var novel = new Novel + { + Url = "http://demo/novel", + ExternalId = "demo-1", + Author = new Person { Name = LocalizationKey.CreateFromText("Author", Language.En) }, + RawLanguage = Language.En, + RawStatus = NovelStatus.InProgress, + Source = source, + Name = LocalizationKey.CreateFromText("Demo Novel", Language.En), + Description = LocalizationKey.CreateFromText("Description", Language.En), + Chapters = new List { chapter }, + Tags = new List() + }; + + dbContext.Novels.Add(novel); + dbContext.SaveChanges(); + + return new NovelCreateResult(novel, chapter); + } + + private static NovelUpdateService CreateService( + NovelServiceDbContext dbContext, + ISourceAdapter adapter, + IEventBus eventBus, + string pendingImageUrl = "https://pending/placeholder.jpg") + { + var options = Options.Create(new NovelUpdateServiceConfiguration + { + PendingImageUrl = pendingImageUrl + }); + + return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, eventBus, options); + } + + [Fact] + public async Task PullChapterContents_rewrites_images_and_publishes_requests() + { + using var dbContext = CreateDbContext(); + var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; + var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + + var rawHtml = "

Hello

\"first\"\"second\""; + var image1 = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 1, 2, 3 } }; + var image2 = new ImageData { Url = "http://img/x2.jpg", Data = new byte[] { 4, 5, 6 } }; + + var adapter = Substitute.For(); + adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" }); + adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult + { + Text = rawHtml, + ImageData = new List { image1, image2 } + })); + + var publishedEvents = new List(); + var eventBus = Substitute.For(); + eventBus.Publish(Arg.Do(publishedEvents.Add)).Returns(Task.CompletedTask); + eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + + var pendingImageUrl = "https://pending/placeholder.jpg"; + var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); + + var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); + + updatedChapter.Images.Should().HaveCount(2); + updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url }); + updatedChapter.Images.All(i => i.Id != Guid.Empty).Should().BeTrue(); + + var storedHtml = updatedChapter.Body.Texts.Single().Text; + var doc = new HtmlDocument(); + doc.LoadHtml(storedHtml); + var imgNodes = doc.DocumentNode.SelectNodes("//img"); + imgNodes.Should().NotBeNull(); + imgNodes!.Count.Should().Be(2); + imgNodes.Should().OnlyContain(node => node.GetAttributeValue("src", string.Empty) == pendingImageUrl); + imgNodes.Select(node => node.GetAttributeValue("alt", string.Empty)) + .Should() + .BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString())); + + publishedEvents.Should().HaveCount(2); + publishedEvents.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id)); + publishedEvents.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data }); + publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/")); + } + + [Fact] + public async Task PullChapterContents_adds_alt_when_missing() + { + using var dbContext = CreateDbContext(); + var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; + var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + + var rawHtml = "

Hi

"; + var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } }; + + var adapter = Substitute.For(); + adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" }); + adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult + { + Text = rawHtml, + ImageData = new List { image } + })); + + var eventBus = Substitute.For(); + eventBus.Publish(Arg.Any()).Returns(Task.CompletedTask); + eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + + var service = CreateService(dbContext, adapter, eventBus); + + var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); + + var storedHtml = updatedChapter.Body.Texts.Single().Text; + var doc = new HtmlDocument(); + doc.LoadHtml(storedHtml); + var imgNode = doc.DocumentNode.SelectSingleNode("//img"); + imgNode.Should().NotBeNull(); + imgNode!.GetAttributeValue("alt", string.Empty).Should().Be(updatedChapter.Images.Single().Id.ToString()); + imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg"); + } + + private record NovelCreateResult(Novel Novel, Chapter Chapter); +} diff --git a/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj b/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj index ac56df2..1d90435 100644 --- a/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj +++ b/FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj @@ -9,6 +9,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.Designer.cs b/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.Designer.cs new file mode 100644 index 0000000..584b138 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.Designer.cs @@ -0,0 +1,540 @@ +// +using System; +using FictionArchive.Service.NovelService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + [DbContext(typeof(NovelServiceDbContext))] + [Migration("20251123203953_AddImages")] + partial class AddImages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPath") + .HasColumnType("text"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LocalizationKeys"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EngineId") + .HasColumnType("bigint"); + + b.Property("KeyRequestedForTranslationId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TranslateTo") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EngineId"); + + b.HasIndex("KeyRequestedForTranslationId"); + + b.ToTable("LocalizationRequests"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationKeyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("TranslationEngineId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LocalizationKeyId"); + + b.HasIndex("TranslationEngineId"); + + b.ToTable("LocalizationText"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BodyId"); + + b.HasIndex("NameId"); + + b.HasIndex("NovelId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CoverImageId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionId") + .HasColumnType("uuid"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("RawLanguage") + .HasColumnType("integer"); + + b.Property("RawStatus") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("StatusOverride") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CoverImageId"); + + b.HasIndex("DescriptionId"); + + b.HasIndex("NameId"); + + b.HasIndex("SourceId"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("TagType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayNameId"); + + b.HasIndex("SourceId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalUrl") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("TranslationEngines"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.Property("NovelsId") + .HasColumnType("bigint"); + + b.Property("TagsId") + .HasColumnType("bigint"); + + b.HasKey("NovelsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("NovelNovelTag"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter") + .WithMany("Images") + .HasForeignKey("ChapterId"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine") + .WithMany() + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation") + .WithMany() + .HasForeignKey("KeyRequestedForTranslationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Engine"); + + b.Navigation("KeyRequestedForTranslation"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null) + .WithMany("Texts") + .HasForeignKey("LocalizationKeyId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine") + .WithMany() + .HasForeignKey("TranslationEngineId"); + + b.Navigation("TranslationEngine"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body") + .WithMany() + .HasForeignKey("BodyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + .WithMany("Chapters") + .HasForeignKey("NovelId"); + + b.Navigation("Body"); + + b.Navigation("Name"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage") + .WithMany() + .HasForeignKey("CoverImageId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description") + .WithMany() + .HasForeignKey("DescriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("CoverImage"); + + b.Navigation("Description"); + + b.Navigation("Name"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName") + .WithMany() + .HasForeignKey("DisplayNameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId"); + + b.Navigation("DisplayName"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + .WithMany() + .HasForeignKey("NovelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Navigation("Texts"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.cs b/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.cs new file mode 100644 index 0000000..8c9f8a6 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251123203953_AddImages.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + /// + public partial class AddImages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImageId", + table: "Novels", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "Images", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OriginalPath = table.Column(type: "text", nullable: false), + NewPath = table.Column(type: "text", nullable: true), + ChapterId = table.Column(type: "bigint", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedTime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Images", x => x.Id); + table.ForeignKey( + name: "FK_Images_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Novels_CoverImageId", + table: "Novels", + column: "CoverImageId"); + + migrationBuilder.CreateIndex( + name: "IX_Images_ChapterId", + table: "Images", + column: "ChapterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Novels_Images_CoverImageId", + table: "Novels", + column: "CoverImageId", + principalTable: "Images", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Novels_Images_CoverImageId", + table: "Novels"); + + migrationBuilder.DropTable( + name: "Images"); + + migrationBuilder.DropIndex( + name: "IX_Novels_CoverImageId", + table: "Novels"); + + migrationBuilder.DropColumn( + name: "CoverImageId", + table: "Novels"); + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs index ed31991..470f4a9 100644 --- a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs @@ -23,6 +23,35 @@ namespace FictionArchive.Service.NovelService.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPath") + .HasColumnType("text"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("Images"); + }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => { b.Property("Id") @@ -158,6 +187,9 @@ namespace FictionArchive.Service.NovelService.Migrations b.Property("AuthorId") .HasColumnType("bigint"); + b.Property("CoverImageId") + .HasColumnType("uuid"); + b.Property("CreatedTime") .HasColumnType("timestamp with time zone"); @@ -194,6 +226,8 @@ namespace FictionArchive.Service.NovelService.Migrations b.HasIndex("AuthorId"); + b.HasIndex("CoverImageId"); + b.HasIndex("DescriptionId"); b.HasIndex("NameId"); @@ -335,6 +369,15 @@ namespace FictionArchive.Service.NovelService.Migrations b.ToTable("NovelNovelTag"); }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter") + .WithMany("Images") + .HasForeignKey("ChapterId"); + + b.Navigation("Chapter"); + }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => { b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine") @@ -398,6 +441,10 @@ namespace FictionArchive.Service.NovelService.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage") + .WithMany() + .HasForeignKey("CoverImageId"); + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description") .WithMany() .HasForeignKey("DescriptionId") @@ -418,6 +465,8 @@ namespace FictionArchive.Service.NovelService.Migrations b.Navigation("Author"); + b.Navigation("CoverImage"); + b.Navigation("Description"); b.Navigation("Name"); @@ -473,6 +522,11 @@ namespace FictionArchive.Service.NovelService.Migrations b.Navigation("Texts"); }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Navigation("Images"); + }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => { b.Navigation("Chapters"); diff --git a/FictionArchive.Service.NovelService/Models/Configuration/NovelUpdateServiceConfiguration.cs b/FictionArchive.Service.NovelService/Models/Configuration/NovelUpdateServiceConfiguration.cs new file mode 100644 index 0000000..e3d5e96 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/Configuration/NovelUpdateServiceConfiguration.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.NovelService.Models.Configuration; + +public class NovelUpdateServiceConfiguration +{ + public string PendingImageUrl { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Images/Image.cs b/FictionArchive.Service.NovelService/Models/Images/Image.cs new file mode 100644 index 0000000..c7141ee --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/Images/Image.cs @@ -0,0 +1,13 @@ +using FictionArchive.Service.NovelService.Models.Novels; +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.NovelService.Models.Images; + +public class Image : BaseEntity +{ + public string OriginalPath { get; set; } + public string? NewPath { get; set; } + + // Chapter link. Even if an image appears in another chapter, we should rehost it separately. + public Chapter? Chapter { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs new file mode 100644 index 0000000..4f61613 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/IntegrationEvents/FileUploadRequestStatusUpdateEvent.cs @@ -0,0 +1,22 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; + +public class FileUploadRequestStatusUpdateEvent : IIntegrationEvent +{ + public Guid RequestId { get; set; } + public RequestStatus Status { get; set; } + + #region Success + + public string? FileAccessUrl { get; set; } + + #endregion + + #region Failure + + public string? ErrorMessage { get; set; } + + #endregion +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs index e59705d..05c8011 100644 --- a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs +++ b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs @@ -1,3 +1,4 @@ +using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.Shared.Models; @@ -11,4 +12,7 @@ public class Chapter : BaseEntity public LocalizationKey Name { get; set; } public LocalizationKey Body { get; set; } + + // Images appearing in this chapter. + public List Images { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Novels/Novel.cs b/FictionArchive.Service.NovelService/Models/Novels/Novel.cs index 81d5ca7..582cd96 100644 --- a/FictionArchive.Service.NovelService/Models/Novels/Novel.cs +++ b/FictionArchive.Service.NovelService/Models/Novels/Novel.cs @@ -1,4 +1,5 @@ using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.Shared.Models; using NovelStatus = FictionArchive.Service.NovelService.Models.Enums.NovelStatus; @@ -22,4 +23,5 @@ public class Novel : BaseEntity public List Chapters { get; set; } public List Tags { get; set; } + public Image? CoverImage { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterFetchResult.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterFetchResult.cs new file mode 100644 index 0000000..ca6805d --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterFetchResult.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.NovelService.Models.SourceAdapters; + +public class ChapterFetchResult +{ + public string Text { get; set; } + public List ImageData { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterMetadata.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterMetadata.cs index 353c9bd..d6be4df 100644 --- a/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterMetadata.cs +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/ChapterMetadata.cs @@ -6,5 +6,5 @@ public class ChapterMetadata public uint Order { get; set; } public string? Url { get; set; } public string Name { get; set; } - + public List ImageUrls { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/ImageData.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/ImageData.cs new file mode 100644 index 0000000..52838cb --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/ImageData.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.NovelService.Models.SourceAdapters; + +public class ImageData +{ + public string Url { get; set; } + public byte[] Data { get; set; } +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs index af1467a..716dcbe 100644 --- a/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs @@ -11,6 +11,7 @@ public class NovelMetadata public string AuthorUrl { get; set; } public string Url { get; set; } public string ExternalId { get; set; } + public ImageData? CoverImage { get; set; } public Language RawLanguage { get; set; } public NovelStatus RawStatus { get; set; } diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs index 233118f..68fe593 100644 --- a/FictionArchive.Service.NovelService/Program.cs +++ b/FictionArchive.Service.NovelService/Program.cs @@ -1,4 +1,6 @@ +using FictionArchive.Common.Extensions; using FictionArchive.Service.NovelService.GraphQL; +using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.IntegrationEvents; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.EventHandlers; @@ -16,6 +18,7 @@ public class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + builder.AddLocalAppsettings(); builder.Services.AddMemoryCache(); @@ -27,7 +30,8 @@ public class Program }) .Subscribe() .Subscribe() - .Subscribe(); + .Subscribe() + .Subscribe(); #endregion @@ -56,6 +60,7 @@ public class Program }) .AddHttpMessageHandler(); + builder.Services.Configure(builder.Configuration.GetSection("UpdateService")); builder.Services.AddTransient(); #endregion diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs new file mode 100644 index 0000000..f6b3dc3 --- /dev/null +++ b/FictionArchive.Service.NovelService/Services/EventHandlers/FileUploadRequestStatusUpdateEventHandler.cs @@ -0,0 +1,39 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Models.IntegrationEvents; +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.NovelService.Services.EventHandlers; + +public class FileUploadRequestStatusUpdateEventHandler : IIntegrationEventHandler +{ + private readonly ILogger _logger; + private readonly NovelServiceDbContext _context; + private readonly NovelUpdateService _novelUpdateService; + + public FileUploadRequestStatusUpdateEventHandler(ILogger logger, NovelServiceDbContext context, NovelUpdateService novelUpdateService) + { + _logger = logger; + _context = context; + _novelUpdateService = novelUpdateService; + } + + public async Task Handle(FileUploadRequestStatusUpdateEvent @event) + { + var image = await _context.Images.FindAsync(@event.RequestId); + if (image == null) + { + // Not a request we care about. + return; + } + if (@event.Status == RequestStatus.Failed) + { + _logger.LogError("Image upload failed for image with id {imageId}", image.Id); + return; + } + else if (@event.Status == RequestStatus.Success) + { + _logger.LogInformation("Image upload succeeded for image with id {imageId}", image.Id); + await _novelUpdateService.UpdateImage(image.Id, @event.FileAccessUrl); + } + } +} \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs index a6d0204..6bcb7c7 100644 --- a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs +++ b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs @@ -1,3 +1,4 @@ +using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.Shared.Services.Database; @@ -14,4 +15,5 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger Tags { get; set; } public DbSet LocalizationKeys { get; set; } public DbSet LocalizationRequests { get; set; } + public DbSet Images { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index cf3669e..de3438b 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -1,9 +1,15 @@ +using FictionArchive.Service.FileService.IntegrationEvents; +using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; +using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services.SourceAdapters; +using FictionArchive.Service.Shared.Services.EventBus; +using HtmlAgilityPack; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace FictionArchive.Service.NovelService.Services; @@ -12,12 +18,16 @@ public class NovelUpdateService private readonly NovelServiceDbContext _dbContext; private readonly ILogger _logger; private readonly IEnumerable _sourceAdapters; + private readonly IEventBus _eventBus; + private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration; - public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters) + public NovelUpdateService(NovelServiceDbContext dbContext, ILogger logger, IEnumerable sourceAdapters, IEventBus eventBus, IOptions novelUpdateServiceConfiguration) { _dbContext = dbContext; _logger = logger; _sourceAdapters = sourceAdapters; + _eventBus = eventBus; + _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; } public async Task ImportNovel(string novelUrl) @@ -59,6 +69,10 @@ public class NovelUpdateService RawLanguage = metadata.RawLanguage, Url = metadata.Url, ExternalId = metadata.ExternalId, + CoverImage = metadata.CoverImage != null ? new Image() + { + OriginalPath = metadata.CoverImage.Url, + } : null, Chapters = metadata.Chapters.Select(chapter => { return new Chapter() @@ -85,7 +99,18 @@ public class NovelUpdateService } }); await _dbContext.SaveChangesAsync(); - + + // Signal request for cover image if present + if (addedNovel.Entity.CoverImage != null) + { + await _eventBus.Publish(new FileUploadRequestCreatedEvent() + { + RequestId = addedNovel.Entity.CoverImage.Id, + FileData = metadata.CoverImage.Data, + FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg" + }); + } + return addedNovel.Entity; } @@ -95,17 +120,85 @@ public class NovelUpdateService .Include(novel => novel.Chapters) .ThenInclude(chapter => chapter.Body) .ThenInclude(body => body.Texts) - .Include(novel => novel.Source) + .Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images) .FirstOrDefaultAsync(); var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault(); var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key); var rawChapter = await adapter.GetRawChapter(chapter.Url); - chapter.Body.Texts.Add(new LocalizationText() + var localizationText = new LocalizationText() { - Text = rawChapter, + Text = rawChapter.Text, Language = novel.RawLanguage - }); + }; + chapter.Body.Texts.Add(localizationText); + chapter.Images = rawChapter.ImageData.Select(img => new Image() + { + OriginalPath = img.Url + }).ToList(); await _dbContext.SaveChangesAsync(); + + // Images are saved and have ids, update the chapter body to replace image tags + var chapterDoc = new HtmlDocument(); + chapterDoc.LoadHtml(rawChapter.Text); + foreach (var image in chapter.Images) + { + var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@src='{image.OriginalPath}']"); + if (match != null) + { + match.Attributes["src"].Value = _novelUpdateServiceConfiguration.PendingImageUrl; + if (match.Attributes.Contains("alt")) + { + match.Attributes["alt"].Value = image.Id.ToString(); + } + else + { + match.Attributes.Add("alt", image.Id.ToString()); + } + } + } + localizationText.Text = chapterDoc.DocumentNode.OuterHtml; + await _dbContext.SaveChangesAsync(); + + // Body was updated, raise image request + int imgCount = 0; + foreach (var image in chapter.Images) + { + var data = rawChapter.ImageData.FirstOrDefault(img => img.Url == image.OriginalPath); + await _eventBus.Publish(new FileUploadRequestCreatedEvent() + { + FileData = data.Data, + FilePath = $"{novel.Id}/Images/Chapter-{chapter.Id}/{imgCount++}.jpg", + RequestId = image.Id + }); + } + return chapter; } -} \ No newline at end of file + + public async Task UpdateImage(Guid imageId, string newUrl) + { + var image = await _dbContext.Images + .Include(img => img.Chapter) + .ThenInclude(chapter => chapter.Body) + .ThenInclude(body => body.Texts) + .FirstOrDefaultAsync(image => image.Id == imageId); + image.NewPath = newUrl; + + // If this is an image from a chapter, let's update the chapter body(s) + if (image.Chapter != null) + { + foreach (var bodyText in image.Chapter.Body.Texts) + { + var chapterDoc = new HtmlDocument(); + chapterDoc.LoadHtml(bodyText.Text); + var match = chapterDoc.DocumentNode.SelectSingleNode(@$"//img[@alt='{image.Id}']"); + if (match != null) + { + match.Attributes["src"].Value = newUrl; + } + } + } + + await _dbContext.SaveChangesAsync(); + } +} diff --git a/FictionArchive.Service.NovelService/Services/SourceAdapters/ISourceAdapter.cs b/FictionArchive.Service.NovelService/Services/SourceAdapters/ISourceAdapter.cs index cd4ff0b..d4870f4 100644 --- a/FictionArchive.Service.NovelService/Services/SourceAdapters/ISourceAdapter.cs +++ b/FictionArchive.Service.NovelService/Services/SourceAdapters/ISourceAdapter.cs @@ -8,5 +8,5 @@ public interface ISourceAdapter public SourceDescriptor SourceDescriptor { get; } public Task CanProcessNovel(string url); public Task GetMetadata(string novelUrl); - public Task GetRawChapter(string chapterUrl); + public Task GetRawChapter(string chapterUrl); } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs index b99e785..ae22269 100644 --- a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs +++ b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs @@ -81,6 +81,21 @@ public class NovelpiaAdapter : ISourceAdapter novel.AuthorName = authorMatch.Groups[2].Value; novel.AuthorUrl = authorMatch.Groups[2].Value; + // Cover image URL + var coverMatch = Regex.Match(novelData, @"href=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)"""); + string coverImageUrl = coverMatch.Groups[1].Value; + if (string.IsNullOrEmpty(coverImageUrl)) + { + coverMatch = Regex.Match(novelData, @"src=""(//images\.novelpia\.com/imagebox/cover/.+?\.file)"""); + coverImageUrl = coverMatch.Groups[1].Value; + } + + novel.CoverImage = new ImageData() + { + Url = coverImageUrl, + Data = await GetImageData(coverImageUrl), + }; + // Some badge info var badgeSet = Regex.Match(novelData, @"(?s)(.*?)<\/p>"); var badgeMatches = Regex.Matches(badgeSet.Groups[1].Value, @"]*>(.*?)<\/span>"); @@ -160,7 +175,7 @@ public class NovelpiaAdapter : ISourceAdapter return novel; } - public async Task GetRawChapter(string chapterUrl) + public async Task GetRawChapter(string chapterUrl) { var chapterId = uint.Parse(Regex.Match(chapterUrl, ChapterIdRegex).Groups[1].Value); var endpoint = ChapterDownloadEndpoint + chapterId; @@ -171,6 +186,11 @@ public class NovelpiaAdapter : ISourceAdapter { throw new Exception(); } + + var fetchResult = new ChapterFetchResult() + { + ImageData = new List() + }; StringBuilder builder = new StringBuilder(); using var doc = JsonDocument.Parse(responseContent); @@ -182,10 +202,20 @@ public class NovelpiaAdapter : ISourceAdapter foreach (JsonElement item in sArray.EnumerateArray()) { string text = item.GetProperty("text").GetString(); + var imageMatch = Regex.Match(text, @""); if (text.Contains("cover-wrapper")) { continue; } + if (imageMatch.Success) + { + var url = imageMatch.Groups[1].Value; + fetchResult.ImageData.Add(new ImageData() + { + Url = url, + Data = await GetImageData(url) + }); + } if (text.Contains("opacity: 0")) { continue; @@ -193,8 +223,24 @@ public class NovelpiaAdapter : ISourceAdapter builder.Append(WebUtility.HtmlDecode(text)); } + fetchResult.Text = builder.ToString(); - return builder.ToString(); - + return fetchResult; + } + + private async Task GetImageData(string url) + { + if (!url.StartsWith("http")) + { + url = "https:" + url; + } + + var image = await _httpClient.GetAsync(url); + if (!image.IsSuccessStatusCode) + { + _logger.LogError("Attempting to fetch image with url {imgUrl} returned status code {code}.", url, image.StatusCode); + throw new Exception(); + } + return await image.Content.ReadAsByteArrayAsync(); } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAuthMessageHandler.cs b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAuthMessageHandler.cs index 532985c..466d20b 100644 --- a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAuthMessageHandler.cs +++ b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAuthMessageHandler.cs @@ -52,10 +52,15 @@ public class NovelpiaAuthMessageHandler : DelegatingHandler var response = await _httpClient.SendAsync(loginMessage); using (var streamReader = new StreamReader(response.Content.ReadAsStream())) { - if (streamReader.ReadToEnd().Contains(LoginSuccessMessage)) + var message = await streamReader.ReadToEndAsync(); + if (message.Contains(LoginSuccessMessage)) { _cache.Set(CacheKey, loginKey); } + else + { + throw new Exception("An error occured while retrieving the login key. Message: " + message); + } } } diff --git a/FictionArchive.Service.NovelService/appsettings.json b/FictionArchive.Service.NovelService/appsettings.json index bde9f89..3bf5160 100644 --- a/FictionArchive.Service.NovelService/appsettings.json +++ b/FictionArchive.Service.NovelService/appsettings.json @@ -9,6 +9,9 @@ "Username": "REPLACE_ME", "Password": "REPLACE_ME" }, + "UpdateService": { + "PendingImageUrl": "https://localhost:7247/api/pendingupload.png" + }, "ConnectionStrings": { "DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres" }, diff --git a/FictionArchive.sln b/FictionArchive.sln index 6a6227c..81af8d3 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Auth EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.NovelService.Tests", "FictionArchive.Service.NovelService.Tests\FictionArchive.Service.NovelService.Tests.csproj", "{166E645E-9DFB-44E8-8CC8-FA249A11679F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,5 +62,9 @@ Global {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.Build.0 = Debug|Any CPU {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.ActiveCfg = Release|Any CPU {EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.Build.0 = Release|Any CPU + {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal -- 2.49.1