19 Commits

Author SHA1 Message Date
gamer147
c6d794aabc Merge remote-tracking branch 'origin/feature/FA-11_CICD' into feature/FA-11_CICD
Some checks failed
CI / build-backend (pull_request) Failing after 53s
CI / build-frontend (pull_request) Failing after 4m52s
2025-11-26 00:18:40 -05:00
gamer147
62e7e20f94 [FA-11] Fix issue with local package reference 2025-11-26 00:18:33 -05:00
9e1792e4d0 Merge branch 'master' into feature/FA-11_CICD
Some checks failed
CI / build-backend (pull_request) Failing after 1m49s
CI / build-frontend (pull_request) Has been cancelled
2025-11-26 04:50:58 +00:00
gamer147
747a212fb0 Add assistant directly 2025-11-25 23:50:01 -05:00
gamer147
200bdaabed [FA-11] Try to add Claude assistant
Some checks failed
CI / build-frontend (pull_request) Has been cancelled
CI / build-backend (pull_request) Has been cancelled
2025-11-25 23:45:53 -05:00
gamer147
caa36648e2 Haven't checked yet 2025-11-25 23:29:55 -05:00
6f2454329d Merge pull request 'feature/FA-18_BootstrapFrontend' (#32) from feature/FA-18_BootstrapFrontend into master
Reviewed-on: #32
2025-11-24 18:37:29 +00:00
gamer147
fdf2ff7c1b [FA-18] Adjustments based on feeback 2025-11-24 13:36:49 -05:00
gamer147
e8596b67c4 [FA-18] Frontend bootstrapped 2025-11-24 13:25:29 -05:00
a01250696f Merge pull request 'feature/FA-5_ImageSupport' (#31) from feature/FA-5_ImageSupport into master
Reviewed-on: #31
2025-11-24 02:17:10 +00:00
gamer147
16ed16ff62 [FA-5] Adds image support with proper S3 upload and replacement after upload 2025-11-23 21:16:26 -05:00
gamer147
573a0f6e3f [FA-5] FileService setup, build scripts tweaked to be easier to maintain 2025-11-22 22:36:44 -05:00
gamer147
1adbb955cf [FA-5] Remove gateway.fgp 2025-11-22 22:30:46 -05:00
gamer147
71e27b5dbb [FA-5] Update gitignore for Fusion Gateway builds 2025-11-22 22:30:06 -05:00
708f1a5338 Merge pull request 'feature/FA-13_ApiGateway' (#30) from feature/FA-13_ApiGateway into master
Reviewed-on: #30
2025-11-22 23:14:22 +00:00
gamer147
df7978fb43 [FA-13] Works locally, probably works in CICD 2025-11-22 18:14:01 -05:00
gamer147
ceb4271182 [FA-13] Forgot gateway schema 2025-11-22 00:33:34 -05:00
gamer147
ffa51cfce4 [FA-13] Adds Fusion gateway, need to setup a local build and stitch process. Only NovelService included right now. 2025-11-22 00:33:06 -05:00
592fa7fb36 Merge pull request '[FA-10] Adds user service and authentication service' (#16) from feature/FA-10_UserService into master
Reviewed-on: #16
2025-11-22 04:09:32 +00:00
112 changed files with 13011 additions and 159 deletions

View File

@@ -0,0 +1,122 @@
name: Build Gateway
on:
workflow_dispatch:
push:
branches:
- master
paths:
- 'FictionArchive.API/**'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_NAME: ${{ gitea.repository_owner }}/fictionarchive-api
jobs:
build-gateway:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Create subgraphs directory
run: mkdir -p subgraphs
# Download all subgraph packages from latest successful builds
- name: Download Novel Service subgraph
uses: actions/download-artifact@v4
with:
name: novel-service-subgraph
path: subgraphs/novel
continue-on-error: true
- name: Download Translation Service subgraph
uses: actions/download-artifact@v4
with:
name: translation-service-subgraph
path: subgraphs/translation
continue-on-error: true
- name: Download Scheduler Service subgraph
uses: actions/download-artifact@v4
with:
name: scheduler-service-subgraph
path: subgraphs/scheduler
continue-on-error: true
- name: Download User Service subgraph
uses: actions/download-artifact@v4
with:
name: user-service-subgraph
path: subgraphs/user
continue-on-error: true
- name: Download File Service subgraph
uses: actions/download-artifact@v4
with:
name: file-service-subgraph
path: subgraphs/file
continue-on-error: true
- name: Configure subgraph URLs for Docker
run: |
for fsp in subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
dir=$(dirname "$fsp")
name=$(basename "$dir")
url="http://${name}-service:8080/graphql"
echo "Setting $name URL to $url"
fusion subgraph config set http --url "$url" -c "$fsp"
fi
done
- name: Compose gateway
run: |
cd FictionArchive.API
rm -f gateway.fgp
for fsp in ../subgraphs/*/*.fsp; do
if [ -f "$fsp" ]; then
echo "Composing: $fsp"
fusion compose -p gateway.fgp -s "$fsp"
fi
done
- name: Restore dependencies
run: dotnet restore FictionArchive.API/FictionArchive.API.csproj
- name: Build gateway
run: dotnet build FictionArchive.API/FictionArchive.API.csproj -c Release --no-restore -p:SkipFusionBuild=true
- name: Run tests
run: dotnet test FictionArchive.sln -c Release --no-build --verbosity normal
continue-on-error: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: FictionArchive.API/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -0,0 +1,77 @@
name: Build Subgraphs
on:
push:
branches:
- master
paths:
- 'FictionArchive.Service.*/**'
- 'FictionArchive.Common/**'
- 'FictionArchive.Service.Shared/**'
jobs:
build-subgraphs:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: novel-service
project: FictionArchive.Service.NovelService
subgraph: Novel
- name: translation-service
project: FictionArchive.Service.TranslationService
subgraph: Translation
- name: scheduler-service
project: FictionArchive.Service.SchedulerService
subgraph: Scheduler
- name: user-service
project: FictionArchive.Service.UserService
subgraph: User
- name: file-service
project: FictionArchive.Service.FileService
subgraph: File
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Restore dependencies
run: dotnet restore ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj
- name: Build
run: dotnet build ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj -c Release --no-restore
- name: Export schema
run: |
dotnet run --project ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj \
--no-build -c Release --no-launch-profile \
-- schema export --output ${{ matrix.service.project }}/schema.graphql
- name: Pack subgraph
run: fusion subgraph pack -w ${{ matrix.service.project }}
- name: Upload subgraph package
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.service.name }}-subgraph
path: ${{ matrix.service.project }}/*.fsp
retention-days: 30
# Trigger gateway build after all subgraphs are built
trigger-gateway:
runs-on: ubuntu-latest
needs: build-subgraphs
steps:
- name: Trigger gateway workflow
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/actions/workflows/build-gateway.yml/dispatches" \
-d '{"ref":"master"}'

View File

@@ -0,0 +1,63 @@
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Fusion CLI
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
- name: Restore dependencies
run: dotnet restore FictionArchive.sln
- name: Build solution
run: dotnet build FictionArchive.sln --configuration Release --no-restore
- name: Run tests
run: dotnet test FictionArchive.sln --configuration Release --no-build --verbosity normal
build-frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: fictionarchive-web
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: fictionarchive-web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build

View File

@@ -0,0 +1,43 @@
name: Claude Assistant for Gitea
on:
# Trigger on issue comments (works on both issues and pull requests in Gitea)
issue_comment:
types: [created]
# Trigger on issues being opened or assigned
issues:
types: [opened, assigned]
# Note: pull_request_review_comment has limited support in Gitea
# Use issue_comment instead which covers PR comments
jobs:
claude-assistant:
# Basic trigger detection - check for @claude in comments or issue body
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
# Note: Gitea Actions may not require id-token: write for basic functionality
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Assistant
uses: markwylde/claude-code-gitea-action
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
gitea_token: ${{ secrets.CLAUDE_GITEA_TOKEN }}
timeout_minutes: "60"
trigger_phrase: "@claude"
# Optional: Customize for Gitea environment
custom_instructions: |
You are working in a Gitea environment. Be aware that:
- Some GitHub Actions features may behave differently
- Focus on core functionality and avoid advanced GitHub-specific features
- Use standard git operations when possible

View File

@@ -0,0 +1,98 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ${{ gitea.server_url }}
IMAGE_PREFIX: ${{ gitea.repository_owner }}/fictionarchive
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
service:
- name: api
dockerfile: FictionArchive.API/Dockerfile
- name: novel-service
dockerfile: FictionArchive.Service.NovelService/Dockerfile
- name: user-service
dockerfile: FictionArchive.Service.UserService/Dockerfile
- name: translation-service
dockerfile: FictionArchive.Service.TranslationService/Dockerfile
- name: file-service
dockerfile: FictionArchive.Service.FileService/Dockerfile
- name: scheduler-service
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.service.dockerfile }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push frontend Docker image
uses: docker/build-push-action@v6
with:
context: ./fictionarchive-web
file: fictionarchive-web/Dockerfile
push: true
build-args: |
VITE_GRAPHQL_URI=${{ vars.VITE_GRAPHQL_URI }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=${{ vars.VITE_OIDC_CLIENT_ID }}
VITE_OIDC_REDIRECT_URI=${{ vars.VITE_OIDC_REDIRECT_URI }}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.VITE_OIDC_POST_LOGOUT_REDIRECT_URI }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
cache-from: type=gha
cache-to: type=gha,mode=max

5
.gitignore vendored
View File

@@ -135,3 +135,8 @@ _NCrunch*
# Local user appsettings
appsettings.Local.json
# Fusion Builds
schema.graphql
*.fsp
gateway.fgp

36
Documentation/AGENTS.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,405 @@
# FictionArchive Architecture Overview
## High-Level Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ React 19 Frontend │
│ (Apollo Client, TailwindCSS, OIDC Auth) │
└───────────────────────────┬────────────────────────────────────┘
│ GraphQL
┌────────────────────────────────────────────────────────────────┐
│ Hot Chocolate Fusion Gateway │
│ (FictionArchive.API) │
└──────┬────────┬────────┬────────┬────────┬─────────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────┐┌──────────┐┌───────────┐┌──────────┐┌──────────────┐
│ Novel ││ User ││Translation││Scheduler ││ File │
│ Service ││ Service ││ Service ││ Service ││ Service │
└────┬─────┘└────┬─────┘└─────┬─────┘└────┬─────┘└──────┬───────┘
│ │ │ │ │
└───────────┴────────────┴───────────┴─────────────┘
┌────────┴────────┐
│ RabbitMQ │
│ (Event Bus) │
└─────────────────┘
┌────────┴────────┐
│ PostgreSQL │
│ (per service) │
└─────────────────┘
```
## Technology Stack
| Layer | Technology | Version |
|-------|------------|---------|
| Runtime | .NET | 8.0 |
| GraphQL | Hot Chocolate / Fusion | 13+ |
| Database | PostgreSQL | 12+ |
| ORM | Entity Framework Core | 8.0 |
| Message Broker | RabbitMQ | 3.12+ |
| Job Scheduler | Quartz.NET | Latest |
| Object Storage | AWS S3 / Garage | - |
| Date/Time | NodaTime | Latest |
| Frontend | React | 19.2 |
| Frontend Build | Vite | 7.2 |
| GraphQL Client | Apollo Client | 4.0 |
| Auth | OIDC Client TS | 3.4 |
| Styling | TailwindCSS | 3.4 |
| UI Components | Radix UI | Latest |
## Project Structure
```
FictionArchive.sln
├── FictionArchive.Common # Shared enums and extensions
├── FictionArchive.API # GraphQL Fusion Gateway
├── FictionArchive.Service.Shared # Shared infrastructure
├── FictionArchive.Service.NovelService
├── FictionArchive.Service.UserService
├── FictionArchive.Service.TranslationService
├── FictionArchive.Service.FileService
├── FictionArchive.Service.SchedulerService
├── FictionArchive.Service.AuthenticationService
├── FictionArchive.Service.NovelService.Tests
└── fictionarchive-web # React frontend
```
## Services
### FictionArchive.API - GraphQL Fusion Gateway
- **Role**: Single entry point for all GraphQL queries
- **Port**: 5001 (HTTPS)
- **Endpoints**:
- `/graphql` - GraphQL endpoint
- `/healthz` - Health check
- **Responsibilities**:
- Compose GraphQL schemas from all subgraphs
- Route queries to appropriate services
- CORS policy management
### FictionArchive.Service.NovelService
- **Role**: Novel/fiction content management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_NovelService`
- **GraphQL Operations**:
- `GetNovels` - Paginated, filterable novel listing
- `ImportNovel` - Trigger novel import
- `FetchChapterContents` - Fetch chapter content
- **Models**: Novel, Chapter, Source, NovelTag, Image, LocalizationKey
- **External Integration**: Novelpia adapter
- **Events Published**: `TranslationRequestCreatedEvent`, `FileUploadRequestCreatedEvent`
- **Events Subscribed**: `TranslationRequestCompletedEvent`, `NovelUpdateRequestedEvent`, `ChapterPullRequestedEvent`, `FileUploadRequestStatusUpdateEvent`
### FictionArchive.Service.UserService
- **Role**: User identity and profile management
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_UserService`
- **Models**: User (with OAuth provider linking)
- **Events Subscribed**: `AuthUserAddedEvent`
### FictionArchive.Service.TranslationService
- **Role**: Text translation orchestration
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_TranslationService`
- **External Integration**: DeepL API
- **Models**: TranslationRequest
- **Events Published**: `TranslationRequestCompletedEvent`
- **Events Subscribed**: `TranslationRequestCreatedEvent`
### FictionArchive.Service.FileService
- **Role**: File storage and S3 proxy
- **Port**: 8080 (HTTP)
- **Protocol**: REST only (not GraphQL)
- **Endpoints**: `GET /api/{*path}` - S3 file proxy
- **External Integration**: S3-compatible storage (AWS S3 / Garage)
- **Events Published**: `FileUploadRequestStatusUpdateEvent`
- **Events Subscribed**: `FileUploadRequestCreatedEvent`
### FictionArchive.Service.SchedulerService
- **Role**: Job scheduling and automation
- **Port**: 8081 (HTTPS)
- **Database**: `FictionArchive_SchedulerService`
- **Scheduler**: Quartz.NET with persistent job store
- **GraphQL Operations**: `ScheduleEventJob`, `GetScheduledJobs`
- **Models**: SchedulerJob, EventJobTemplate
### FictionArchive.Service.AuthenticationService
- **Role**: OAuth/OIDC webhook receiver
- **Port**: 8080 (HTTP)
- **Protocol**: REST only
- **Endpoints**: `POST /api/AuthenticationWebhook/UserRegistered`
- **Events Published**: `AuthUserAddedEvent`
- **No Database** - Stateless webhook handler
## Communication Patterns
### GraphQL Federation
- Hot Chocolate Fusion Gateway composes subgraph schemas
- Schema export automated via `build_gateway.py`
- Each service defines its own Query/Mutation types
### Event-Driven Architecture (RabbitMQ)
- Direct exchange: `fiction-archive-event-bus`
- Per-service queues based on `ClientIdentifier`
- Routing key = event class name
- Headers: `X-Created-At`, `X-Event-Id`
- NodaTime JSON serialization
### Event Flow Examples
**Novel Import:**
```
1. Frontend → importNovel mutation
2. NovelService publishes NovelUpdateRequestedEvent
3. NovelUpdateRequestedEventHandler processes
4. Fetches metadata via NovelpiaAdapter
5. Publishes FileUploadRequestCreatedEvent (for cover)
6. FileService uploads to S3
7. FileService publishes FileUploadRequestStatusUpdateEvent
8. NovelService updates image path
```
**Translation:**
```
1. NovelService publishes TranslationRequestCreatedEvent
2. TranslationService translates via DeepL
3. TranslationService publishes TranslationRequestCompletedEvent
4. NovelService updates chapter translation
```
## Data Storage
### Database Pattern
- Database per service (PostgreSQL)
- Connection string format: `Host=localhost;Database=FictionArchive_{ServiceName};...`
- Auto-migration on startup via `dbContext.UpdateDatabase()`
### Audit Trail
- `AuditInterceptor` auto-sets `CreatedTime` and `LastUpdatedTime`
- `IAuditable` interface with NodaTime `Instant` fields
- `BaseEntity<TKey>` abstract base class
### Object Storage
- S3-compatible (AWS S3 or Garage)
- Path-style URLs for Garage compatibility
- Proxied through FileService
## Frontend Architecture
### Structure
```
fictionarchive-web/
├── src/
│ ├── auth/ # OIDC authentication
│ ├── components/ # React components
│ │ └── ui/ # Radix-based primitives
│ ├── pages/ # Route pages
│ ├── layouts/ # Layout components
│ ├── graphql/ # GraphQL queries
│ ├── __generated__/ # Codegen output
│ └── lib/ # Utilities
└── codegen.ts # GraphQL Codegen config
```
### Authentication
- OIDC via `oidc-client-ts`
- Environment variables for configuration
- `useAuth` hook for state access
### State Management
- Apollo Client for GraphQL state
- React Context for auth state
## Infrastructure
### Docker
- Multi-stage builds
- Base: `mcr.microsoft.com/dotnet/aspnet:8.0`
- Non-root user for security
- Ports: 8080 (HTTP) or 8081 (HTTPS)
### Health Checks
- All services expose `/healthz`
### Configuration
- `appsettings.json` - Default settings
- `appsettings.Development.json` - Dev overrides
- `appsettings.Local.json` - Local secrets (not committed)
---
# Improvement Recommendations
## Critical
### 1. Event Bus - No Dead Letter Queue or Retry Logic
**Location**: `FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs:126-133`
**Issue**: Events are always ACK'd even on failure. No DLQ configuration for poison messages. Failed events are lost forever.
**Recommendation**: Implement retry with exponential backoff, dead-letter exchange, and poison message handling.
```csharp
// Example: Add retry and DLQ
catch (Exception e)
{
_logger.LogError(e, "Error handling event");
if (retryCount < maxRetries)
{
await channel.BasicNackAsync(@event.DeliveryTag, false, true); // requeue
}
else
{
// Send to DLQ
await channel.BasicNackAsync(@event.DeliveryTag, false, false);
}
}
```
### 2. CORS Configuration is Insecure
**Location**: `FictionArchive.API/Program.cs:24-33`
**Issue**: `AllowAnyOrigin()` allows requests from any domain, unsuitable for production.
**Recommendation**: Configure specific allowed origins via appsettings:
```csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>())
.AllowAnyMethod()
.AllowAnyHeader();
});
});
```
### 3. Auto-Migration on Startup
**Location**: `FictionArchive.Service.Shared/Services/Database/FictionArchiveDbContext.cs:23-38`
**Issue**: Running migrations at startup can cause race conditions with multiple instances and potential data corruption during rolling deployments.
**Recommendation**: Use a migration job, init container, or CLI tool instead of startup code.
## Important
### 4. No Circuit Breaker Pattern
**Issue**: External service calls (DeepL, Novelpia, S3) lack resilience patterns.
**Recommendation**: Add Polly for circuit breaker, retry, and timeout policies:
```csharp
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
```
### 5. Missing Request Validation/Rate Limiting
**Issue**: No visible rate limiting on GraphQL mutations. `ImportNovel` could be abused.
**Recommendation**: Add rate limiting middleware and input validation.
### 6. Hardcoded Exchange Name
**Location**: `RabbitMQEventBus.cs:24`
**Issue**: `fiction-archive-event-bus` is hardcoded.
**Recommendation**: Move to configuration for environment flexibility.
### 7. No Distributed Tracing
**Issue**: Event correlation exists (`X-Event-Id` header) but not integrated with tracing.
**Recommendation**: Add OpenTelemetry for end-to-end request tracing across services.
### 8. Singleton AuditInterceptor
**Location**: `FictionArchiveDbContext.cs:20`
**Issue**: `new AuditInterceptor()` created per DbContext instance.
**Recommendation**: Register as singleton in DI and inject.
## Minor / Code Quality
### 9. Limited Test Coverage
**Issue**: Only `NovelService.Tests` exists. No integration tests for event handlers.
**Recommendation**: Add unit and integration tests for each service, especially event handlers.
### 10. Inconsistent Port Configuration
**Issue**: Some services use 8080 (HTTP), others 8081 (HTTPS).
**Recommendation**: Standardize on HTTPS with proper cert management.
### 11. No API Versioning
**Issue**: GraphQL schemas have no versioning strategy.
**Recommendation**: Consider schema versioning or deprecation annotations for breaking changes.
### 12. Frontend - No Error Boundary
**Issue**: React app lacks error boundaries for graceful failure handling.
**Recommendation**: Add React Error Boundaries around routes.
### 13. Missing Health Check Depth
**Issue**: Health checks only verify service is running, not dependencies.
**Recommendation**: Add database, RabbitMQ, and S3 health checks:
```csharp
builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRabbitMQ()
.AddS3(options => { });
```
### 14. Synchronous File Operations in Event Handlers
**Issue**: File uploads may block event handling thread for large files.
**Recommendation**: Consider async streaming for large files.
## Architectural Suggestions
### 15. Consider Outbox Pattern
**Issue**: Publishing events and saving to DB aren't transactional, could lead to inconsistent state.
**Recommendation**: Implement transactional outbox pattern for guaranteed delivery:
```
1. Save entity + outbox message in same transaction
2. Background worker publishes from outbox
3. Delete outbox message after successful publish
```
### 16. Gateway Schema Build Process
**Issue**: Python script (`build_gateway.py`) for schema composition requires manual execution.
**Recommendation**: Integrate into CI/CD pipeline or consider runtime schema polling.
### 17. Secret Management
**Issue**: Credentials in appsettings files.
**Recommendation**: Use Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, or similar secret management solution.
---
## Key Files Reference
| File | Purpose |
|------|---------|
| `FictionArchive.API/Program.cs` | Gateway setup |
| `FictionArchive.API/build_gateway.py` | Schema composition script |
| `FictionArchive.Service.Shared/Services/EventBus/` | Event bus implementation |
| `FictionArchive.Service.Shared/Extensions/` | Service registration helpers |
| `FictionArchive.Service.Shared/Services/Database/` | DB infrastructure |
| `fictionarchive-web/src/auth/AuthContext.tsx` | Frontend auth state |

220
Documentation/CICD.md Normal file
View File

@@ -0,0 +1,220 @@
# CI/CD Configuration
This document describes the CI/CD pipeline configuration for FictionArchive using Gitea Actions.
## Workflows Overview
| Workflow | File | Trigger | Purpose |
|----------|------|---------|---------|
| CI | `build.yml` | Push/PR to master | Build and test all projects |
| Build Subgraphs | `build-subgraphs.yml` | Push to master (service changes) | Build GraphQL subgraph packages |
| Build Gateway | `build-gateway.yml` | Manual or triggered by subgraphs | Compose gateway and build Docker image |
| Release | `release.yml` | Tag `v*.*.*` | Build and push all Docker images |
## Pipeline Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Push to master │
└─────────────────────────────┬───────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ build.yml │ │ build-subgraphs.yml │
│ (CI checks - always) │ │ (if service changes) │
└─────────────────────────┘ └────────────┬────────────┘
┌─────────────────────────┐
│ build-gateway.yml │
│ (compose & push API) │
└─────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Push tag v*.*.* │
└─────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────┐
│ release.yml │
│ (build & push all) │
└─────────────────────────┘
```
## Required Configuration
### Repository Secrets
Configure these in **Settings → Actions → Secrets**:
| Secret | Description | Required By |
|--------|-------------|-------------|
| `REGISTRY_TOKEN` | Gitea access token with `write:package` scope | `release.yml`, `build-gateway.yml` |
| `GITEA_TOKEN` | Gitea access token for API calls | `build-subgraphs.yml` |
#### Creating Access Tokens
1. Go to **Settings → Applications → Access Tokens**
2. Create a new token with the following scopes:
- `write:package` - Push container images
- `write:repository` - Trigger workflows via API
3. Copy the token and add it as a repository secret
### Repository Variables
Configure these in **Settings → Actions → Variables**:
| Variable | Description | Example | Required By |
|----------|-------------|---------|-------------|
| `VITE_GRAPHQL_URI` | GraphQL API endpoint URL | `https://api.fictionarchive.example.com/graphql/` | `release.yml` |
| `VITE_OIDC_AUTHORITY` | OIDC provider authority URL | `https://auth.example.com/application/o/fiction-archive/` | `release.yml` |
| `VITE_OIDC_CLIENT_ID` | OIDC client identifier | `your-client-id` | `release.yml` |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
| `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` | Post-logout redirect URL | `https://fictionarchive.example.com/` | `release.yml` |
## Workflow Details
### CI (`build.yml`)
**Trigger:** Push or pull request to `master`
**Jobs:**
1. `build-backend` - Builds .NET solution and runs tests
2. `build-frontend` - Builds React application with linting
**Requirements:**
- .NET 8.0 SDK
- Python 3.12
- Node.js 20
- HotChocolate Fusion CLI
### Build Subgraphs (`build-subgraphs.yml`)
**Trigger:** Push to `master` with changes in:
- `FictionArchive.Service.*/**`
- `FictionArchive.Common/**`
- `FictionArchive.Service.Shared/**`
**Jobs:**
1. `build-subgraphs` - Matrix job building each service's `.fsp` package
2. `trigger-gateway` - Triggers gateway rebuild via API
**Subgraphs Built:**
- Novel Service
- Translation Service
- Scheduler Service
- User Service
- File Service
**Artifacts:** Each subgraph produces a `.fsp` file retained for 30 days.
### Build Gateway (`build-gateway.yml`)
**Trigger:**
- Manual dispatch (`workflow_dispatch`)
- Push to `master` with changes in `FictionArchive.API/**`
- Triggered by `build-subgraphs.yml` completion
**Process:**
1. Downloads all subgraph `.fsp` artifacts
2. Configures Docker-internal URLs for each subgraph
3. Composes gateway schema using Fusion CLI
4. Builds and pushes API Docker image
**Image Tags:**
- `<registry>/<owner>/fictionarchive-api:latest`
- `<registry>/<owner>/fictionarchive-api:<commit-sha>`
### Release (`release.yml`)
**Trigger:** Push tag matching `v*.*.*` (e.g., `v1.0.0`)
**Jobs:**
1. `build-and-push` - Matrix job building all backend service images
2. `build-frontend` - Builds and pushes frontend image
**Services Built:**
- `fictionarchive-api`
- `fictionarchive-novel-service`
- `fictionarchive-user-service`
- `fictionarchive-translation-service`
- `fictionarchive-file-service`
- `fictionarchive-scheduler-service`
- `fictionarchive-authentication-service`
- `fictionarchive-frontend`
**Image Tags:**
- `<registry>/<owner>/fictionarchive-<service>:<version>`
- `<registry>/<owner>/fictionarchive-<service>:latest`
## Container Registry
Images are pushed to the Gitea Container Registry at:
```
<gitea-server-url>/<repository-owner>/fictionarchive-<service>:<tag>
```
### Pulling Images
```bash
# Login to registry
docker login <gitea-server-url> -u <username> -p <token>
# Pull an image
docker pull <gitea-server-url>/<owner>/fictionarchive-api:latest
```
## Creating a Release
1. Ensure all changes are committed and pushed to `master`
2. Create and push a version tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The release workflow will automatically build and push all images
4. Monitor progress in **Actions** tab
## Troubleshooting
### Build Failures
**"REGISTRY_TOKEN secret not found"**
- Ensure the `REGISTRY_TOKEN` secret is configured in repository settings
- Verify the token has `write:package` scope
**"Failed to trigger gateway workflow"**
- Ensure `GITEA_TOKEN` secret is configured
- Verify the token has `write:repository` scope
**"No subgraph artifacts found"**
- The gateway build requires subgraph artifacts from a previous `build-subgraphs` run
- Trigger `build-subgraphs.yml` manually or push a change to a service
### Frontend Build Failures
**"VITE_* variables are empty"**
- Ensure all required variables are configured in repository settings
- Variables use `vars.*` context, not `secrets.*`
### Docker Push Failures
**"unauthorized: authentication required"**
- Verify `REGISTRY_TOKEN` has correct permissions
- Check that the token hasn't expired
## Local Testing
To test workflows locally before pushing:
```bash
# Install act (GitHub Actions local runner)
# Note: act has partial Gitea Actions compatibility
# Run CI workflow
act push -W .gitea/workflows/build.yml
# Run with specific event
act push --eventpath .gitea/test-event.json
```

187
Documentation/README.md Normal file
View File

@@ -0,0 +1,187 @@
# FictionArchive
A distributed microservices-based web application for managing fiction and novel content. Features include importing from external sources, multi-language translation, file storage, and user management.
## Architecture
FictionArchive uses a GraphQL Fusion gateway pattern to orchestrate multiple domain services with event-driven communication via RabbitMQ.
More information available in [ARCHITECTURE.md](ARCHITECTURE.md)
## Prerequisites
- .NET SDK 8.0+
- Node.js 20+
- Python 3 (for gateway build script)
- Docker & Docker Compose
- PostgreSQL 16+
- RabbitMQ 3+
**Required CLI Tools**
```bash
# Hot Chocolate Fusion CLI
dotnet tool install -g HotChocolate.Fusion.CommandLine
```
## Getting Started
### Local Development
1. **Clone the repository**
```bash
git clone <repository-url>
cd FictionArchive
```
2. **Start infrastructure** (PostgreSQL, RabbitMQ)
```bash
docker compose up -d postgres rabbitmq
```
3. **Build and run backend**
```bash
dotnet restore
dotnet build FictionArchive.sln
# Start services (in separate terminals or use a process manager)
dotnet run --project FictionArchive.Service.NovelService
dotnet run --project FictionArchive.Service.UserService
dotnet run --project FictionArchive.Service.TranslationService
dotnet run --project FictionArchive.Service.FileService
dotnet run --project FictionArchive.Service.SchedulerService
dotnet run --project FictionArchive.Service.AuthenticationService
# Start the gateway (builds fusion schema automatically)
dotnet run --project FictionArchive.API
```
4. **Build and run frontend**
```bash
cd fictionarchive-web
npm install
npm run codegen # Generate GraphQL types
npm run dev # Start dev server at http://localhost:5173
```
### Docker Deployment
1. **Create environment file**
```bash
cp .env.example .env
# Edit .env with your configuration
```
2. **Start all services**
```bash
docker compose up -d
```
## Configuration
### Environment Variables
Create a `.env` file in the project root:
```bash
# PostgreSQL
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-secure-password
# RabbitMQ
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=your-secure-password
# External Services
NOVELPIA_USERNAME=your-username
NOVELPIA_PASSWORD=your-password
DEEPL_API_KEY=your-api-key
# S3 Storage
S3_ENDPOINT=https://s3.example.com
S3_BUCKET=fictionarchive
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
# OIDC Authentication
OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
OIDC_CLIENT_ID=your-client-id
```
### Frontend Environment
Create `fictionarchive-web/.env.local`:
```bash
VITE_GRAPHQL_URI=http://localhost:5234/graphql/
VITE_OIDC_AUTHORITY=https://auth.example.com/application/o/fiction-archive/
VITE_OIDC_CLIENT_ID=your-client-id
VITE_OIDC_REDIRECT_URI=http://localhost:5173/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
```
## Building the GraphQL Gateway
The API gateway uses Hot Chocolate Fusion to compose schemas from all subgraphs. The gateway schema is rebuilt automatically when building the API project.
**Manual rebuild:**
```bash
cd FictionArchive.API
python build_gateway.py
```
**Skip specific services** by adding them to `FictionArchive.API/gateway_skip.txt`:
```
FictionArchive.Service.NovelService.Tests
```
## CI/CD
The project uses Gitea Actions with the following workflows:
| Workflow | Trigger | Description |
|----------|---------|-------------|
| `build.yml` | Push/PR to master | CI checks - builds and tests |
| `build-subgraphs.yml` | Service changes on master | Builds subgraph `.fsp` packages |
| `build-gateway.yml` | Gateway changes or subgraph builds | Composes gateway and builds Docker image |
| `release.yml` | Tag `v*.*.*` | Builds and pushes all Docker images |
### Release Process
```bash
git tag v1.0.0
git push origin v1.0.0
```
## Project Structure
```
FictionArchive/
├── FictionArchive.sln
├── FictionArchive.Common/ # Shared enums and extensions
├── FictionArchive.Service.Shared/ # Shared infrastructure (EventBus, DB)
├── FictionArchive.API/ # GraphQL Fusion Gateway
├── FictionArchive.Service.NovelService/
├── FictionArchive.Service.UserService/
├── FictionArchive.Service.TranslationService/
├── FictionArchive.Service.FileService/
├── FictionArchive.Service.SchedulerService/
├── FictionArchive.Service.AuthenticationService/
├── FictionArchive.Service.NovelService.Tests/
├── fictionarchive-web/ # React frontend
├── docker-compose.yml
└── .gitea/workflows/ # CI/CD workflows
```
## Testing
```bash
# Run all tests
dotnet test FictionArchive.sln
# Run specific test project
dotnet test FictionArchive.Service.NovelService.Tests
```
## Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) - Detailed architecture documentation
- [AGENTS.md](AGENTS.md) - Development guidelines and coding standards

View File

@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.API.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
/*private readonly FictionArchiveDbContext _dbContext;
private readonly ISourceAdapter _novelpiaAdapter;
private readonly ITranslationEngineAdapter _translationEngine;
public TestController(ISourceAdapter novelpiaAdapter, FictionArchiveDbContext dbContext, ITranslationEngineAdapter translationEngine)
{
_novelpiaAdapter = novelpiaAdapter;
_dbContext = dbContext;
_translationEngine = translationEngine;
}
[HttpGet("GetNovel")]
public async Task<Novel> GetNovel(string novelUrl)
{
var novel = await _novelpiaAdapter.GetMetadata(novelUrl);
novel.Source = new Source()
{
Name = "Novelpia",
Id = 1,
Url = "https://novelpia.com"
};
_dbContext.Novels.Add(novel);
await _dbContext.SaveChangesAsync();
return novel;
}
[HttpGet("GetChapter")]
public async Task<string> GetChapter(uint novelId, uint chapterNumber)
{
var novel = await _dbContext.Novels.Include(n => n.Chapters).ThenInclude(c => c.Translations).FirstOrDefaultAsync(n => n.Id == novelId);
var chapter = novel.Chapters.FirstOrDefault(c => c.Order == chapterNumber);
var rawChapter = await _novelpiaAdapter.GetRawChapter(chapter.Url);
chapter.Translations.Add(new ChapterTranslation()
{
Language = novel.RawLanguage,
Body = rawChapter
});
await _dbContext.SaveChangesAsync();
return rawChapter;
}
[HttpPost("TranslateChapter")]
public async Task<ChapterTranslation> TranslateChapter(uint novelId, uint chapterNumber, Language to)
{
var novel = await _dbContext.Novels.Include(n => n.Chapters)
.ThenInclude(c => c.Translations).FirstOrDefaultAsync(novel => novel.Id == novelId);
var chapter = novel.Chapters.FirstOrDefault(c => c.Order == chapterNumber);
var chapterRaw = chapter.Translations.FirstOrDefault(ct => ct.Language == novel.RawLanguage);
var newTranslation = new ChapterTranslation()
{
Language = to,
TranslationEngine = new TranslationEngine()
{
Name = "DeepL"
}
};
var translation = await _translationEngine.GetTranslation(chapterRaw.Body, novel.RawLanguage, to);
newTranslation.Body = translation;
chapter.Translations.Add(newTranslation);
await _dbContext.SaveChangesAsync();
return newTranslation;
}*/
}
}

View File

@@ -7,15 +7,23 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.API/FictionArchive.API.csproj", "FictionArchive.API/"]
COPY ["FictionArchive.Common/FictionArchive.Common.csproj", "FictionArchive.Common/"]
COPY ["FictionArchive.Service.Shared/FictionArchive.Service.Shared.csproj", "FictionArchive.Service.Shared/"]
RUN dotnet restore "FictionArchive.API/FictionArchive.API.csproj"
COPY . .
COPY FictionArchive.API/ FictionArchive.API/
COPY FictionArchive.Common/ FictionArchive.Common/
COPY FictionArchive.Service.Shared/ FictionArchive.Service.Shared/
WORKDIR "/src/FictionArchive.API"
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Skip fusion build - gateway.fgp should be pre-composed in CI
RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/build -p:SkipFusionBuild=true
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
FROM base AS final
WORKDIR /app

View File

@@ -11,6 +11,7 @@
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
@@ -21,6 +22,11 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
<Target Name="RunFusionBuild" BeforeTargets="BeforeBuild" Condition="'$(SkipFusionBuild)' != 'true'">
<Exec Command="python build_gateway.py $(FusionBuildArgs)" WorkingDirectory="$(ProjectDir)" />
</Target>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
@@ -28,8 +34,11 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
<Folder Include="GraphQL\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,5 @@
using FictionArchive.Service.Shared.Extensions;
namespace FictionArchive.API;
public class Program
@@ -6,39 +8,38 @@ public class Program
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// OpenAPI & REST
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache();
builder.Services.AddHealthChecks();
#region Fusion Gateway
builder.Services.AddHttpClient("Fusion");
builder.Services
.AddFusionGatewayServer()
.ConfigureFromFile("gateway.fgp")
.CoreBuilder.ApplySaneDefaults();
#endregion
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAllOrigins",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapGraphQL();
app.UseCors("AllowAllOrigins");
app.MapHealthChecks("/healthz");
app.MapControllers();
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -23,7 +23,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7063;http://localhost:5234",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Local development script for building the Fusion gateway.
This script is used for local development only. In CI/CD, subgraphs are built
separately and the gateway is composed from pre-built .fsp artifacts.
Usage:
python build_gateway.py
Requirements:
- .NET 8.0 SDK
- HotChocolate Fusion CLI (dotnet tool install -g HotChocolate.Fusion.CommandLine)
"""
import subprocess
import sys
import os
from pathlib import Path
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: gateway_skip.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() and (d / "subgraph-config.json").exists()
]
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("----------------------------------------")

View File

@@ -0,0 +1,5 @@
# List of service folders to skip
FictionArchive.Service.Shared
FictionArchive.Service.AuthenticationService
FictionArchive.Service.FileService
FictionArchive.Service.NovelService.Tests

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Common.Enums;
public enum RequestStatus
{
Failed = -1,
Pending = 0,
Success = 1
}

View File

@@ -9,14 +9,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
<PackageReference Include="NodaTime" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Configuration.Abstractions" />
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.15\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -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> s3Configuration)
{
_amazonS3Client = amazonS3Client;
_s3Configuration = s3Configuration.Value;
}
[HttpGet]
public async Task<IActionResult> 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;
}
}
}
}

View File

@@ -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"]

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.FileService.Models;
public class ProxyConfiguration
{
public string BaseUrl { get; set; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,69 @@
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;
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);
})
.Subscribe<FileUploadRequestCreatedEvent, FileUploadRequestCreatedEventHandler>();
#endregion
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
// Add S3 Client
builder.Services.Configure<S3Configuration>(builder.Configuration.GetSection("S3"));
builder.Services.AddSingleton<AmazonS3Client>(provider =>
{
var config = provider.GetRequiredService<IOptions<S3Configuration>>().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();
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,58 @@
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<FileUploadRequestCreatedEvent>
{
private readonly ILogger<FileUploadRequestCreatedEventHandler> _logger;
private readonly AmazonS3Client _amazonS3Client;
private readonly IEventBus _eventBus;
private readonly S3Configuration _s3Configuration;
private readonly ProxyConfiguration _proxyConfiguration;
public FileUploadRequestCreatedEventHandler(ILogger<FileUploadRequestCreatedEventHandler> logger, AmazonS3Client amazonS3Client, IEventBus eventBus, IOptions<S3Configuration> s3Configuration, IOptions<ProxyConfiguration> 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;
putObjectRequest.UseChunkEncoding = false; // Needed to avoid an error with Garage
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
});
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ProxyConfiguration": {
"BaseUrl": "https://localhost:7247/api"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "FileService"
},
"S3": {
"Url": "https://s3.orfl.xyz",
"Bucket": "fictionarchive",
"AccessKey": "REPLACE_ME",
"SecretKey": "REPLACE_ME"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\FictionArchive.Service.NovelService\\FictionArchive.Service.NovelService.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<NovelServiceDbContext>()
.UseInMemoryDatabase($"NovelUpdateServiceTests-{Guid.NewGuid()}")
.Options;
return new NovelServiceDbContext(options, NullLogger<NovelServiceDbContext>.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<LocalizationText>() },
Images = new List<Image>()
};
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> { chapter },
Tags = new List<NovelTag>()
};
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<NovelUpdateService>.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 = "<p>Hello</p><img src=\"http://img/x1.jpg\" alt=\"first\" /><img src=\"http://img/x2.jpg\" alt=\"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<ISourceAdapter>();
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<ImageData> { image1, image2 }
}));
var publishedEvents = new List<FileUploadRequestCreatedEvent>();
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Do<FileUploadRequestCreatedEvent>(publishedEvents.Add)).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).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 = "<p>Hi</p><img src=\"http://img/x1.jpg\">";
var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } };
var adapter = Substitute.For<ISourceAdapter>();
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<ImageData> { image }
}));
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Any<FileUploadRequestCreatedEvent>()).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).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);
}

View File

@@ -8,6 +8,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -0,0 +1,540 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
b.Property<int>("RawStatus")
.HasColumnType("integer");
b.Property<long>("SourceId")
.HasColumnType("bigint");
b.Property<int?>("StatusOverride")
.HasColumnType("integer");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Sources");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.Property<long>("NovelsId")
.HasColumnType("bigint");
b.Property<long>("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
}
}
}

View File

@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class AddImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CoverImageId",
table: "Novels",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "Images",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalPath = table.Column<string>(type: "text", nullable: false),
NewPath = table.Column<string>(type: "text", nullable: true),
ChapterId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -23,6 +23,35 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<Guid>("Id")
@@ -158,6 +187,9 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("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");

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.NovelService.Models.Configuration;
public class NovelUpdateServiceConfiguration
{
public string PendingImageUrl { get; set; }
}

View File

@@ -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<Guid>
{
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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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<uint>
public LocalizationKey Name { get; set; }
public LocalizationKey Body { get; set; }
// Images appearing in this chapter.
public List<Image> Images { get; set; }
}

View File

@@ -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<uint>
public List<Chapter> Chapters { get; set; }
public List<NovelTag> Tags { get; set; }
public Image? CoverImage { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class ChapterFetchResult
{
public string Text { get; set; }
public List<ImageData> ImageData { get; set; }
}

View File

@@ -6,5 +6,5 @@ public class ChapterMetadata
public uint Order { get; set; }
public string? Url { get; set; }
public string Name { get; set; }
public List<string> ImageUrls { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class ImageData
{
public string Url { get; set; }
public byte[] Data { get; set; }
}

View File

@@ -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; }

View File

@@ -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<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>();
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
#endregion
@@ -56,6 +60,7 @@ public class Program
})
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
builder.Services.Configure<NovelUpdateServiceConfiguration>(builder.Configuration.GetSection("UpdateService"));
builder.Services.AddTransient<NovelUpdateService>();
#endregion
@@ -77,6 +82,6 @@ public class Program
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -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<FileUploadRequestStatusUpdateEvent>
{
private readonly ILogger<FileUploadRequestStatusUpdateEventHandler> _logger;
private readonly NovelServiceDbContext _context;
private readonly NovelUpdateService _novelUpdateService;
public FileUploadRequestStatusUpdateEventHandler(ILogger<FileUploadRequestStatusUpdateEventHandler> 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);
}
}
}

View File

@@ -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<NovelServic
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
public DbSet<Image> Images { get; set; }
}

View File

@@ -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<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
private readonly IEventBus _eventBus;
private readonly NovelUpdateServiceConfiguration _novelUpdateServiceConfiguration;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters)
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters, IEventBus eventBus, IOptions<NovelUpdateServiceConfiguration> novelUpdateServiceConfiguration)
{
_dbContext = dbContext;
_logger = logger;
_sourceAdapters = sourceAdapters;
_eventBus = eventBus;
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
}
public async Task<Novel> 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()
@@ -86,6 +100,17 @@ 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;
}
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();
}
}

View File

@@ -8,5 +8,5 @@ public interface ISourceAdapter
public SourceDescriptor SourceDescriptor { get; }
public Task<bool> CanProcessNovel(string url);
public Task<NovelMetadata> GetMetadata(string novelUrl);
public Task<string> GetRawChapter(string chapterUrl);
public Task<ChapterFetchResult> GetRawChapter(string chapterUrl);
}

View File

@@ -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\s+class=""in-badge"">(.*?)<\/p>");
var badgeMatches = Regex.Matches(badgeSet.Groups[1].Value, @"<span[^>]*>(.*?)<\/span>");
@@ -160,7 +175,7 @@ public class NovelpiaAdapter : ISourceAdapter
return novel;
}
public async Task<string> GetRawChapter(string chapterUrl)
public async Task<ChapterFetchResult> GetRawChapter(string chapterUrl)
{
var chapterId = uint.Parse(Regex.Match(chapterUrl, ChapterIdRegex).Groups[1].Value);
var endpoint = ChapterDownloadEndpoint + chapterId;
@@ -172,6 +187,11 @@ public class NovelpiaAdapter : ISourceAdapter
throw new Exception();
}
var fetchResult = new ChapterFetchResult()
{
ImageData = new List<ImageData>()
};
StringBuilder builder = new StringBuilder();
using var doc = JsonDocument.Parse(responseContent);
JsonElement root = doc.RootElement;
@@ -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, @"<img.+?src=\""(.+?)\"".+?>");
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<byte[]> 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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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"
},

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Novels",
"http": {
"baseAddress": "http://localhost:5101/graphql"
}
}

View File

@@ -69,6 +69,6 @@ public class Program
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Scheduler",
"http": {
"baseAddress": "http://localhost:5213/graphql"
}
}

View File

@@ -10,15 +10,21 @@ public static class GraphQLExtensions
public static IRequestExecutorBuilder AddDefaultGraphQl<TQuery, TMutation>(this IServiceCollection services) where TQuery : class where TMutation : class
{
return services.AddGraphQLServer()
.AddQueryType<TQuery>()
.AddMutationType<TMutation>()
.AddDiagnosticEventListener<ErrorEventListener>()
.AddErrorFilter<LoggingErrorFilter>()
.AddType<UnsignedIntType>()
.AddType<InstantType>()
.AddMutationConventions(applyToAllMutations: true)
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
.AddSorting()
.AddProjections();
.AddQueryType<TQuery>()
.AddMutationType<TMutation>()
.ApplySaneDefaults();
}
public static IRequestExecutorBuilder ApplySaneDefaults(this IRequestExecutorBuilder builder)
{
return builder.AddDiagnosticEventListener<ErrorEventListener>()
.AddErrorFilter<LoggingErrorFilter>()
.AddType<UnsignedIntType>()
.AddType<InstantType>()
.AddMutationConventions(applyToAllMutations: true)
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
.AddSorting()
.AddProjections();
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.NodaTime" Version="15.1.11" />

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.TranslationService.Services.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -12,7 +13,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FictionArchive.Service.TranslationService.Migrations
{
[DbContext(typeof(TranslationServiceDbContext))]
[Migration("20251118052322_Initial")]
[Migration("20251122225458_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -27,11 +28,9 @@ namespace FictionArchive.Service.TranslationService.Migrations
modelBuilder.Entity("FictionArchive.Service.TranslationService.Models.Database.TranslationRequest", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<long>("BilledCharacterCount")
.HasColumnType("bigint");

View File

@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -16,8 +16,7 @@ namespace FictionArchive.Service.TranslationService.Migrations
name: "TranslationRequests",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalText = table.Column<string>(type: "text", nullable: false),
TranslatedText = table.Column<string>(type: "text", nullable: true),
From = table.Column<int>(type: "integer", nullable: false),

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.TranslationService.Services.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -24,11 +25,9 @@ namespace FictionArchive.Service.TranslationService.Migrations
modelBuilder.Entity("FictionArchive.Service.TranslationService.Models.Database.TranslationRequest", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<long>("BilledCharacterCount")
.HasColumnType("bigint");

View File

@@ -73,6 +73,6 @@ public class Program
app.MapGraphQL();
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "Translation",
"http": {
"baseAddress": "http://localhost:5234/graphql"
}
}

View File

@@ -47,6 +47,6 @@ public class Program
app.MapHealthChecks("/healthz");
app.Run();
app.RunWithGraphQLCommands(args);
}
}

View File

@@ -0,0 +1,6 @@
{
"subgraph": "User",
"http": {
"baseAddress": "http://localhost:5145/graphql"
}
}

View File

@@ -16,10 +16,9 @@ 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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{35C67E32-A17F-4EAB-B141-88AFCE11FF9C}"
ProjectSection(SolutionItems) = preProject
compose.yaml = compose.yaml
EndProjectSection
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
@@ -59,5 +58,13 @@ 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
{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

10
README.md Normal file
View File

@@ -0,0 +1,10 @@
# FictionArchive
A distributed microservices-based web application for managing fiction and novel content.
## Documentation
- [README](Documentation/README.md) - Getting started and project overview
- [ARCHITECTURE](Documentation/ARCHITECTURE.md) - System architecture and design
- [CICD](Documentation/CICD.md) - CI/CD pipeline configuration
- [AGENTS](Documentation/AGENTS.md) - Development guidelines and coding standards

177
docker-compose.yml Normal file
View File

@@ -0,0 +1,177 @@
services:
# ===========================================
# Infrastructure
# ===========================================
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
rabbitmq:
image: rabbitmq:3-management-alpine
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# ===========================================
# Backend Services
# ===========================================
novel-service:
build:
context: .
dockerfile: FictionArchive.Service.NovelService/Dockerfile
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Novelpia__Username: ${NOVELPIA_USERNAME}
Novelpia__Password: ${NOVELPIA_PASSWORD}
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
translation-service:
build:
context: .
dockerfile: FictionArchive.Service.TranslationService/Dockerfile
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_TranslationService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
DeepL__ApiKey: ${DEEPL_API_KEY}
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
scheduler-service:
build:
context: .
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
user-service:
build:
context: .
dockerfile: FictionArchive.Service.UserService/Dockerfile
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
authentication-service:
build:
context: .
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
file-service:
build:
context: .
dockerfile: FictionArchive.Service.FileService/Dockerfile
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__Endpoint: ${S3_ENDPOINT:-https://s3.orfl.xyz}
S3__Bucket: ${S3_BUCKET:-fictionarchive}
S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api
labels:
- "traefik.enable=true"
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)"
- "traefik.http.routers.file-service.entrypoints=websecure"
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt"
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
# ===========================================
# API Gateway
# ===========================================
api-gateway:
build:
context: .
dockerfile: FictionArchive.API/Dockerfile
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.api-gateway.entrypoints=websecure"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
depends_on:
- novel-service
- translation-service
- scheduler-service
- user-service
- authentication-service
- file-service
restart: unless-stopped
# ===========================================
# Frontend
# ===========================================
frontend:
build:
context: ./fictionarchive-web
dockerfile: Dockerfile
args:
VITE_GRAPHQL_URI: https://api.fictionarchive.orfl.xyz/graphql/
VITE_OIDC_AUTHORITY: ${OIDC_AUTHORITY:-https://auth.orfl.xyz/application/o/fiction-archive/}
VITE_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
VITE_OIDC_REDIRECT_URI: https://fictionarchive.orfl.xyz/
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: https://fictionarchive.orfl.xyz/
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
letsencrypt:

1
fictionarchive-web/.env Normal file
View File

@@ -0,0 +1 @@
VITE_GRAPHQL_URI=http://localhost:5234/graphql/

27
fictionarchive-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Generated GraphQL artifacts
src/__generated__/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,32 @@
FROM node:20-alpine AS build
WORKDIR /app
# Build arguments for Vite environment variables
ARG VITE_GRAPHQL_URI
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ARG VITE_OIDC_REDIRECT_URI
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI
# Set environment variables for build
ENV VITE_GRAPHQL_URI=$VITE_GRAPHQL_URI
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
ENV VITE_OIDC_REDIRECT_URI=$VITE_OIDC_REDIRECT_URI
ENV VITE_OIDC_POST_LOGOUT_REDIRECT_URI=$VITE_OIDC_POST_LOGOUT_REDIRECT_URI
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,42 @@
# FictionArchive React Starter
A minimal React + Vite + Apollo Client scaffold ready to connect to the FictionArchive Fusion gateway. Point it at your own endpoint by changing `VITE_GRAPHQL_URI`.
## Getting started
```bash
cd fictionarchive-web
npm install
npm run dev
```
Then open the printed local URL. Update `.env` (create it if missing) with your endpoint:
```
VITE_GRAPHQL_URI=https://localhost:5001/graphql
VITE_OIDC_AUTHORITY=https://your-idp
VITE_OIDC_CLIENT_ID=fictionarchive-web
VITE_OIDC_REDIRECT_URI=http://localhost:5173
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173
# Optional: token used only by codegen if your gateway requires auth
VITE_CODEGEN_TOKEN=your_api_token
```
## Scripts
- `npm run dev`: start Vite dev server.
- `npm run build`: type-check + build (runs codegen first via `prebuild`).
- `npm run codegen`: generate typed hooks from `src/**/*.graphql` into `src/__generated__/graphql.ts`.
## Project notes
- `src/apolloClient.ts` configures the Apollo client with `InMemoryCache`, reads `VITE_GRAPHQL_URI`, and attaches an `Authorization: Bearer` header when an OIDC user is present.
- GraphQL code generation is configured via `codegen.ts` (loads `.env`/`.env.local` automatically); run `npm run codegen` to emit typed hooks to `src/__generated__/graphql.ts` (ignored by git) or rely on the `prebuild` hook.
- Routing is handled in `src/App.tsx` with `react-router-dom`; `/` renders the novels listing and `/novels/:id` is stubbed for future detail pages.
- Styles live primarily in `src/index.css` alongside the shared UI components.
## Codegen tips
- Default schema URL: `CODEGEN_SCHEMA_URL` (falls back to `VITE_GRAPHQL_URI`, then `https://localhost:5001/graphql`).
- Add `VITE_CODEGEN_TOKEN` (or `CODEGEN_TOKEN`) if your gateway requires a bearer token during introspection.
- Generated outputs land in `src/__generated__/graphql.ts` (git-ignored). Run `npm run codegen` after schema/operation changes or rely on `npm run build` (runs `prebuild`).

View File

@@ -0,0 +1,40 @@
import type { CodegenConfig } from '@graphql-codegen/cli'
const schema =
process.env.CODEGEN_SCHEMA_URL ??
process.env.VITE_GRAPHQL_URI ??
'https://localhost:5001/graphql'
const authToken = process.env.VITE_CODEGEN_TOKEN ?? process.env.CODEGEN_TOKEN
const headers = authToken
? {
Authorization: `Bearer ${authToken}`,
}
: undefined
const config: CodegenConfig = {
schema: {
[schema]: { headers },
},
documents: 'src/**/*.graphql',
generates: {
'src/__generated__/graphql.ts': {
plugins: [
'typescript',
'typescript-operations',
'typescript-react-apollo',
],
config: {
withHooks: true,
avoidOptionals: true,
dedupeFragments: true,
maybeValue: 'T | null',
skipTypename: true,
apolloReactHooksImportFrom: '@apollo/client/react',
},
},
},
ignoreNoDocuments: true,
}
export default config

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-64.png" sizes="64x64" />
<link rel="apple-touch-icon" href="/favicon-180.png" sizes="180x180" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FictionArchive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Handle SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

8349
fictionarchive-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "fictionarchive-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"prebuild": "npm run codegen",
"codegen": "graphql-codegen --config codegen.ts -r dotenv/config --use-system-ca",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@apollo/client": "^4.0.9",
"@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"graphql": "^16.12.0",
"react-router-dom": "^6.27.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.3",
"@graphql-codegen/typescript": "^4.0.9",
"@graphql-codegen/typescript-operations": "^4.0.9",
"@graphql-codegen/typescript-react-apollo": "^4.0.9",
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"dotenv": "^16.4.5",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,158 @@
.page {
max-width: 960px;
margin: 0 auto;
padding: 2.5rem 1.5rem 3rem;
}
.card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 18px 60px -30px rgba(15, 23, 42, 0.3);
}
.card + .card {
margin-top: 1.25rem;
}
.intro {
text-align: left;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
color: #475569;
margin: 0 0 0.35rem;
}
.lede {
color: #475569;
margin: 0.5rem 0 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: #eef2ff;
color: #312e81;
border: 1px solid #c7d2fe;
font-weight: 600;
}
.muted {
color: #475569;
margin: 0.35rem 0 0;
}
.query-form {
margin: 1rem 0;
display: grid;
gap: 0.35rem;
}
.label {
font-weight: 600;
color: #0f172a;
}
.field-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.field-row input {
flex: 1;
padding: 0.65rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 12px;
font-size: 1rem;
color: #0f172a;
background: #f8fafc;
}
.field-row input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.field-row button {
padding: 0.65rem 0.9rem;
border: 1px solid #312e81;
background: linear-gradient(135deg, #4338ca, #312e81);
color: #fff;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.field-row button:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px -10px rgba(49, 46, 129, 0.6);
}
.field-row button:active {
transform: translateY(0);
box-shadow: none;
}
.country {
margin-top: 1rem;
}
.country__headline {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
}
.country h3 {
margin: 0.2rem 0 0;
}
.emoji {
font-size: 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
dt {
font-size: 0.9rem;
color: #475569;
margin-bottom: 0.2rem;
}
dd {
margin: 0;
font-weight: 600;
color: #0f172a;
}
.error {
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecdd3;
border-radius: 12px;
padding: 0.75rem 1rem;
margin: 0.75rem 0 0;
}

View File

@@ -0,0 +1,19 @@
import { Route, Routes } from 'react-router-dom'
import { AppLayout } from './layouts/AppLayout'
import { NovelsPage } from './pages/NovelsPage'
import { NovelDetailPage } from './pages/NovelDetailPage'
import { NotFoundPage } from './pages/NotFoundPage'
function App() {
return (
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<NovelsPage />} />
<Route path="novels/:id" element={<NovelDetailPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,30 @@
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import {SetContextLink} from '@apollo/client/link/context'
import { userManager } from './auth/oidcClient'
const uri = import.meta.env.VITE_GRAPHQL_URI ?? 'https://localhost:5001/graphql'
const httpLink = new HttpLink({ uri })
const authLink = new SetContextLink(async ({ headers }, _) => {
if (!userManager) return { headers }
try {
const user = await userManager.getUser()
const token = user?.access_token
if (!token) return { headers }
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
},
}
} catch (error) {
console.warn('Failed to load user for auth header', error)
return { headers }
}
})
export const apolloClient = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
})

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { User } from 'oidc-client-ts'
import { isOidcConfigured, userManager } from './oidcClient'
type AuthContextValue = {
user: User | null
isLoading: boolean
isConfigured: boolean
login: () => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const callbackHandledRef = useRef(false)
useEffect(() => {
if (!userManager) {
setIsLoading(false)
return
}
let cancelled = false
userManager
.getUser()
.then((loadedUser) => {
if (!cancelled) setUser(loadedUser ?? null)
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const manager = userManager
if (!manager) return
const handleLoaded = (nextUser: User) => setUser(nextUser)
const handleUnloaded = () => setUser(null)
manager.events.addUserLoaded(handleLoaded)
manager.events.addUserUnloaded(handleUnloaded)
manager.events.addUserSignedOut(handleUnloaded)
return () => {
manager.events.removeUserLoaded(handleLoaded)
manager.events.removeUserUnloaded(handleUnloaded)
manager.events.removeUserSignedOut(handleUnloaded)
}
}, [])
useEffect(() => {
const manager = userManager
if (!manager || callbackHandledRef.current) return
const url = new URL(window.location.href)
const hasAuthParams =
url.searchParams.has('code') ||
url.searchParams.has('id_token') ||
url.searchParams.has('error')
if (!hasAuthParams) return
callbackHandledRef.current = true
manager
.signinRedirectCallback()
.then((nextUser) => {
setUser(nextUser ?? null)
})
.catch((error) => {
console.error('Failed to complete sign-in redirect', error)
})
.finally(() => {
const cleanUrl = `${url.origin}${url.pathname}`
window.history.replaceState({}, document.title, cleanUrl)
})
}, [])
const login = useCallback(async () => {
const manager = userManager
if (!manager) {
console.warn('OIDC is not configured; set VITE_OIDC_* environment variables.')
return
}
await manager.signinRedirect()
}, [])
const logout = useCallback(async () => {
const manager = userManager
if (!manager) {
console.warn('OIDC is not configured; set VITE_OIDC_* environment variables.')
return
}
try {
await manager.signoutRedirect()
} catch (error) {
console.error('Failed to sign out via redirect, clearing local session instead.', error)
await manager.removeUser()
setUser(null)
}
}, [])
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
isConfigured: isOidcConfigured,
login,
logout,
}),
[isLoading, login, logout, user],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,36 @@
import { UserManager, WebStorageStateStore, type UserManagerSettings } from 'oidc-client-ts'
const authority = import.meta.env.VITE_OIDC_AUTHORITY
const clientId = import.meta.env.VITE_OIDC_CLIENT_ID
const redirectUri = import.meta.env.VITE_OIDC_REDIRECT_URI
const postLogoutRedirectUri =
import.meta.env.VITE_OIDC_POST_LOGOUT_REDIRECT_URI ?? redirectUri
const scope = import.meta.env.VITE_OIDC_SCOPE ?? 'openid profile email'
export const isOidcConfigured =
Boolean(authority) && Boolean(clientId) && Boolean(redirectUri)
function buildSettings(): UserManagerSettings | null {
if (!isOidcConfigured) return null
return {
authority: authority!,
client_id: clientId!,
redirect_uri: redirectUri!,
post_logout_redirect_uri: postLogoutRedirectUri,
response_type: 'code',
scope,
loadUserInfo: true,
automaticSilentRenew: true,
userStore:
typeof window !== 'undefined'
? new WebStorageStateStore({ store: window.localStorage })
: undefined,
}
}
export const userManager = (() => {
const settings = buildSettings()
if (!settings) return null
return new UserManager(settings)
})()

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAuth } from '../auth/AuthContext'
import { Button } from './ui/button'
export function AuthenticationDisplay() {
const { user, isConfigured, isLoading, login, logout } = useAuth()
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const email = useMemo(
() =>
user?.profile?.email ??
user?.profile?.preferred_username ??
user?.profile?.name ??
user?.profile?.sub ??
null,
[user],
)
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current) return
if (!menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
if (isLoading) {
return (
<Button variant="outline" size="sm" disabled>
Loading...
</Button>
)
}
if (!isConfigured) {
return (
<Button
variant="secondary"
size="sm"
onClick={() =>
alert('OIDC is not configured. Set VITE_OIDC_* environment variables to enable login.')
}
>
Configure OIDC
</Button>
)
}
if (!user) {
return (
<Button variant="secondary" size="sm" onClick={login}>
Login
</Button>
)
}
return (
<div className="relative" ref={menuRef}>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 text-foreground"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
aria-haspopup="menu"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{(email ?? 'User').slice(0, 1).toUpperCase()}
</span>
<span className="max-w-[12ch] truncate">{email ?? 'User'}</span>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-lg border bg-white p-2 shadow-lg">
<div className="px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed in
</p>
<p className="truncate text-sm font-medium text-foreground">{email ?? 'User'}</p>
</div>
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
Log out
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Link, NavLink } from 'react-router-dom'
import { AuthenticationDisplay } from './AuthenticationDisplay'
import { Button } from './ui/button'
import { Input } from './ui/input'
export function Navbar() {
return (
<nav className="sticky top-0 z-10 border-b bg-white/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center gap-6 px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-3">
<div
className="flex h-10 w-10 select-none items-center justify-center rounded-xl bg-primary text-primary-foreground font-bold shadow-md shadow-primary/20"
translate="no"
aria-label="FictionArchive"
>
FA
</div>
<div>
<p className="text-sm font-semibold text-foreground">FictionArchive</p>
<p className="text-xs text-muted-foreground">GraphQL playground</p>
</div>
</Link>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Button variant="ghost" size="sm" asChild>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? 'text-foreground' : 'text-muted-foreground'
}
>
Novels
</NavLink>
</Button>
</div>
<div className="ml-auto flex items-center gap-3">
<div className="flex items-center gap-2">
<Input
placeholder="Search"
className="w-48 sm:w-64"
aria-label="Search"
/>
</div>
<AuthenticationDisplay />
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,47 @@
import type { Novel } from '../__generated__/graphql'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'
type NovelCardProps = {
novel: Novel
}
function pickText(novelText?: Novel['name'] | Novel['description']) {
const texts = novelText?.texts ?? []
const english = texts.find((t) => t.language === 'EN')
return (english ?? texts[0])?.text ?? 'No description available.'
}
export function NovelCard({ novel }: NovelCardProps) {
const title = pickText(novel.name)
const description = pickText(novel.description)
const cover = novel.coverImage
const coverSrc = cover?.newPath ?? cover?.originalPath
return (
<Card className="overflow-hidden border shadow-sm hover:shadow-md transition-shadow">
{coverSrc ? (
<div className="aspect-[3/4] w-full overflow-hidden bg-muted/50">
<img
src={coverSrc}
alt={title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="aspect-[3/4] w-full bg-muted/50" />
)}
<CardHeader className="space-y-2">
<CardTitle className="line-clamp-2 text-lg leading-tight">
{title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="line-clamp-3 text-sm text-muted-foreground">
{description}
</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,34 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,54 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground shadow-sm',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import { cn } from '../../lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '../../lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,31 @@
query Novels($first: Int, $after: String) {
novels(first: $first, after: $after) {
edges {
cursor
node {
id
url
name {
texts {
language
text
}
}
description {
texts {
language
text
}
}
coverImage {
originalPath
newPath
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}

Some files were not shown because too many files have changed in this diff Show More