Compare commits
82 Commits
9e1792e4d0
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b5d4694f12 | |||
|
|
6d47153a42 | ||
| dbbc2fd8dc | |||
|
|
176c94297b | ||
|
|
8b3faa8f6c | ||
|
|
d87bd81190 | ||
|
|
bee805c441 | ||
| d8e3ec7ec9 | |||
|
|
3612c89b99 | ||
|
|
ebb2e6e7fc | ||
|
|
01d3b94050 | ||
|
|
c0290cc5af | ||
| 1d950b7721 | |||
|
|
7738bcf438 | ||
| 61e0cb69d8 | |||
|
|
02525d611a | ||
| c21fe0fbd5 | |||
|
|
bbc0b5ec7d | ||
| 5527c15ae7 | |||
|
|
1e374e6eeb | ||
| c710f14257 | |||
|
|
6c10077505 | ||
| fecb3e6f43 | |||
|
|
f0ea71e00e | ||
| 45afb57df5 | |||
|
|
6fd76f6787 | ||
| baad092f07 | |||
|
|
4fb34bdef7 | ||
| 89a2cf6db1 | |||
|
|
f830773af5 | ||
| 7185b95c65 | |||
|
|
8b44cf2f0c | ||
| ac48889f4c | |||
|
|
5c52d29da9 | ||
| 16004ad938 | |||
|
|
d109db2155 | ||
| 89dff0980b | |||
|
|
e70c39ea75 | ||
|
|
81e4e88ad4 | ||
| c9d93a4e55 | |||
| 9527d94928 | |||
|
|
c2fdeca6c4 | ||
| aae17021af | |||
|
|
c60aaf2bdb | ||
|
|
b2f4548807 | ||
|
|
8d6f0d6cfd | ||
| bc83bffb4b | |||
|
|
75e96cbee5 | ||
|
|
9c82d648cd | ||
|
|
78612ea29d | ||
| 4412a1f658 | |||
| 12e3c5dfdd | |||
|
|
b71d9031e1 | ||
|
|
09ebdb1b2a | ||
| 43d5ada7fb | |||
|
|
4635ed1b4e | ||
|
|
920fd00910 | ||
|
|
0d9f788678 | ||
|
|
0938c16a76 | ||
|
|
f25cbc1a04 | ||
|
|
078eaf5237 | ||
|
|
b9115d78a9 | ||
|
|
7e94f06853 | ||
|
|
50263109ab | ||
|
|
6ebfe81ae3 | ||
|
|
80aac63f7d | ||
|
|
adc99c7000 | ||
| 87075be61e | |||
| 259dc08aea | |||
| 2203d2ee54 | |||
| 30cc89242d | |||
| 84294455f9 | |||
| be62af98d3 | |||
|
|
15a8185621 | ||
|
|
0180a58084 | ||
|
|
573f3fc7b0 | ||
|
|
cdc2176e35 | ||
|
|
e9eaf1569b | ||
|
|
ba99642e97 | ||
|
|
c6d794aabc | ||
|
|
62e7e20f94 | ||
| aff1396c6a |
@@ -3,18 +3,71 @@ name: Build Gateway
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- master
|
- 'v*.*.*'
|
||||||
paths:
|
|
||||||
- 'FictionArchive.API/**'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ gitea.server_url }}
|
REGISTRY: ${{ gitea.server_url }}
|
||||||
IMAGE_NAME: ${{ gitea.repository_owner }}/fictionarchive-api
|
IMAGE_NAME: ${{ gitea.repository_owner }}/fictionarchive-api
|
||||||
|
|
||||||
jobs:
|
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
|
||||||
|
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: Add .NET tools to PATH
|
||||||
|
run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- 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 -c Release --no-launch-profile \
|
||||||
|
--project ${{ matrix.service.project }}/${{ matrix.service.project }}.csproj \
|
||||||
|
-- schema export --output schema.graphql
|
||||||
|
|
||||||
|
- name: Pack subgraph
|
||||||
|
run: fusion subgraph pack -w ${{ matrix.service.project }}
|
||||||
|
|
||||||
|
- name: Upload subgraph package
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.service.name }}-subgraph
|
||||||
|
path: ${{ matrix.service.project }}/*.fsp
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-subgraphs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -27,44 +80,35 @@ jobs:
|
|||||||
- name: Install Fusion CLI
|
- name: Install Fusion CLI
|
||||||
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
|
run: dotnet tool install -g HotChocolate.Fusion.CommandLine
|
||||||
|
|
||||||
|
- name: Add .NET tools to PATH
|
||||||
|
run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Create subgraphs directory
|
- name: Create subgraphs directory
|
||||||
run: mkdir -p subgraphs
|
run: mkdir -p subgraphs
|
||||||
|
|
||||||
# Download all subgraph packages from latest successful builds
|
|
||||||
- name: Download Novel Service subgraph
|
- name: Download Novel Service subgraph
|
||||||
uses: actions/download-artifact@v4
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: novel-service-subgraph
|
name: novel-service-subgraph
|
||||||
path: subgraphs/novel
|
path: subgraphs/novel
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Download Translation Service subgraph
|
- name: Download Translation Service subgraph
|
||||||
uses: actions/download-artifact@v4
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: translation-service-subgraph
|
name: translation-service-subgraph
|
||||||
path: subgraphs/translation
|
path: subgraphs/translation
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Download Scheduler Service subgraph
|
- name: Download Scheduler Service subgraph
|
||||||
uses: actions/download-artifact@v4
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: scheduler-service-subgraph
|
name: scheduler-service-subgraph
|
||||||
path: subgraphs/scheduler
|
path: subgraphs/scheduler
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Download User Service subgraph
|
- name: Download User Service subgraph
|
||||||
uses: actions/download-artifact@v4
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: user-service-subgraph
|
name: user-service-subgraph
|
||||||
path: subgraphs/user
|
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
|
- name: Configure subgraph URLs for Docker
|
||||||
run: |
|
run: |
|
||||||
@@ -95,13 +139,13 @@ jobs:
|
|||||||
- name: Build gateway
|
- name: Build gateway
|
||||||
run: dotnet build FictionArchive.API/FictionArchive.API.csproj -c Release --no-restore -p:SkipFusionBuild=true
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract registry hostname
|
||||||
|
id: registry
|
||||||
|
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -116,7 +160,7 @@ jobs:
|
|||||||
file: FictionArchive.API/Dockerfile
|
file: FictionArchive.API/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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"}'
|
|
||||||
@@ -20,38 +20,49 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
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
|
- name: Restore dependencies
|
||||||
run: dotnet restore FictionArchive.sln
|
run: dotnet restore FictionArchive.sln
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build FictionArchive.sln --configuration Release --no-restore
|
run: dotnet build FictionArchive.sln --configuration Release --no-restore /p:SkipFusionBuild=true
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: dotnet test FictionArchive.sln --configuration Release --no-build --verbosity normal
|
run: |
|
||||||
|
dotnet test FictionArchive.sln --configuration Release --no-build --verbosity normal \
|
||||||
|
--logger "trx;LogFileName=test-results.trx" \
|
||||||
|
--collect:"XPlat Code Coverage" \
|
||||||
|
--results-directory ./TestResults
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: ./TestResults/**/*.trx
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload coverage results
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage-results
|
||||||
|
path: ./TestResults/**/coverage.cobertura.xml
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: fictionarchive-web
|
working-directory: fictionarchive-web-astro
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
package-manager-cache: false
|
||||||
cache-dependency-path: fictionarchive-web/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -15,8 +15,6 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
service:
|
service:
|
||||||
- name: api
|
|
||||||
dockerfile: FictionArchive.API/Dockerfile
|
|
||||||
- name: novel-service
|
- name: novel-service
|
||||||
dockerfile: FictionArchive.Service.NovelService/Dockerfile
|
dockerfile: FictionArchive.Service.NovelService/Dockerfile
|
||||||
- name: user-service
|
- name: user-service
|
||||||
@@ -40,6 +38,10 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Extract registry hostname
|
||||||
|
id: registry
|
||||||
|
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -54,8 +56,8 @@ jobs:
|
|||||||
file: ${{ matrix.service.dockerfile }}
|
file: ${{ matrix.service.dockerfile }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:${{ steps.version.outputs.VERSION }}
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:${{ steps.version.outputs.VERSION }}
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:latest
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service.name }}:latest
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Extract registry hostname
|
||||||
|
id: registry
|
||||||
|
run: echo "HOST=$(echo '${{ gitea.server_url }}' | sed 's|https\?://||')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -82,17 +88,18 @@ jobs:
|
|||||||
- name: Build and push frontend Docker image
|
- name: Build and push frontend Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./fictionarchive-web
|
context: ./fictionarchive-web-astro
|
||||||
file: fictionarchive-web/Dockerfile
|
file: fictionarchive-web-astro/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
VITE_GRAPHQL_URI=${{ vars.VITE_GRAPHQL_URI }}
|
PUBLIC_GRAPHQL_URI=${{ vars.PUBLIC_GRAPHQL_URI }}
|
||||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
PUBLIC_OIDC_AUTHORITY=${{ vars.PUBLIC_OIDC_AUTHORITY }}
|
||||||
VITE_OIDC_CLIENT_ID=${{ vars.VITE_OIDC_CLIENT_ID }}
|
PUBLIC_OIDC_CLIENT_ID=${{ vars.PUBLIC_OIDC_CLIENT_ID }}
|
||||||
VITE_OIDC_REDIRECT_URI=${{ vars.VITE_OIDC_REDIRECT_URI }}
|
PUBLIC_OIDC_REDIRECT_URI=${{ vars.PUBLIC_OIDC_REDIRECT_URI }}
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.VITE_OIDC_POST_LOGOUT_REDIRECT_URI }}
|
PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=${{ vars.PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI }}
|
||||||
|
PUBLIC_OIDC_SCOPE=${{ vars.PUBLIC_OIDC_SCOPE }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:${{ steps.version.outputs.VERSION }}
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
|
${{ steps.registry.outputs.HOST }}/${{ env.IMAGE_PREFIX }}-frontend:latest
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ This document describes the CI/CD pipeline configuration for FictionArchive usin
|
|||||||
| Workflow | File | Trigger | Purpose |
|
| Workflow | File | Trigger | Purpose |
|
||||||
|----------|------|---------|---------|
|
|----------|------|---------|---------|
|
||||||
| CI | `build.yml` | Push/PR to master | Build and test all projects |
|
| 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` | Tag `v*.*.*` or manual | Build subgraphs, compose gateway, push API image |
|
||||||
| 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 |
|
| Release | `release.yml` | Tag `v*.*.*` | Build and push all Docker images |
|
||||||
|
| Claude PR Assistant | `claude_assistant.yml` | Issue/PR comments with @claude | AI-assisted code review and issue handling |
|
||||||
|
|
||||||
## Pipeline Architecture
|
## Pipeline Architecture
|
||||||
|
|
||||||
@@ -18,27 +18,32 @@ This document describes the CI/CD pipeline configuration for FictionArchive usin
|
|||||||
│ Push to master │
|
│ Push to master │
|
||||||
└─────────────────────────────┬───────────────────────────────────────┘
|
└─────────────────────────────┬───────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌───────────────┴───────────────┐
|
▼
|
||||||
▼ ▼
|
┌─────────────────────────┐
|
||||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
│ build.yml │
|
||||||
│ build.yml │ │ build-subgraphs.yml │
|
│ (CI checks) │
|
||||||
│ (CI checks - always) │ │ (if service changes) │
|
└─────────────────────────┘
|
||||||
└─────────────────────────┘ └────────────┬────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ build-gateway.yml │
|
|
||||||
│ (compose & push API) │
|
|
||||||
└─────────────────────────┘
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ Push tag v*.*.* │
|
│ Push tag v*.*.* │
|
||||||
|
└─────────────────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┴───────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ release.yml │ │ build-gateway.yml │
|
||||||
|
│ (build & push all │ │ (build subgraphs & │
|
||||||
|
│ backend + frontend) │ │ push API gateway) │
|
||||||
|
└─────────────────────────┘ └─────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Issue/PR comment containing @claude │
|
||||||
└─────────────────────────────┬───────────────────────────────────────┘
|
└─────────────────────────────┬───────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ release.yml │
|
│ claude_assistant.yml │
|
||||||
│ (build & push all) │
|
│ (AI code assistance) │
|
||||||
└─────────────────────────┘
|
└─────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,14 +56,15 @@ Configure these in **Settings → Actions → Secrets**:
|
|||||||
| Secret | Description | Required By |
|
| Secret | Description | Required By |
|
||||||
|--------|-------------|-------------|
|
|--------|-------------|-------------|
|
||||||
| `REGISTRY_TOKEN` | Gitea access token with `write:package` scope | `release.yml`, `build-gateway.yml` |
|
| `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` |
|
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code OAuth token | `claude_assistant.yml` |
|
||||||
|
| `CLAUDE_GITEA_TOKEN` | Gitea token for Claude assistant | `claude_assistant.yml` |
|
||||||
|
|
||||||
#### Creating Access Tokens
|
#### Creating Access Tokens
|
||||||
|
|
||||||
1. Go to **Settings → Applications → Access Tokens**
|
1. Go to **Settings → Applications → Access Tokens**
|
||||||
2. Create a new token with the following scopes:
|
2. Create a new token with the following scopes:
|
||||||
- `write:package` - Push container images
|
- `write:package` - Push container images
|
||||||
- `write:repository` - Trigger workflows via API
|
- `write:repository` - For Claude assistant to push commits
|
||||||
3. Copy the token and add it as a repository secret
|
3. Copy the token and add it as a repository secret
|
||||||
|
|
||||||
### Repository Variables
|
### Repository Variables
|
||||||
@@ -85,42 +91,62 @@ Configure these in **Settings → Actions → Variables**:
|
|||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- .NET 8.0 SDK
|
- .NET 8.0 SDK
|
||||||
- Python 3.12
|
|
||||||
- Node.js 20
|
- Node.js 20
|
||||||
- HotChocolate Fusion CLI
|
|
||||||
|
|
||||||
### Build Subgraphs (`build-subgraphs.yml`)
|
**Steps (Backend):**
|
||||||
|
1. Checkout repository
|
||||||
|
2. Setup .NET 8.0
|
||||||
|
3. Restore dependencies
|
||||||
|
4. Build solution (Release, with `SkipFusionBuild=true`)
|
||||||
|
5. Run tests
|
||||||
|
|
||||||
**Trigger:** Push to `master` with changes in:
|
**Steps (Frontend):**
|
||||||
- `FictionArchive.Service.*/**`
|
1. Checkout repository
|
||||||
- `FictionArchive.Common/**`
|
2. Setup Node.js 20
|
||||||
- `FictionArchive.Service.Shared/**`
|
3. Install dependencies (`npm ci`)
|
||||||
|
4. Run linter (`npm run lint`)
|
||||||
**Jobs:**
|
5. Build application (`npm run build`)
|
||||||
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`)
|
### Build Gateway (`build-gateway.yml`)
|
||||||
|
|
||||||
**Trigger:**
|
**Trigger:**
|
||||||
- Manual dispatch (`workflow_dispatch`)
|
- Manual dispatch (`workflow_dispatch`)
|
||||||
- Push to `master` with changes in `FictionArchive.API/**`
|
- Push tag matching `v*.*.*`
|
||||||
- Triggered by `build-subgraphs.yml` completion
|
|
||||||
|
|
||||||
**Process:**
|
**Jobs:**
|
||||||
1. Downloads all subgraph `.fsp` artifacts
|
|
||||||
2. Configures Docker-internal URLs for each subgraph
|
#### 1. `build-subgraphs` (Matrix Job)
|
||||||
3. Composes gateway schema using Fusion CLI
|
Builds GraphQL subgraph packages for each service:
|
||||||
4. Builds and pushes API Docker image
|
|
||||||
|
| Service | Project | Subgraph Name |
|
||||||
|
|---------|---------|---------------|
|
||||||
|
| novel-service | FictionArchive.Service.NovelService | Novel |
|
||||||
|
| translation-service | FictionArchive.Service.TranslationService | Translation |
|
||||||
|
| scheduler-service | FictionArchive.Service.SchedulerService | Scheduler |
|
||||||
|
| user-service | FictionArchive.Service.UserService | User |
|
||||||
|
|
||||||
|
**Note:** File Service and Authentication Service are not subgraphs (no GraphQL schema).
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout repository
|
||||||
|
2. Setup .NET 8.0
|
||||||
|
3. Install HotChocolate Fusion CLI
|
||||||
|
4. Restore and build service project
|
||||||
|
5. Export GraphQL schema (`schema export`)
|
||||||
|
6. Pack subgraph into `.fsp` file
|
||||||
|
7. Upload artifact (retained 30 days)
|
||||||
|
|
||||||
|
#### 2. `build-gateway` (Depends on `build-subgraphs`)
|
||||||
|
Composes the API gateway from subgraph packages.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout repository
|
||||||
|
2. Setup .NET 8.0 and Fusion CLI
|
||||||
|
3. Download all subgraph artifacts
|
||||||
|
4. Configure Docker-internal URLs (`http://{service}-service:8080/graphql`)
|
||||||
|
5. Compose gateway schema using Fusion CLI
|
||||||
|
6. Build gateway project
|
||||||
|
7. Build and push Docker image
|
||||||
|
|
||||||
**Image Tags:**
|
**Image Tags:**
|
||||||
- `<registry>/<owner>/fictionarchive-api:latest`
|
- `<registry>/<owner>/fictionarchive-api:latest`
|
||||||
@@ -131,23 +157,54 @@ Configure these in **Settings → Actions → Variables**:
|
|||||||
**Trigger:** Push tag matching `v*.*.*` (e.g., `v1.0.0`)
|
**Trigger:** Push tag matching `v*.*.*` (e.g., `v1.0.0`)
|
||||||
|
|
||||||
**Jobs:**
|
**Jobs:**
|
||||||
1. `build-and-push` - Matrix job building all backend service images
|
|
||||||
2. `build-frontend` - Builds and pushes frontend image
|
|
||||||
|
|
||||||
**Services Built:**
|
#### 1. `build-and-push` (Matrix Job)
|
||||||
- `fictionarchive-api`
|
Builds and pushes all backend service images:
|
||||||
- `fictionarchive-novel-service`
|
|
||||||
- `fictionarchive-user-service`
|
| Service | Dockerfile |
|
||||||
- `fictionarchive-translation-service`
|
|---------|------------|
|
||||||
- `fictionarchive-file-service`
|
| novel-service | FictionArchive.Service.NovelService/Dockerfile |
|
||||||
- `fictionarchive-scheduler-service`
|
| user-service | FictionArchive.Service.UserService/Dockerfile |
|
||||||
- `fictionarchive-authentication-service`
|
| translation-service | FictionArchive.Service.TranslationService/Dockerfile |
|
||||||
- `fictionarchive-frontend`
|
| file-service | FictionArchive.Service.FileService/Dockerfile |
|
||||||
|
| scheduler-service | FictionArchive.Service.SchedulerService/Dockerfile |
|
||||||
|
| authentication-service | FictionArchive.Service.AuthenticationService/Dockerfile |
|
||||||
|
|
||||||
|
#### 2. `build-frontend`
|
||||||
|
Builds and pushes the frontend image with environment-specific build arguments.
|
||||||
|
|
||||||
|
**Build Args:**
|
||||||
|
- `VITE_GRAPHQL_URI`
|
||||||
|
- `VITE_OIDC_AUTHORITY`
|
||||||
|
- `VITE_OIDC_CLIENT_ID`
|
||||||
|
- `VITE_OIDC_REDIRECT_URI`
|
||||||
|
- `VITE_OIDC_POST_LOGOUT_REDIRECT_URI`
|
||||||
|
|
||||||
**Image Tags:**
|
**Image Tags:**
|
||||||
- `<registry>/<owner>/fictionarchive-<service>:<version>`
|
- `<registry>/<owner>/fictionarchive-<service>:<version>`
|
||||||
- `<registry>/<owner>/fictionarchive-<service>:latest`
|
- `<registry>/<owner>/fictionarchive-<service>:latest`
|
||||||
|
|
||||||
|
### Claude PR Assistant (`claude_assistant.yml`)
|
||||||
|
|
||||||
|
**Trigger:** Comments or issues containing `@claude`:
|
||||||
|
- Issue comments
|
||||||
|
- Pull request review comments
|
||||||
|
- Pull request reviews
|
||||||
|
- New issues (opened or assigned)
|
||||||
|
|
||||||
|
**Permissions Required:**
|
||||||
|
- `contents: write`
|
||||||
|
- `pull-requests: write`
|
||||||
|
- `issues: write`
|
||||||
|
- `id-token: write`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
Mention `@claude` in any issue or PR comment to invoke the AI assistant for:
|
||||||
|
- Code review assistance
|
||||||
|
- Bug analysis
|
||||||
|
- Implementation suggestions
|
||||||
|
- Documentation help
|
||||||
|
|
||||||
## Container Registry
|
## Container Registry
|
||||||
|
|
||||||
Images are pushed to the Gitea Container Registry at:
|
Images are pushed to the Gitea Container Registry at:
|
||||||
@@ -155,6 +212,19 @@ Images are pushed to the Gitea Container Registry at:
|
|||||||
<gitea-server-url>/<repository-owner>/fictionarchive-<service>:<tag>
|
<gitea-server-url>/<repository-owner>/fictionarchive-<service>:<tag>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Image Naming Convention
|
||||||
|
|
||||||
|
| Image | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `fictionarchive-api` | API Gateway (GraphQL Federation) |
|
||||||
|
| `fictionarchive-novel-service` | Novel Service |
|
||||||
|
| `fictionarchive-user-service` | User Service |
|
||||||
|
| `fictionarchive-translation-service` | Translation Service |
|
||||||
|
| `fictionarchive-file-service` | File Service |
|
||||||
|
| `fictionarchive-scheduler-service` | Scheduler Service |
|
||||||
|
| `fictionarchive-authentication-service` | Authentication Service |
|
||||||
|
| `fictionarchive-frontend` | Web Frontend |
|
||||||
|
|
||||||
### Pulling Images
|
### Pulling Images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -184,13 +254,13 @@ docker pull <gitea-server-url>/<owner>/fictionarchive-api:latest
|
|||||||
- Ensure the `REGISTRY_TOKEN` secret is configured in repository settings
|
- Ensure the `REGISTRY_TOKEN` secret is configured in repository settings
|
||||||
- Verify the token has `write:package` scope
|
- 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"**
|
**"No subgraph artifacts found"**
|
||||||
- The gateway build requires subgraph artifacts from a previous `build-subgraphs` run
|
- The gateway build requires subgraph artifacts from the `build-subgraphs` job
|
||||||
- Trigger `build-subgraphs.yml` manually or push a change to a service
|
- If subgraph builds failed, check the matrix job logs for errors
|
||||||
|
|
||||||
|
**"Schema export failed"**
|
||||||
|
- Ensure the service project has a valid `subgraph-config.json`
|
||||||
|
- Check that the service starts correctly for schema export
|
||||||
|
|
||||||
### Frontend Build Failures
|
### Frontend Build Failures
|
||||||
|
|
||||||
@@ -204,6 +274,13 @@ docker pull <gitea-server-url>/<owner>/fictionarchive-api:latest
|
|||||||
- Verify `REGISTRY_TOKEN` has correct permissions
|
- Verify `REGISTRY_TOKEN` has correct permissions
|
||||||
- Check that the token hasn't expired
|
- Check that the token hasn't expired
|
||||||
|
|
||||||
|
### Claude Assistant Failures
|
||||||
|
|
||||||
|
**"Claude assistant not responding"**
|
||||||
|
- Verify `CLAUDE_CODE_OAUTH_TOKEN` is configured
|
||||||
|
- Verify `CLAUDE_GITEA_TOKEN` is configured and has write permissions
|
||||||
|
- Check that the comment contains `@claude` mention
|
||||||
|
|
||||||
## Local Testing
|
## Local Testing
|
||||||
|
|
||||||
To test workflows locally before pushing:
|
To test workflows locally before pushing:
|
||||||
|
|||||||
9977
Documentation/sample_novel_page.html
Normal file
9977
Documentation/sample_novel_page.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ RUN dotnet build "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/b
|
|||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
|
RUN dotnet publish "./FictionArchive.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false /p:SkipFusionBuild=true
|
||||||
|
# Copy pre-composed gateway.fgp from CI build
|
||||||
|
COPY FictionArchive.API/gateway.fgp /app/publish/
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Fusion" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.HeaderPropagation" Version="8.0.22" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
|
<!-- Builds the Fusion graph file before building the application itself (skipped in CI) -->
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ public class Program
|
|||||||
|
|
||||||
#region Fusion Gateway
|
#region Fusion Gateway
|
||||||
|
|
||||||
builder.Services.AddHttpClient("Fusion");
|
// Register header propagation service to forward Authorization header to subgraphs
|
||||||
|
builder.Services.AddHttpClient("Fusion")
|
||||||
|
.AddHeaderPropagation();
|
||||||
|
builder.Services.AddHeaderPropagation(opt =>
|
||||||
|
{
|
||||||
|
opt.Headers.Add("Authorization");
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddFusionGatewayServer()
|
.AddFusionGatewayServer()
|
||||||
@@ -21,23 +27,27 @@ public class Program
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:4321";
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowAllOrigins",
|
options.AddPolicy("AllowFictionArchiveOrigins",
|
||||||
builder =>
|
policyBuilder =>
|
||||||
{
|
{
|
||||||
builder.AllowAnyOrigin()
|
policyBuilder.WithOrigins(allowedOrigin)
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader()
|
||||||
|
.AllowCredentials();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors("AllowAllOrigins");
|
app.UseCors("AllowFictionArchiveOrigins");
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseHeaderPropagation();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -5,5 +5,18 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigin": "http://localhost:4321"
|
||||||
|
},
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" 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" />
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Web;
|
|||||||
using Amazon.S3;
|
using Amazon.S3;
|
||||||
using Amazon.S3.Model;
|
using Amazon.S3.Model;
|
||||||
using FictionArchive.Service.FileService.Models;
|
using FictionArchive.Service.FileService.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -10,6 +11,7 @@ namespace FictionArchive.Service.FileService.Controllers
|
|||||||
{
|
{
|
||||||
[Route("api/{*path}")]
|
[Route("api/{*path}")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
public class S3ProxyController : ControllerBase
|
public class S3ProxyController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AmazonS3Client _amazonS3Client;
|
private readonly AmazonS3Client _amazonS3Client;
|
||||||
@@ -33,8 +35,16 @@ namespace FictionArchive.Service.FileService.Controllers
|
|||||||
BucketName = _s3Configuration.Bucket,
|
BucketName = _s3Configuration.Bucket,
|
||||||
Key = decodedPath
|
Key = decodedPath
|
||||||
});
|
});
|
||||||
|
|
||||||
return new FileStreamResult(s3Response.ResponseStream, s3Response.Headers.ContentType);
|
Response.Headers.CacheControl = "public, max-age=604800"; // 7 days
|
||||||
|
Response.Headers.LastModified = s3Response.LastModified?.ToString("R");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(s3Response.ETag))
|
||||||
|
{
|
||||||
|
Response.Headers.ETag = s3Response.ETag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(s3Response.ResponseStream, s3Response.Headers.ContentType);
|
||||||
}
|
}
|
||||||
catch (AmazonS3Exception e)
|
catch (AmazonS3Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ EXPOSE 8081
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj", "FictionArchive.Service.ImageService/"]
|
COPY ["FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj", "FictionArchive.Service.FileService/"]
|
||||||
RUN dotnet restore "FictionArchive.Service.ImageService/FictionArchive.Service.ImageService.csproj"
|
RUN dotnet restore "FictionArchive.Service.FileService/FictionArchive.Service.FileService.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/FictionArchive.Service.ImageService"
|
WORKDIR "/src/FictionArchive.Service.FileService"
|
||||||
RUN dotnet build "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "./FictionArchive.Service.FileService.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "./FictionArchive.Service.ImageService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "./FictionArchive.Service.FileService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "FictionArchive.Service.ImageService.dll"]
|
ENTRYPOINT ["dotnet", "FictionArchive.Service.FileService.dll"]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
|
<PackageReference Include="AWSSDK.S3" Version="4.0.13.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class Program
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
// Add authentication with cookie support
|
||||||
|
builder.Services.AddOidcCookieAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
|
builder.Services.Configure<ProxyConfiguration>(builder.Configuration.GetSection("ProxyConfiguration"));
|
||||||
|
|
||||||
// Add S3 Client
|
// Add S3 Client
|
||||||
@@ -60,6 +64,9 @@ public class Program
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
@@ -18,5 +18,15 @@
|
|||||||
"AccessKey": "REPLACE_ME",
|
"AccessKey": "REPLACE_ME",
|
||||||
"SecretKey": "REPLACE_ME"
|
"SecretKey": "REPLACE_ME"
|
||||||
},
|
},
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class NovelUpdateServiceTests
|
|||||||
Images = new List<Image>()
|
Images = new List<Image>()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var volume = new Volume
|
||||||
|
{
|
||||||
|
Order = 1,
|
||||||
|
Name = LocalizationKey.CreateFromText("Main Story", Language.En),
|
||||||
|
Chapters = new List<Chapter> { chapter }
|
||||||
|
};
|
||||||
|
|
||||||
var novel = new Novel
|
var novel = new Novel
|
||||||
{
|
{
|
||||||
Url = "http://demo/novel",
|
Url = "http://demo/novel",
|
||||||
@@ -52,14 +59,14 @@ public class NovelUpdateServiceTests
|
|||||||
Source = source,
|
Source = source,
|
||||||
Name = LocalizationKey.CreateFromText("Demo Novel", Language.En),
|
Name = LocalizationKey.CreateFromText("Demo Novel", Language.En),
|
||||||
Description = LocalizationKey.CreateFromText("Description", Language.En),
|
Description = LocalizationKey.CreateFromText("Description", Language.En),
|
||||||
Chapters = new List<Chapter> { chapter },
|
Volumes = new List<Volume> { volume },
|
||||||
Tags = new List<NovelTag>()
|
Tags = new List<NovelTag>()
|
||||||
};
|
};
|
||||||
|
|
||||||
dbContext.Novels.Add(novel);
|
dbContext.Novels.Add(novel);
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
|
|
||||||
return new NovelCreateResult(novel, chapter);
|
return new NovelCreateResult(novel, volume, chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NovelUpdateService CreateService(
|
private static NovelUpdateService CreateService(
|
||||||
@@ -81,7 +88,7 @@ public class NovelUpdateServiceTests
|
|||||||
{
|
{
|
||||||
using var dbContext = CreateDbContext();
|
using var dbContext = CreateDbContext();
|
||||||
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
||||||
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
|
var (novel, volume, 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 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 image1 = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 1, 2, 3 } };
|
||||||
@@ -103,7 +110,7 @@ public class NovelUpdateServiceTests
|
|||||||
var pendingImageUrl = "https://pending/placeholder.jpg";
|
var pendingImageUrl = "https://pending/placeholder.jpg";
|
||||||
var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl);
|
var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl);
|
||||||
|
|
||||||
var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order);
|
var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order);
|
||||||
|
|
||||||
updatedChapter.Images.Should().HaveCount(2);
|
updatedChapter.Images.Should().HaveCount(2);
|
||||||
updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url });
|
updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url });
|
||||||
@@ -131,7 +138,7 @@ public class NovelUpdateServiceTests
|
|||||||
{
|
{
|
||||||
using var dbContext = CreateDbContext();
|
using var dbContext = CreateDbContext();
|
||||||
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
||||||
var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source);
|
var (novel, volume, chapter) = CreateNovelWithSingleChapter(dbContext, source);
|
||||||
|
|
||||||
var rawHtml = "<p>Hi</p><img src=\"http://img/x1.jpg\">";
|
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 image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } };
|
||||||
@@ -150,7 +157,7 @@ public class NovelUpdateServiceTests
|
|||||||
|
|
||||||
var service = CreateService(dbContext, adapter, eventBus);
|
var service = CreateService(dbContext, adapter, eventBus);
|
||||||
|
|
||||||
var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order);
|
var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order);
|
||||||
|
|
||||||
var storedHtml = updatedChapter.Body.Texts.Single().Text;
|
var storedHtml = updatedChapter.Body.Texts.Single().Text;
|
||||||
var doc = new HtmlDocument();
|
var doc = new HtmlDocument();
|
||||||
@@ -161,5 +168,139 @@ public class NovelUpdateServiceTests
|
|||||||
imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg");
|
imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
private record NovelCreateResult(Novel Novel, Chapter Chapter);
|
private record NovelCreateResult(Novel Novel, Volume Volume, Chapter Chapter);
|
||||||
|
|
||||||
|
#region UpdateImage Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateImage_sets_NewPath_on_image_without_chapter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var image = new Image
|
||||||
|
{
|
||||||
|
OriginalPath = "http://original/cover.jpg",
|
||||||
|
NewPath = null
|
||||||
|
};
|
||||||
|
dbContext.Images.Add(image);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var adapter = Substitute.For<ISourceAdapter>();
|
||||||
|
var eventBus = Substitute.For<IEventBus>();
|
||||||
|
var service = CreateService(dbContext, adapter, eventBus);
|
||||||
|
|
||||||
|
var newUrl = "https://cdn.example.com/uploaded/cover.jpg";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.UpdateImage(image.Id, newUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedImage = await dbContext.Images.FindAsync(image.Id);
|
||||||
|
updatedImage!.NewPath.Should().Be(newUrl);
|
||||||
|
updatedImage.OriginalPath.Should().Be("http://original/cover.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateImage_updates_chapter_body_html_with_new_url()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
||||||
|
var (novel, _, chapter) = CreateNovelWithSingleChapter(dbContext, source);
|
||||||
|
|
||||||
|
var image = new Image
|
||||||
|
{
|
||||||
|
OriginalPath = "http://original/image.jpg",
|
||||||
|
NewPath = null,
|
||||||
|
Chapter = chapter
|
||||||
|
};
|
||||||
|
chapter.Images.Add(image);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Set up the chapter body with an img tag referencing the image by ID (as PullChapterContents does)
|
||||||
|
var pendingUrl = "https://pending/placeholder.jpg";
|
||||||
|
var bodyHtml = $"<p>Content</p><img src=\"{pendingUrl}\" alt=\"{image.Id}\" />";
|
||||||
|
chapter.Body.Texts.Add(new LocalizationText
|
||||||
|
{
|
||||||
|
Language = Language.En,
|
||||||
|
Text = bodyHtml
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var adapter = Substitute.For<ISourceAdapter>();
|
||||||
|
var eventBus = Substitute.For<IEventBus>();
|
||||||
|
var service = CreateService(dbContext, adapter, eventBus, pendingUrl);
|
||||||
|
|
||||||
|
var newUrl = "https://cdn.example.com/uploaded/image.jpg";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await service.UpdateImage(image.Id, newUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedImage = await dbContext.Images
|
||||||
|
.Include(i => i.Chapter)
|
||||||
|
.ThenInclude(c => c.Body)
|
||||||
|
.ThenInclude(b => b.Texts)
|
||||||
|
.FirstAsync(i => i.Id == image.Id);
|
||||||
|
|
||||||
|
updatedImage.NewPath.Should().Be(newUrl);
|
||||||
|
|
||||||
|
var updatedBodyText = updatedImage.Chapter!.Body.Texts.Single().Text;
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(updatedBodyText);
|
||||||
|
var imgNode = doc.DocumentNode.SelectSingleNode("//img");
|
||||||
|
imgNode.Should().NotBeNull();
|
||||||
|
imgNode!.GetAttributeValue("src", string.Empty).Should().Be(newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateImage_does_not_modify_other_images_in_chapter_body()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" };
|
||||||
|
var (_, _, chapter) = CreateNovelWithSingleChapter(dbContext, source);
|
||||||
|
|
||||||
|
var image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter };
|
||||||
|
var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter };
|
||||||
|
chapter.Images.Add(image1);
|
||||||
|
chapter.Images.Add(image2);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var pendingUrl = "https://pending/placeholder.jpg";
|
||||||
|
var bodyHtml = $"<p>Content</p><img src=\"{pendingUrl}\" alt=\"{image1.Id}\" /><img src=\"{pendingUrl}\" alt=\"{image2.Id}\" />";
|
||||||
|
chapter.Body.Texts.Add(new LocalizationText
|
||||||
|
{
|
||||||
|
Language = Language.En,
|
||||||
|
Text = bodyHtml
|
||||||
|
});
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var adapter = Substitute.For<ISourceAdapter>();
|
||||||
|
var eventBus = Substitute.For<IEventBus>();
|
||||||
|
var service = CreateService(dbContext, adapter, eventBus, pendingUrl);
|
||||||
|
|
||||||
|
var newUrl = "https://cdn.example.com/uploaded/img1.jpg";
|
||||||
|
|
||||||
|
// Act - only update image1
|
||||||
|
await service.UpdateImage(image1.Id, newUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var updatedChapter = await dbContext.Chapters
|
||||||
|
.Include(c => c.Body)
|
||||||
|
.ThenInclude(b => b.Texts)
|
||||||
|
.FirstAsync(c => c.Id == chapter.Id);
|
||||||
|
|
||||||
|
var updatedBodyText = updatedChapter.Body.Texts.Single().Text;
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(updatedBodyText);
|
||||||
|
|
||||||
|
var img1Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image1.Id}']");
|
||||||
|
var img2Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image2.Id}']");
|
||||||
|
|
||||||
|
img1Node!.GetAttributeValue("src", string.Empty).Should().Be(newUrl);
|
||||||
|
img2Node!.GetAttributeValue("src", string.Empty).Should().Be(pendingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,35 @@ using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
|||||||
using FictionArchive.Service.NovelService.Services;
|
using FictionArchive.Service.NovelService.Services;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus;
|
using FictionArchive.Service.Shared.Services.EventBus;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
using HotChocolate.Types;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace FictionArchive.Service.NovelService.GraphQL;
|
namespace FictionArchive.Service.NovelService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, IEventBus eventBus)
|
[Authorize]
|
||||||
|
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, NovelUpdateService service)
|
||||||
{
|
{
|
||||||
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
|
return await service.QueueNovelImport(novelUrl);
|
||||||
{
|
|
||||||
NovelUrl = novelUrl
|
|
||||||
};
|
|
||||||
await eventBus.Publish(importNovelRequestEvent);
|
|
||||||
return importNovelRequestEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
|
[Authorize]
|
||||||
uint chapterNumber,
|
public async Task<ChapterPullRequestedEvent> FetchChapterContents(
|
||||||
IEventBus eventBus)
|
uint novelId,
|
||||||
|
uint volumeId,
|
||||||
|
uint chapterOrder,
|
||||||
|
NovelUpdateService service)
|
||||||
{
|
{
|
||||||
var chapterPullEvent = new ChapterPullRequestedEvent()
|
return await service.QueueChapterPull(novelId, volumeId, chapterOrder);
|
||||||
{
|
}
|
||||||
NovelId = novelId,
|
|
||||||
ChapterNumber = chapterNumber
|
[Error<KeyNotFoundException>]
|
||||||
};
|
[Authorize]
|
||||||
await eventBus.Publish(chapterPullEvent);
|
public async Task<bool> DeleteNovel(uint novelId, NovelUpdateService service)
|
||||||
return chapterPullEvent;
|
{
|
||||||
|
await service.DeleteNovel(novelId);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using FictionArchive.Service.NovelService.Models.Novels;
|
using FictionArchive.Common.Enums;
|
||||||
|
using FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
using FictionArchive.Service.NovelService.Services;
|
using FictionArchive.Service.NovelService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using HotChocolate.Data;
|
using HotChocolate.Data;
|
||||||
using HotChocolate.Types;
|
using HotChocolate.Types;
|
||||||
|
|
||||||
@@ -7,12 +9,255 @@ namespace FictionArchive.Service.NovelService.GraphQL;
|
|||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[UsePaging]
|
[UsePaging]
|
||||||
[UseProjection]
|
[UseProjection]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
[UseSorting]
|
[UseSorting]
|
||||||
public IQueryable<Novel> GetNovels(NovelServiceDbContext dbContext)
|
public IQueryable<NovelDto> GetNovels(
|
||||||
|
NovelServiceDbContext dbContext,
|
||||||
|
Language preferredLanguage = Language.En)
|
||||||
{
|
{
|
||||||
return dbContext.Novels.AsQueryable();
|
return dbContext.Novels.Select(novel => new NovelDto
|
||||||
|
{
|
||||||
|
Id = novel.Id,
|
||||||
|
CreatedTime = novel.CreatedTime,
|
||||||
|
LastUpdatedTime = novel.LastUpdatedTime,
|
||||||
|
Url = novel.Url,
|
||||||
|
RawLanguage = novel.RawLanguage,
|
||||||
|
RawStatus = novel.RawStatus,
|
||||||
|
StatusOverride = novel.StatusOverride,
|
||||||
|
ExternalId = novel.ExternalId,
|
||||||
|
|
||||||
|
Name = novel.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
|
||||||
|
Description = novel.Description.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? novel.Description.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
|
||||||
|
Author = new PersonDto
|
||||||
|
{
|
||||||
|
Id = novel.Author.Id,
|
||||||
|
CreatedTime = novel.Author.CreatedTime,
|
||||||
|
LastUpdatedTime = novel.Author.LastUpdatedTime,
|
||||||
|
Name = novel.Author.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? novel.Author.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
ExternalUrl = novel.Author.ExternalUrl
|
||||||
|
},
|
||||||
|
|
||||||
|
Source = new SourceDto
|
||||||
|
{
|
||||||
|
Id = novel.Source.Id,
|
||||||
|
CreatedTime = novel.Source.CreatedTime,
|
||||||
|
LastUpdatedTime = novel.Source.LastUpdatedTime,
|
||||||
|
Name = novel.Source.Name,
|
||||||
|
Key = novel.Source.Key,
|
||||||
|
Url = novel.Source.Url
|
||||||
|
},
|
||||||
|
|
||||||
|
CoverImage = novel.CoverImage != null
|
||||||
|
? new ImageDto
|
||||||
|
{
|
||||||
|
Id = novel.CoverImage.Id,
|
||||||
|
CreatedTime = novel.CoverImage.CreatedTime,
|
||||||
|
LastUpdatedTime = novel.CoverImage.LastUpdatedTime,
|
||||||
|
NewPath = novel.CoverImage.NewPath
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
|
Volumes = novel.Volumes.OrderBy(v => v.Order).Select(volume => new VolumeDto
|
||||||
|
{
|
||||||
|
Id = volume.Id,
|
||||||
|
CreatedTime = volume.CreatedTime,
|
||||||
|
LastUpdatedTime = volume.LastUpdatedTime,
|
||||||
|
Order = volume.Order,
|
||||||
|
Name = volume.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? volume.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
Chapters = volume.Chapters.OrderBy(c => c.Order).Select(chapter => new ChapterDto
|
||||||
|
{
|
||||||
|
Id = chapter.Id,
|
||||||
|
CreatedTime = chapter.CreatedTime,
|
||||||
|
LastUpdatedTime = chapter.LastUpdatedTime,
|
||||||
|
Revision = chapter.Revision,
|
||||||
|
Order = chapter.Order,
|
||||||
|
Url = chapter.Url,
|
||||||
|
Name = chapter.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
Body = chapter.Body.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
Images = chapter.Images.Select(image => new ImageDto
|
||||||
|
{
|
||||||
|
Id = image.Id,
|
||||||
|
CreatedTime = image.CreatedTime,
|
||||||
|
LastUpdatedTime = image.LastUpdatedTime,
|
||||||
|
NewPath = image.NewPath
|
||||||
|
}).ToList()
|
||||||
|
}).ToList()
|
||||||
|
}).ToList(),
|
||||||
|
|
||||||
|
Tags = novel.Tags.Select(tag => new NovelTagDto
|
||||||
|
{
|
||||||
|
Id = tag.Id,
|
||||||
|
CreatedTime = tag.CreatedTime,
|
||||||
|
LastUpdatedTime = tag.LastUpdatedTime,
|
||||||
|
Key = tag.Key,
|
||||||
|
TagType = tag.TagType,
|
||||||
|
DisplayName = tag.DisplayName.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? tag.DisplayName.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
Source = tag.Source != null
|
||||||
|
? new SourceDto
|
||||||
|
{
|
||||||
|
Id = tag.Source.Id,
|
||||||
|
CreatedTime = tag.Source.CreatedTime,
|
||||||
|
LastUpdatedTime = tag.Source.LastUpdatedTime,
|
||||||
|
Name = tag.Source.Name,
|
||||||
|
Key = tag.Source.Key,
|
||||||
|
Url = tag.Source.Url
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}).ToList()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
[Authorize]
|
||||||
|
[UseFirstOrDefault]
|
||||||
|
[UseProjection]
|
||||||
|
public IQueryable<ChapterReaderDto> GetChapter(
|
||||||
|
NovelServiceDbContext dbContext,
|
||||||
|
uint novelId,
|
||||||
|
uint volumeOrder,
|
||||||
|
uint chapterOrder,
|
||||||
|
Language preferredLanguage = Language.En)
|
||||||
|
{
|
||||||
|
return dbContext.Chapters
|
||||||
|
.Where(c => c.Volume.Novel.Id == novelId && c.Volume.Order == volumeOrder && c.Order == chapterOrder)
|
||||||
|
.Select(chapter => new ChapterReaderDto
|
||||||
|
{
|
||||||
|
Id = chapter.Id,
|
||||||
|
CreatedTime = chapter.CreatedTime,
|
||||||
|
LastUpdatedTime = chapter.LastUpdatedTime,
|
||||||
|
Revision = chapter.Revision,
|
||||||
|
Order = chapter.Order,
|
||||||
|
Url = chapter.Url,
|
||||||
|
|
||||||
|
Name = chapter.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
|
||||||
|
Body = chapter.Body.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
|
||||||
|
Images = chapter.Images.Select(image => new ImageDto
|
||||||
|
{
|
||||||
|
Id = image.Id,
|
||||||
|
CreatedTime = image.CreatedTime,
|
||||||
|
LastUpdatedTime = image.LastUpdatedTime,
|
||||||
|
NewPath = image.NewPath
|
||||||
|
}).ToList(),
|
||||||
|
|
||||||
|
NovelId = chapter.Volume.Novel.Id,
|
||||||
|
NovelName = chapter.Volume.Novel.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
|
||||||
|
// Volume context
|
||||||
|
VolumeId = chapter.Volume.Id,
|
||||||
|
VolumeName = chapter.Volume.Name.Texts
|
||||||
|
.Where(t => t.Language == preferredLanguage)
|
||||||
|
.Select(t => t.Text)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Name.Texts.Select(t => t.Text).FirstOrDefault()
|
||||||
|
?? "",
|
||||||
|
VolumeOrder = chapter.Volume.Order,
|
||||||
|
TotalChaptersInVolume = chapter.Volume.Chapters.Count,
|
||||||
|
|
||||||
|
// Previous chapter: first try same volume, then last chapter of previous volume
|
||||||
|
PrevChapterVolumeOrder = chapter.Volume.Chapters
|
||||||
|
.Where(c => c.Order < chapterOrder)
|
||||||
|
.OrderByDescending(c => c.Order)
|
||||||
|
.Select(c => (int?)chapter.Volume.Order)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Novel.Volumes
|
||||||
|
.Where(v => v.Order < chapter.Volume.Order)
|
||||||
|
.OrderByDescending(v => v.Order)
|
||||||
|
.SelectMany(v => v.Chapters.OrderByDescending(c => c.Order).Take(1))
|
||||||
|
.Select(c => (int?)c.Volume.Order)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
|
||||||
|
PrevChapterOrder = chapter.Volume.Chapters
|
||||||
|
.Where(c => c.Order < chapterOrder)
|
||||||
|
.OrderByDescending(c => c.Order)
|
||||||
|
.Select(c => (uint?)c.Order)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Novel.Volumes
|
||||||
|
.Where(v => v.Order < chapter.Volume.Order)
|
||||||
|
.OrderByDescending(v => v.Order)
|
||||||
|
.SelectMany(v => v.Chapters.OrderByDescending(c => c.Order).Take(1))
|
||||||
|
.Select(c => (uint?)c.Order)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
|
||||||
|
// Next chapter: first try same volume, then first chapter of next volume
|
||||||
|
NextChapterVolumeOrder = chapter.Volume.Chapters
|
||||||
|
.Where(c => c.Order > chapterOrder)
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.Select(c => (int?)chapter.Volume.Order)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Novel.Volumes
|
||||||
|
.Where(v => v.Order > chapter.Volume.Order)
|
||||||
|
.OrderBy(v => v.Order)
|
||||||
|
.SelectMany(v => v.Chapters.OrderBy(c => c.Order).Take(1))
|
||||||
|
.Select(c => (int?)c.Volume.Order)
|
||||||
|
.FirstOrDefault(),
|
||||||
|
|
||||||
|
NextChapterOrder = chapter.Volume.Chapters
|
||||||
|
.Where(c => c.Order > chapterOrder)
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.Select(c => (uint?)c.Order)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? chapter.Volume.Novel.Volumes
|
||||||
|
.Where(v => v.Order > chapter.Volume.Order)
|
||||||
|
.OrderBy(v => v.Order)
|
||||||
|
.SelectMany(v => v.Chapters.OrderBy(c => c.Order).Take(1))
|
||||||
|
.Select(c => (uint?)c.Order)
|
||||||
|
.FirstOrDefault()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
547
FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs
generated
Normal file
547
FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs
generated
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
// <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("20251208230154_FA-misc_NovelConstraint")]
|
||||||
|
partial class FAmisc_NovelConstraint
|
||||||
|
{
|
||||||
|
/// <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.HasIndex("ExternalId", "SourceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
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", "Novel")
|
||||||
|
.WithMany("Chapters")
|
||||||
|
.HasForeignKey("NovelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Body");
|
||||||
|
|
||||||
|
b.Navigation("Name");
|
||||||
|
|
||||||
|
b.Navigation("Novel");
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FAmisc_NovelConstraint : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Novels_ExternalId_SourceId",
|
||||||
|
table: "Novels",
|
||||||
|
columns: new[] { "ExternalId", "SourceId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
column: "NovelId",
|
||||||
|
principalTable: "Novels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Novels_ExternalId_SourceId",
|
||||||
|
table: "Novels");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
column: "NovelId",
|
||||||
|
principalTable: "Novels",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
605
FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.Designer.cs
generated
Normal file
605
FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.Designer.cs
generated
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// <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("20251229203027_AddVolumes")]
|
||||||
|
partial class AddVolumes
|
||||||
|
{
|
||||||
|
/// <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>("Order")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("Revision")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("VolumeId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BodyId");
|
||||||
|
|
||||||
|
b.HasIndex("NameId");
|
||||||
|
|
||||||
|
b.HasIndex("VolumeId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
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.HasIndex("ExternalId", "SourceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
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("FictionArchive.Service.NovelService.Models.Novels.Volume", 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<Instant>("LastUpdatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("NameId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("NovelId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NameId");
|
||||||
|
|
||||||
|
b.HasIndex("NovelId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Volume");
|
||||||
|
});
|
||||||
|
|
||||||
|
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.Volume", "Volume")
|
||||||
|
.WithMany("Chapters")
|
||||||
|
.HasForeignKey("VolumeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Body");
|
||||||
|
|
||||||
|
b.Navigation("Name");
|
||||||
|
|
||||||
|
b.Navigation("Volume");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("NameId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
|
||||||
|
.WithMany("Volumes")
|
||||||
|
.HasForeignKey("NovelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Name");
|
||||||
|
|
||||||
|
b.Navigation("Novel");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("Volumes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Chapters");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVolumes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1. Create the Volume table
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Volume",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Order = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
NameId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
NovelId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
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_Volume", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Volume_LocalizationKeys_NameId",
|
||||||
|
column: x => x.NameId,
|
||||||
|
principalTable: "LocalizationKeys",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Volume_Novels_NovelId",
|
||||||
|
column: x => x.NovelId,
|
||||||
|
principalTable: "Novels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Volume_NameId",
|
||||||
|
table: "Volume",
|
||||||
|
column: "NameId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Volume_NovelId_Order",
|
||||||
|
table: "Volume",
|
||||||
|
columns: new[] { "NovelId", "Order" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// 2. Add nullable VolumeId column to Chapter (keep NovelId for now)
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "VolumeId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// 3. Data migration: Create volumes and link chapters for each novel
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
novel_rec RECORD;
|
||||||
|
loc_key_id uuid;
|
||||||
|
volume_id bigint;
|
||||||
|
BEGIN
|
||||||
|
FOR novel_rec IN SELECT ""Id"", ""RawLanguage"" FROM ""Novels"" LOOP
|
||||||
|
-- Create LocalizationKey for volume name
|
||||||
|
loc_key_id := gen_random_uuid();
|
||||||
|
INSERT INTO ""LocalizationKeys"" (""Id"", ""CreatedTime"", ""LastUpdatedTime"")
|
||||||
|
VALUES (loc_key_id, NOW(), NOW());
|
||||||
|
|
||||||
|
-- Create LocalizationText for 'Main Story' in novel's raw language
|
||||||
|
INSERT INTO ""LocalizationText"" (""Id"", ""LocalizationKeyId"", ""Language"", ""Text"", ""CreatedTime"", ""LastUpdatedTime"")
|
||||||
|
VALUES (gen_random_uuid(), loc_key_id, novel_rec.""RawLanguage"", 'Main Story', NOW(), NOW());
|
||||||
|
|
||||||
|
-- Create Volume for this novel
|
||||||
|
INSERT INTO ""Volume"" (""Order"", ""NameId"", ""NovelId"", ""CreatedTime"", ""LastUpdatedTime"")
|
||||||
|
VALUES (1, loc_key_id, novel_rec.""Id"", NOW(), NOW())
|
||||||
|
RETURNING ""Id"" INTO volume_id;
|
||||||
|
|
||||||
|
-- Link all chapters of this novel to the new volume
|
||||||
|
UPDATE ""Chapter"" SET ""VolumeId"" = volume_id WHERE ""NovelId"" = novel_rec.""Id"";
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
");
|
||||||
|
|
||||||
|
// 4. Drop old FK and index for NovelId
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Chapter_NovelId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
// 5. Drop NovelId column from Chapter
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NovelId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
// 6. Make VolumeId non-nullable
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "VolumeId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
// 7. Add unique index and FK for VolumeId
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Chapter_VolumeId_Order",
|
||||||
|
table: "Chapter",
|
||||||
|
columns: new[] { "VolumeId", "Order" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Chapter_Volume_VolumeId",
|
||||||
|
table: "Chapter",
|
||||||
|
column: "VolumeId",
|
||||||
|
principalTable: "Volume",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Add back NovelId column
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Migrate data back: set NovelId from Volume
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE ""Chapter"" c
|
||||||
|
SET ""NovelId"" = v.""NovelId""
|
||||||
|
FROM ""Volume"" v
|
||||||
|
WHERE c.""VolumeId"" = v.""Id"";
|
||||||
|
");
|
||||||
|
|
||||||
|
// Make NovelId non-nullable
|
||||||
|
migrationBuilder.AlterColumn<long>(
|
||||||
|
name: "NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(long),
|
||||||
|
oldType: "bigint",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
// Drop VolumeId FK and index
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Chapter_Volume_VolumeId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Chapter_VolumeId_Order",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
// Drop VolumeId column
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VolumeId",
|
||||||
|
table: "Chapter");
|
||||||
|
|
||||||
|
// Recreate NovelId index and FK
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Chapter_NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
column: "NovelId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Chapter_Novels_NovelId",
|
||||||
|
table: "Chapter",
|
||||||
|
column: "NovelId",
|
||||||
|
principalTable: "Novels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
// Note: Volume LocalizationKeys are not cleaned up in Down migration
|
||||||
|
// as they may have been modified. Manual cleanup may be needed.
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Volume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,9 +153,6 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
b.Property<Guid>("NameId")
|
b.Property<Guid>("NameId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<long?>("NovelId")
|
|
||||||
.HasColumnType("bigint");
|
|
||||||
|
|
||||||
b.Property<long>("Order")
|
b.Property<long>("Order")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -165,13 +162,17 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
b.Property<string>("Url")
|
b.Property<string>("Url")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("VolumeId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BodyId");
|
b.HasIndex("BodyId");
|
||||||
|
|
||||||
b.HasIndex("NameId");
|
b.HasIndex("NameId");
|
||||||
|
|
||||||
b.HasIndex("NovelId");
|
b.HasIndex("VolumeId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Chapter");
|
b.ToTable("Chapter");
|
||||||
});
|
});
|
||||||
@@ -234,6 +235,9 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SourceId");
|
b.HasIndex("SourceId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalId", "SourceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Novels");
|
b.ToTable("Novels");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,6 +358,39 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
b.ToTable("TranslationEngines");
|
b.ToTable("TranslationEngines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", 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<Instant>("LastUpdatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("NameId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("NovelId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NameId");
|
||||||
|
|
||||||
|
b.HasIndex("NovelId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Volume");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NovelNovelTag", b =>
|
modelBuilder.Entity("NovelNovelTag", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("NovelsId")
|
b.Property<long>("NovelsId")
|
||||||
@@ -424,13 +461,17 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
|
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Volume", "Volume")
|
||||||
.WithMany("Chapters")
|
.WithMany("Chapters")
|
||||||
.HasForeignKey("NovelId");
|
.HasForeignKey("VolumeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Body");
|
b.Navigation("Body");
|
||||||
|
|
||||||
b.Navigation("Name");
|
b.Navigation("Name");
|
||||||
|
|
||||||
|
b.Navigation("Volume");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
|
||||||
@@ -502,6 +543,25 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
b.Navigation("Name");
|
b.Navigation("Name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("NameId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
|
||||||
|
.WithMany("Volumes")
|
||||||
|
.HasForeignKey("NovelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Name");
|
||||||
|
|
||||||
|
b.Navigation("Novel");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("NovelNovelTag", b =>
|
modelBuilder.Entity("NovelNovelTag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
|
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
|
||||||
@@ -528,6 +588,11 @@ namespace FictionArchive.Service.NovelService.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Volumes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Chapters");
|
b.Navigation("Chapters");
|
||||||
});
|
});
|
||||||
|
|||||||
10
FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs
Normal file
10
FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public abstract class BaseDto<TKey>
|
||||||
|
{
|
||||||
|
public TKey Id { get; init; }
|
||||||
|
public Instant CreatedTime { get; init; }
|
||||||
|
public Instant LastUpdatedTime { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class ChapterDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public uint Revision { get; init; }
|
||||||
|
public uint Order { get; init; }
|
||||||
|
public string? Url { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Body { get; init; }
|
||||||
|
public required List<ImageDto> Images { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class ChapterReaderDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public uint Revision { get; init; }
|
||||||
|
public uint Order { get; init; }
|
||||||
|
public string? Url { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Body { get; init; }
|
||||||
|
public required List<ImageDto> Images { get; init; }
|
||||||
|
|
||||||
|
// Navigation context
|
||||||
|
public uint NovelId { get; init; }
|
||||||
|
public required string NovelName { get; init; }
|
||||||
|
|
||||||
|
// Volume context
|
||||||
|
public uint VolumeId { get; init; }
|
||||||
|
public required string VolumeName { get; init; }
|
||||||
|
public int VolumeOrder { get; init; }
|
||||||
|
public int TotalChaptersInVolume { get; init; }
|
||||||
|
|
||||||
|
// Cross-volume navigation (VolumeOrder + ChapterOrder identify a chapter)
|
||||||
|
public int? PrevChapterVolumeOrder { get; init; }
|
||||||
|
public uint? PrevChapterOrder { get; init; }
|
||||||
|
public int? NextChapterVolumeOrder { get; init; }
|
||||||
|
public uint? NextChapterOrder { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class ImageDto : BaseDto<Guid>
|
||||||
|
{
|
||||||
|
public string? NewPath { get; init; }
|
||||||
|
}
|
||||||
20
FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs
Normal file
20
FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using FictionArchive.Common.Enums;
|
||||||
|
using FictionArchive.Service.NovelService.Models.Enums;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class NovelDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public required PersonDto Author { get; init; }
|
||||||
|
public required string Url { get; init; }
|
||||||
|
public Language RawLanguage { get; init; }
|
||||||
|
public NovelStatus RawStatus { get; init; }
|
||||||
|
public NovelStatus? StatusOverride { get; init; }
|
||||||
|
public required SourceDto Source { get; init; }
|
||||||
|
public required string ExternalId { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Description { get; init; }
|
||||||
|
public required List<VolumeDto> Volumes { get; init; }
|
||||||
|
public required List<NovelTagDto> Tags { get; init; }
|
||||||
|
public ImageDto? CoverImage { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using FictionArchive.Service.NovelService.Models.Enums;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class NovelTagDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public required string Key { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public TagType TagType { get; init; }
|
||||||
|
public SourceDto? Source { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class PersonDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public string? ExternalUrl { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class SourceDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Key { get; init; }
|
||||||
|
public required string Url { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.DTOs;
|
||||||
|
|
||||||
|
public class VolumeDto : BaseDto<uint>
|
||||||
|
{
|
||||||
|
public int Order { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required List<ChapterDto> Chapters { get; init; }
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
|
|||||||
public class ChapterPullRequestedEvent : IIntegrationEvent
|
public class ChapterPullRequestedEvent : IIntegrationEvent
|
||||||
{
|
{
|
||||||
public uint NovelId { get; set; }
|
public uint NovelId { get; set; }
|
||||||
public uint ChapterNumber { get; set; }
|
public uint VolumeId { get; set; }
|
||||||
|
public uint ChapterOrder { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using FictionArchive.Service.NovelService.Models.Images;
|
using FictionArchive.Service.NovelService.Models.Images;
|
||||||
using FictionArchive.Service.NovelService.Models.Localization;
|
using FictionArchive.Service.NovelService.Models.Localization;
|
||||||
using FictionArchive.Service.Shared.Models;
|
using FictionArchive.Service.Shared.Models;
|
||||||
|
|
||||||
namespace FictionArchive.Service.NovelService.Models.Novels;
|
namespace FictionArchive.Service.NovelService.Models.Novels;
|
||||||
|
|
||||||
|
[Table("Chapter")]
|
||||||
public class Chapter : BaseEntity<uint>
|
public class Chapter : BaseEntity<uint>
|
||||||
{
|
{
|
||||||
public uint Revision { get; set; }
|
public uint Revision { get; set; }
|
||||||
@@ -15,4 +17,10 @@ public class Chapter : BaseEntity<uint>
|
|||||||
|
|
||||||
// Images appearing in this chapter.
|
// Images appearing in this chapter.
|
||||||
public List<Image> Images { get; set; }
|
public List<Image> Images { get; set; }
|
||||||
|
|
||||||
|
#region Navigation Properties
|
||||||
|
|
||||||
|
public Volume Volume { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ public class Novel : BaseEntity<uint>
|
|||||||
public LocalizationKey Name { get; set; }
|
public LocalizationKey Name { get; set; }
|
||||||
public LocalizationKey Description { get; set; }
|
public LocalizationKey Description { get; set; }
|
||||||
|
|
||||||
public List<Chapter> Chapters { get; set; }
|
public List<Volume> Volumes { get; set; }
|
||||||
public List<NovelTag> Tags { get; set; }
|
public List<NovelTag> Tags { get; set; }
|
||||||
public Image? CoverImage { get; set; }
|
public Image? CoverImage { get; set; }
|
||||||
}
|
}
|
||||||
24
FictionArchive.Service.NovelService/Models/Novels/Volume.cs
Normal file
24
FictionArchive.Service.NovelService/Models/Novels/Volume.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using FictionArchive.Service.NovelService.Models.Localization;
|
||||||
|
using FictionArchive.Service.Shared.Models;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.NovelService.Models.Novels;
|
||||||
|
|
||||||
|
[Table("Volume")]
|
||||||
|
public class Volume : BaseEntity<uint>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Signed int to allow special ordering like -1 for "Author Notes" at top.
|
||||||
|
/// </summary>
|
||||||
|
public int Order { get; set; }
|
||||||
|
|
||||||
|
public LocalizationKey Name { get; set; }
|
||||||
|
|
||||||
|
public List<Chapter> Chapters { get; set; }
|
||||||
|
|
||||||
|
#region Navigation Properties
|
||||||
|
|
||||||
|
public Novel Novel { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public class NovelMetadata
|
|||||||
public Language RawLanguage { get; set; }
|
public Language RawLanguage { get; set; }
|
||||||
public NovelStatus RawStatus { get; set; }
|
public NovelStatus RawStatus { get; set; }
|
||||||
|
|
||||||
public List<ChapterMetadata> Chapters { get; set; }
|
public List<VolumeMetadata> Volumes { get; set; }
|
||||||
public List<string> SourceTags { get; set; }
|
public List<string> SourceTags { get; set; }
|
||||||
public List<string> SystemTags { get; set; }
|
public List<string> SystemTags { get; set; }
|
||||||
public SourceDescriptor SourceDescriptor { get; set; }
|
public SourceDescriptor SourceDescriptor { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
|
||||||
|
|
||||||
|
public class VolumeMetadata
|
||||||
|
{
|
||||||
|
public int Order { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public List<ChapterMetadata> Chapters { get; set; }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using FictionArchive.Service.NovelService.Services;
|
|||||||
using FictionArchive.Service.NovelService.Services.EventHandlers;
|
using FictionArchive.Service.NovelService.Services.EventHandlers;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters;
|
||||||
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
|
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.Shared.Services.GraphQL;
|
using FictionArchive.Service.Shared.Services.GraphQL;
|
||||||
@@ -17,6 +18,8 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.AddLocalAppsettings();
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
@@ -24,26 +27,32 @@ public class Program
|
|||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
|
})
|
||||||
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
|
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
|
||||||
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
|
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
|
||||||
|
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>()
|
||||||
|
.Subscribe<FileUploadRequestStatusUpdateEvent, FileUploadRequestStatusUpdateEventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<NovelServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<NovelServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -53,12 +62,14 @@ public class Program
|
|||||||
builder.Services.AddHttpClient<NovelpiaAuthMessageHandler>(client =>
|
builder.Services.AddHttpClient<NovelpiaAuthMessageHandler>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("https://novelpia.com");
|
client.BaseAddress = new Uri("https://novelpia.com");
|
||||||
});
|
})
|
||||||
|
.AddStandardResilienceHandler();
|
||||||
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>(client =>
|
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("https://novelpia.com");
|
client.BaseAddress = new Uri("https://novelpia.com");
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
|
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>()
|
||||||
|
.AddStandardResilienceHandler();
|
||||||
|
|
||||||
builder.Services.Configure<NovelUpdateServiceConfiguration>(builder.Configuration.GetSection("UpdateService"));
|
builder.Services.Configure<NovelUpdateServiceConfiguration>(builder.Configuration.GetSection("UpdateService"));
|
||||||
builder.Services.AddTransient<NovelUpdateService>();
|
builder.Services.AddTransient<NovelUpdateService>();
|
||||||
@@ -67,11 +78,16 @@ public class Program
|
|||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -79,7 +95,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<Chapter
|
|||||||
|
|
||||||
public async Task Handle(ChapterPullRequestedEvent @event)
|
public async Task Handle(ChapterPullRequestedEvent @event)
|
||||||
{
|
{
|
||||||
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.ChapterNumber);
|
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.VolumeId, @event.ChapterOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,10 +10,31 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
|
|||||||
: FictionArchiveDbContext(options, logger)
|
: FictionArchiveDbContext(options, logger)
|
||||||
{
|
{
|
||||||
public DbSet<Novel> Novels { get; set; }
|
public DbSet<Novel> Novels { get; set; }
|
||||||
|
public DbSet<Volume> Volumes { get; set; }
|
||||||
|
public DbSet<Chapter> Chapters { get; set; }
|
||||||
public DbSet<Source> Sources { get; set; }
|
public DbSet<Source> Sources { get; set; }
|
||||||
public DbSet<TranslationEngine> TranslationEngines { get; set; }
|
public DbSet<TranslationEngine> TranslationEngines { get; set; }
|
||||||
public DbSet<NovelTag> Tags { get; set; }
|
public DbSet<NovelTag> Tags { get; set; }
|
||||||
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
|
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
|
||||||
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
|
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
|
||||||
public DbSet<Image> Images { get; set; }
|
public DbSet<Image> Images { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Novel>()
|
||||||
|
.HasIndex("ExternalId", "SourceId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
// Volume.Order is unique per Novel
|
||||||
|
modelBuilder.Entity<Volume>()
|
||||||
|
.HasIndex("NovelId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
// Chapter.Order is unique per Volume
|
||||||
|
modelBuilder.Entity<Chapter>()
|
||||||
|
.HasIndex("VolumeId", "Order")
|
||||||
|
.IsUnique();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using FictionArchive.Common.Enums;
|
||||||
using FictionArchive.Service.FileService.IntegrationEvents;
|
using FictionArchive.Service.FileService.IntegrationEvents;
|
||||||
using FictionArchive.Service.NovelService.Models.Configuration;
|
using FictionArchive.Service.NovelService.Models.Configuration;
|
||||||
using FictionArchive.Service.NovelService.Models.Enums;
|
using FictionArchive.Service.NovelService.Models.Enums;
|
||||||
using FictionArchive.Service.NovelService.Models.Images;
|
using FictionArchive.Service.NovelService.Models.Images;
|
||||||
|
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
|
||||||
using FictionArchive.Service.NovelService.Models.Localization;
|
using FictionArchive.Service.NovelService.Models.Localization;
|
||||||
using FictionArchive.Service.NovelService.Models.Novels;
|
using FictionArchive.Service.NovelService.Models.Novels;
|
||||||
using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
using FictionArchive.Service.NovelService.Models.SourceAdapters;
|
||||||
@@ -30,14 +32,283 @@ public class NovelUpdateService
|
|||||||
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
|
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
private async Task<Source> GetOrCreateSource(SourceDescriptor descriptor)
|
||||||
|
{
|
||||||
|
var existingSource = await _dbContext.Sources
|
||||||
|
.FirstOrDefaultAsync(s => s.Key == descriptor.Key);
|
||||||
|
|
||||||
|
if (existingSource != null)
|
||||||
|
{
|
||||||
|
return existingSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Source
|
||||||
|
{
|
||||||
|
Name = descriptor.Name,
|
||||||
|
Url = descriptor.Url,
|
||||||
|
Key = descriptor.Key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person GetOrCreateAuthor(
|
||||||
|
string authorName,
|
||||||
|
string? authorUrl,
|
||||||
|
Language rawLanguage,
|
||||||
|
Person? existingAuthor)
|
||||||
|
{
|
||||||
|
// Case 1: No existing author - create new
|
||||||
|
if (existingAuthor == null)
|
||||||
|
{
|
||||||
|
return new Person
|
||||||
|
{
|
||||||
|
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
|
||||||
|
ExternalUrl = authorUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: ExternalUrl differs - create new Person
|
||||||
|
if (existingAuthor.ExternalUrl != authorUrl)
|
||||||
|
{
|
||||||
|
return new Person
|
||||||
|
{
|
||||||
|
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
|
||||||
|
ExternalUrl = authorUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Same URL - update name if different
|
||||||
|
UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage);
|
||||||
|
return existingAuthor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language)
|
||||||
|
{
|
||||||
|
var existingText = key.Texts.FirstOrDefault(t => t.Language == language);
|
||||||
|
if (existingText != null)
|
||||||
|
{
|
||||||
|
existingText.Text = newText;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.Texts.Add(new LocalizationText
|
||||||
|
{
|
||||||
|
Language = language,
|
||||||
|
Text = newText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata)
|
||||||
|
{
|
||||||
|
UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage);
|
||||||
|
UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage);
|
||||||
|
novel.RawStatus = metadata.RawStatus;
|
||||||
|
novel.Url = metadata.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<NovelTag>> SynchronizeTags(
|
||||||
|
List<string> sourceTags,
|
||||||
|
List<string> systemTags,
|
||||||
|
Language rawLanguage)
|
||||||
|
{
|
||||||
|
var allTagKeys = sourceTags.Concat(systemTags).ToHashSet();
|
||||||
|
|
||||||
|
// Query existing tags from DB by Key
|
||||||
|
var existingTagsInDb = await _dbContext.Tags
|
||||||
|
.Where(t => allTagKeys.Contains(t.Key))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key);
|
||||||
|
var result = new List<NovelTag>();
|
||||||
|
|
||||||
|
// Process source tags
|
||||||
|
foreach (var tagKey in sourceTags)
|
||||||
|
{
|
||||||
|
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
|
||||||
|
{
|
||||||
|
result.Add(existingTag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Add(new NovelTag
|
||||||
|
{
|
||||||
|
Key = tagKey,
|
||||||
|
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
|
||||||
|
TagType = TagType.External
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process system tags
|
||||||
|
foreach (var tagKey in systemTags)
|
||||||
|
{
|
||||||
|
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
|
||||||
|
{
|
||||||
|
result.Add(existingTag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Add(new NovelTag
|
||||||
|
{
|
||||||
|
Key = tagKey,
|
||||||
|
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
|
||||||
|
TagType = TagType.System
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Chapter> SynchronizeChapters(
|
||||||
|
List<ChapterMetadata> metadataChapters,
|
||||||
|
Language rawLanguage,
|
||||||
|
List<Chapter>? existingChapters)
|
||||||
|
{
|
||||||
|
existingChapters ??= new List<Chapter>();
|
||||||
|
var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet();
|
||||||
|
|
||||||
|
// Only add chapters that don't already exist (by Order)
|
||||||
|
var newChapters = metadataChapters
|
||||||
|
.Where(mc => !existingOrderSet.Contains(mc.Order))
|
||||||
|
.Select(mc => new Chapter
|
||||||
|
{
|
||||||
|
Order = mc.Order,
|
||||||
|
Url = mc.Url,
|
||||||
|
Revision = mc.Revision,
|
||||||
|
Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage),
|
||||||
|
Body = new LocalizationKey
|
||||||
|
{
|
||||||
|
Texts = new List<LocalizationText>()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Combine existing chapters with new ones
|
||||||
|
return existingChapters.Concat(newChapters).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Volume> SynchronizeVolumes(
|
||||||
|
List<VolumeMetadata> metadataVolumes,
|
||||||
|
Language rawLanguage,
|
||||||
|
List<Volume>? existingVolumes)
|
||||||
|
{
|
||||||
|
existingVolumes ??= new List<Volume>();
|
||||||
|
var result = new List<Volume>();
|
||||||
|
|
||||||
|
foreach (var metaVolume in metadataVolumes)
|
||||||
|
{
|
||||||
|
// Match volumes by Order (unique per novel)
|
||||||
|
var existingVolume = existingVolumes.FirstOrDefault(v => v.Order == metaVolume.Order);
|
||||||
|
|
||||||
|
if (existingVolume != null)
|
||||||
|
{
|
||||||
|
// Volume exists - sync its chapters
|
||||||
|
existingVolume.Chapters = SynchronizeChapters(
|
||||||
|
metaVolume.Chapters,
|
||||||
|
rawLanguage,
|
||||||
|
existingVolume.Chapters);
|
||||||
|
result.Add(existingVolume);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// New volume - create it with synced chapters
|
||||||
|
var newVolume = new Volume
|
||||||
|
{
|
||||||
|
Order = metaVolume.Order,
|
||||||
|
Name = LocalizationKey.CreateFromText(metaVolume.Name, rawLanguage),
|
||||||
|
Chapters = SynchronizeChapters(metaVolume.Chapters, rawLanguage, null)
|
||||||
|
};
|
||||||
|
result.Add(newVolume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep existing volumes not in metadata (user-created volumes)
|
||||||
|
var metaOrders = metadataVolumes.Select(v => v.Order).ToHashSet();
|
||||||
|
result.AddRange(existingVolumes.Where(v => !metaOrders.Contains(v.Order)));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
|
||||||
|
ImageData? newCoverData,
|
||||||
|
Image? existingCoverImage)
|
||||||
|
{
|
||||||
|
// Case 1: No new cover image - keep existing or null
|
||||||
|
if (newCoverData == null)
|
||||||
|
{
|
||||||
|
return (existingCoverImage, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: New cover, no existing
|
||||||
|
if (existingCoverImage == null)
|
||||||
|
{
|
||||||
|
var newImage = new Image { OriginalPath = newCoverData.Url };
|
||||||
|
return (newImage, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Both exist - check if URL changed
|
||||||
|
if (existingCoverImage.OriginalPath != newCoverData.Url)
|
||||||
|
{
|
||||||
|
existingCoverImage.OriginalPath = newCoverData.Url;
|
||||||
|
existingCoverImage.NewPath = null; // Reset uploaded path
|
||||||
|
return (existingCoverImage, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 4: Same cover URL - no change needed
|
||||||
|
return (existingCoverImage, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Novel> CreateNewNovel(NovelMetadata metadata, Source source)
|
||||||
|
{
|
||||||
|
var author = new Person
|
||||||
|
{
|
||||||
|
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
|
||||||
|
ExternalUrl = metadata.AuthorUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
var tags = await SynchronizeTags(
|
||||||
|
metadata.SourceTags,
|
||||||
|
metadata.SystemTags,
|
||||||
|
metadata.RawLanguage);
|
||||||
|
|
||||||
|
var volumes = SynchronizeVolumes(metadata.Volumes, metadata.RawLanguage, null);
|
||||||
|
|
||||||
|
var novel = new Novel
|
||||||
|
{
|
||||||
|
Author = author,
|
||||||
|
RawLanguage = metadata.RawLanguage,
|
||||||
|
Url = metadata.Url,
|
||||||
|
ExternalId = metadata.ExternalId,
|
||||||
|
CoverImage = metadata.CoverImage != null
|
||||||
|
? new Image { OriginalPath = metadata.CoverImage.Url }
|
||||||
|
: null,
|
||||||
|
Volumes = volumes,
|
||||||
|
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
|
||||||
|
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
|
||||||
|
RawStatus = metadata.RawStatus,
|
||||||
|
Tags = tags,
|
||||||
|
Source = source
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.Novels.Add(novel);
|
||||||
|
return novel;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public async Task<Novel> ImportNovel(string novelUrl)
|
public async Task<Novel> ImportNovel(string novelUrl)
|
||||||
{
|
{
|
||||||
|
// Step 1: Get metadata from source adapter
|
||||||
NovelMetadata? metadata = null;
|
NovelMetadata? metadata = null;
|
||||||
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
|
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
|
||||||
{
|
{
|
||||||
if (await sourceAdapter.CanProcessNovel(novelUrl))
|
if (await sourceAdapter.CanProcessNovel(novelUrl))
|
||||||
{
|
{
|
||||||
metadata = await sourceAdapter.GetMetadata(novelUrl);
|
metadata = await sourceAdapter.GetMetadata(novelUrl);
|
||||||
|
break; // Stop after finding adapter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,91 +317,138 @@ public class NovelUpdateService
|
|||||||
throw new NotSupportedException("The provided novel url is currently unsupported.");
|
throw new NotSupportedException("The provided novel url is currently unsupported.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
|
// Step 2: Resolve or create Source
|
||||||
{
|
var source = await GetOrCreateSource(metadata.SourceDescriptor);
|
||||||
Key = tag,
|
|
||||||
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
|
|
||||||
TagType = TagType.System
|
|
||||||
});
|
|
||||||
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
|
|
||||||
{
|
|
||||||
Key = tag,
|
|
||||||
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
|
|
||||||
TagType = TagType.External
|
|
||||||
});
|
|
||||||
|
|
||||||
var addedNovel = _dbContext.Novels.Add(new Novel()
|
// Step 3: Check for existing novel by ExternalId + Source.Key
|
||||||
|
var existingNovel = await _dbContext.Novels
|
||||||
|
.Include(n => n.Author)
|
||||||
|
.ThenInclude(a => a.Name)
|
||||||
|
.ThenInclude(lk => lk.Texts)
|
||||||
|
.Include(n => n.Source)
|
||||||
|
.Include(n => n.Name)
|
||||||
|
.ThenInclude(lk => lk.Texts)
|
||||||
|
.Include(n => n.Description)
|
||||||
|
.ThenInclude(lk => lk.Texts)
|
||||||
|
.Include(n => n.Tags)
|
||||||
|
.Include(n => n.Volumes)
|
||||||
|
.ThenInclude(volume => volume.Chapters)
|
||||||
|
.ThenInclude(chapter => chapter.Body)
|
||||||
|
.ThenInclude(localizationKey => localizationKey.Texts)
|
||||||
|
.Include(n => n.CoverImage)
|
||||||
|
.FirstOrDefaultAsync(n =>
|
||||||
|
n.ExternalId == metadata.ExternalId &&
|
||||||
|
n.Source.Key == metadata.SourceDescriptor.Key);
|
||||||
|
|
||||||
|
Novel novel;
|
||||||
|
bool shouldPublishCoverEvent;
|
||||||
|
|
||||||
|
if (existingNovel == null)
|
||||||
{
|
{
|
||||||
Author = new Person()
|
// CREATE PATH: New novel
|
||||||
{
|
novel = await CreateNewNovel(metadata, source);
|
||||||
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
|
shouldPublishCoverEvent = novel.CoverImage != null;
|
||||||
ExternalUrl = metadata.AuthorUrl,
|
}
|
||||||
},
|
else
|
||||||
RawLanguage = metadata.RawLanguage,
|
{
|
||||||
Url = metadata.Url,
|
// UPDATE PATH: Existing novel
|
||||||
ExternalId = metadata.ExternalId,
|
novel = existingNovel;
|
||||||
CoverImage = metadata.CoverImage != null ? new Image()
|
|
||||||
{
|
// Update author
|
||||||
OriginalPath = metadata.CoverImage.Url,
|
novel.Author = GetOrCreateAuthor(
|
||||||
} : null,
|
metadata.AuthorName,
|
||||||
Chapters = metadata.Chapters.Select(chapter =>
|
metadata.AuthorUrl,
|
||||||
{
|
metadata.RawLanguage,
|
||||||
return new Chapter()
|
existingNovel.Author);
|
||||||
{
|
|
||||||
Order = chapter.Order,
|
// Update metadata (Name, Description, RawStatus)
|
||||||
Url = chapter.Url,
|
UpdateNovelMetadata(novel, metadata);
|
||||||
Revision = chapter.Revision,
|
|
||||||
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
|
// Synchronize tags (remove old, add new, reuse existing)
|
||||||
Body = new LocalizationKey()
|
novel.Tags = await SynchronizeTags(
|
||||||
{
|
metadata.SourceTags,
|
||||||
Texts = new List<LocalizationText>()
|
metadata.SystemTags,
|
||||||
}
|
metadata.RawLanguage);
|
||||||
};
|
|
||||||
}).ToList(),
|
// Synchronize volumes (and their chapters)
|
||||||
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
|
novel.Volumes = SynchronizeVolumes(
|
||||||
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
|
metadata.Volumes,
|
||||||
RawStatus = metadata.RawStatus,
|
metadata.RawLanguage,
|
||||||
Tags = sourceTags.Concat(systemTags).ToList(),
|
existingNovel.Volumes);
|
||||||
Source = new Source()
|
|
||||||
{
|
// Handle cover image
|
||||||
Name = metadata.SourceDescriptor.Name,
|
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
|
||||||
Url = metadata.SourceDescriptor.Url,
|
metadata.CoverImage,
|
||||||
Key = metadata.SourceDescriptor.Key,
|
existingNovel.CoverImage);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
// Signal request for cover image if present
|
// Publish cover image event if needed
|
||||||
if (addedNovel.Entity.CoverImage != null)
|
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
|
||||||
{
|
{
|
||||||
await _eventBus.Publish(new FileUploadRequestCreatedEvent()
|
await _eventBus.Publish(new FileUploadRequestCreatedEvent
|
||||||
{
|
{
|
||||||
RequestId = addedNovel.Entity.CoverImage.Id,
|
RequestId = novel.CoverImage.Id,
|
||||||
FileData = metadata.CoverImage.Data,
|
FileData = metadata.CoverImage.Data,
|
||||||
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg"
|
FilePath = $"Novels/{novel.Id}/Images/cover.jpg"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return addedNovel.Entity;
|
// Publish chapter pull events for chapters without body content
|
||||||
|
foreach (var volume in novel.Volumes)
|
||||||
|
{
|
||||||
|
var chaptersNeedingPull = volume.Chapters
|
||||||
|
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var chapter in chaptersNeedingPull)
|
||||||
|
{
|
||||||
|
await _eventBus.Publish(new ChapterPullRequestedEvent
|
||||||
|
{
|
||||||
|
NovelId = novel.Id,
|
||||||
|
VolumeId = volume.Id,
|
||||||
|
ChapterOrder = chapter.Order
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return novel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
|
public async Task<Chapter> PullChapterContents(uint novelId, uint volumeId, uint chapterOrder)
|
||||||
{
|
{
|
||||||
var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId)
|
var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId)
|
||||||
.Include(novel => novel.Chapters)
|
.Include(novel => novel.Volumes)
|
||||||
|
.ThenInclude(volume => volume.Chapters)
|
||||||
.ThenInclude(chapter => chapter.Body)
|
.ThenInclude(chapter => chapter.Body)
|
||||||
.ThenInclude(body => body.Texts)
|
.ThenInclude(body => body.Texts)
|
||||||
.Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images)
|
.Include(novel => novel.Source)
|
||||||
|
.Include(novel => novel.Volumes)
|
||||||
|
.ThenInclude(volume => volume.Chapters)
|
||||||
|
.ThenInclude(chapter => chapter.Images)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
|
var volume = novel.Volumes.FirstOrDefault(v => v.Id == volumeId);
|
||||||
|
var chapter = volume.Chapters.FirstOrDefault(c => c.Order == chapterOrder);
|
||||||
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
|
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
|
||||||
var rawChapter = await adapter.GetRawChapter(chapter.Url);
|
var rawChapter = await adapter.GetRawChapter(chapter.Url);
|
||||||
var localizationText = new LocalizationText()
|
|
||||||
|
// If we already have the raw for this, overwrite it for now. Revisions will come later.
|
||||||
|
var localizationText = chapter.Body.Texts.FirstOrDefault(text => text.Language == novel.RawLanguage);
|
||||||
|
if (localizationText == null)
|
||||||
{
|
{
|
||||||
Text = rawChapter.Text,
|
localizationText = new LocalizationText()
|
||||||
Language = novel.RawLanguage
|
{
|
||||||
};
|
Text = rawChapter.Text,
|
||||||
chapter.Body.Texts.Add(localizationText);
|
Language = novel.RawLanguage
|
||||||
|
};
|
||||||
|
chapter.Body.Texts.Add(localizationText);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
localizationText.Text = rawChapter.Text;
|
||||||
|
}
|
||||||
|
|
||||||
chapter.Images = rawChapter.ImageData.Select(img => new Image()
|
chapter.Images = rawChapter.ImageData.Select(img => new Image()
|
||||||
{
|
{
|
||||||
OriginalPath = img.Url
|
OriginalPath = img.Url
|
||||||
@@ -195,10 +513,90 @@ public class NovelUpdateService
|
|||||||
if (match != null)
|
if (match != null)
|
||||||
{
|
{
|
||||||
match.Attributes["src"].Value = newUrl;
|
match.Attributes["src"].Value = newUrl;
|
||||||
|
bodyText.Text = chapterDoc.DocumentNode.OuterHtml;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<NovelUpdateRequestedEvent> QueueNovelImport(string novelUrl)
|
||||||
|
{
|
||||||
|
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
|
||||||
|
{
|
||||||
|
NovelUrl = novelUrl
|
||||||
|
};
|
||||||
|
await _eventBus.Publish(importNovelRequestEvent);
|
||||||
|
return importNovelRequestEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder)
|
||||||
|
{
|
||||||
|
var chapterPullEvent = new ChapterPullRequestedEvent()
|
||||||
|
{
|
||||||
|
NovelId = novelId,
|
||||||
|
VolumeId = volumeId,
|
||||||
|
ChapterOrder = chapterOrder
|
||||||
|
};
|
||||||
|
await _eventBus.Publish(chapterPullEvent);
|
||||||
|
return chapterPullEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteNovel(uint novelId)
|
||||||
|
{
|
||||||
|
var novel = await _dbContext.Novels
|
||||||
|
.Include(n => n.CoverImage)
|
||||||
|
.Include(n => n.Name).ThenInclude(k => k.Texts)
|
||||||
|
.Include(n => n.Description).ThenInclude(k => k.Texts)
|
||||||
|
.Include(n => n.Volumes).ThenInclude(v => v.Name).ThenInclude(k => k.Texts)
|
||||||
|
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Images)
|
||||||
|
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts)
|
||||||
|
.Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts)
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == novelId);
|
||||||
|
|
||||||
|
if (novel == null)
|
||||||
|
throw new KeyNotFoundException($"Novel with ID '{novelId}' not found");
|
||||||
|
|
||||||
|
// Collect all LocalizationKey IDs for cleanup
|
||||||
|
var locKeyIds = new List<Guid> { novel.Name.Id, novel.Description.Id };
|
||||||
|
foreach (var volume in novel.Volumes)
|
||||||
|
{
|
||||||
|
locKeyIds.Add(volume.Name.Id);
|
||||||
|
locKeyIds.AddRange(volume.Chapters.Select(c => c.Name.Id));
|
||||||
|
locKeyIds.AddRange(volume.Chapters.Select(c => c.Body.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Remove LocalizationRequests referencing these keys
|
||||||
|
var locRequests = await _dbContext.LocalizationRequests
|
||||||
|
.Where(r => locKeyIds.Contains(r.KeyRequestedForTranslation.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
_dbContext.LocalizationRequests.RemoveRange(locRequests);
|
||||||
|
|
||||||
|
// 2. Remove LocalizationTexts (NO ACTION FK - won't cascade)
|
||||||
|
_dbContext.RemoveRange(novel.Name.Texts);
|
||||||
|
_dbContext.RemoveRange(novel.Description.Texts);
|
||||||
|
foreach (var volume in novel.Volumes)
|
||||||
|
{
|
||||||
|
_dbContext.RemoveRange(volume.Name.Texts);
|
||||||
|
foreach (var chapter in volume.Chapters)
|
||||||
|
{
|
||||||
|
_dbContext.RemoveRange(chapter.Name.Texts);
|
||||||
|
_dbContext.RemoveRange(chapter.Body.Texts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove Images (NO ACTION FK - won't cascade)
|
||||||
|
if (novel.CoverImage != null)
|
||||||
|
_dbContext.Images.Remove(novel.CoverImage);
|
||||||
|
foreach (var volume in novel.Volumes)
|
||||||
|
{
|
||||||
|
foreach (var chapter in volume.Chapters)
|
||||||
|
_dbContext.Images.RemoveRange(chapter.Images);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Remove novel - cascades: volumes, chapters, localization keys, tag mappings
|
||||||
|
_dbContext.Novels.Remove(novel);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public class NovelpiaAdapter : ISourceAdapter
|
|||||||
ExternalId = novelId.ToString(),
|
ExternalId = novelId.ToString(),
|
||||||
SystemTags = new List<string>(),
|
SystemTags = new List<string>(),
|
||||||
SourceTags = new List<string>(),
|
SourceTags = new List<string>(),
|
||||||
Chapters = new List<ChapterMetadata>(),
|
Volumes = new List<VolumeMetadata>(),
|
||||||
SourceDescriptor = SourceDescriptor
|
SourceDescriptor = SourceDescriptor
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,16 +132,17 @@ public class NovelpiaAdapter : ISourceAdapter
|
|||||||
{
|
{
|
||||||
novel.SourceTags.Add(tag);
|
novel.SourceTags.Add(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Author's posts (from notice_table in the page HTML)
|
||||||
|
var authorsPosts = ParseAuthorsPosts(novelData);
|
||||||
|
|
||||||
// Chapters
|
// Chapters
|
||||||
uint page = 0;
|
uint page = 0;
|
||||||
List<ChapterMetadata> chapters = new List<ChapterMetadata>();
|
List<ChapterMetadata> chapters = new List<ChapterMetadata>();
|
||||||
List<uint> seenChapterIds = new List<uint>();
|
List<uint> seenChapterIds = new List<uint>();
|
||||||
uint chapterOrder = 0;
|
uint chapterOrder = 1;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
await Task.Delay(500);
|
|
||||||
_logger.LogInformation("Next chapter batch");
|
|
||||||
var response = await _httpClient.PostAsync(EpisodeListEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
|
var response = await _httpClient.PostAsync(EpisodeListEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
{"novel_no", novelId.ToString()},
|
{"novel_no", novelId.ToString()},
|
||||||
@@ -170,8 +171,26 @@ public class NovelpiaAdapter : ISourceAdapter
|
|||||||
}
|
}
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
novel.Chapters = chapters;
|
|
||||||
|
// Add Author's Posts volume if there are any
|
||||||
|
if (authorsPosts.Count > 0)
|
||||||
|
{
|
||||||
|
novel.Volumes.Add(new VolumeMetadata
|
||||||
|
{
|
||||||
|
Order = 0,
|
||||||
|
Name = "Author's Posts",
|
||||||
|
Chapters = authorsPosts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main Story volume
|
||||||
|
novel.Volumes.Add(new VolumeMetadata
|
||||||
|
{
|
||||||
|
Order = 1,
|
||||||
|
Name = "Main Story",
|
||||||
|
Chapters = chapters
|
||||||
|
});
|
||||||
|
|
||||||
return novel;
|
return novel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,4 +262,40 @@ public class NovelpiaAdapter : ISourceAdapter
|
|||||||
}
|
}
|
||||||
return await image.Content.ReadAsByteArrayAsync();
|
return await image.Content.ReadAsByteArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<ChapterMetadata> ParseAuthorsPosts(string novelHtml)
|
||||||
|
{
|
||||||
|
var posts = new List<ChapterMetadata>();
|
||||||
|
|
||||||
|
// Find the notice_table section
|
||||||
|
var noticeTableMatch = Regex.Match(novelHtml,
|
||||||
|
@"(?s)<table[^>]*class=""notice_table[^""]*""[^>]*>(.*?)</table>");
|
||||||
|
if (!noticeTableMatch.Success)
|
||||||
|
return posts;
|
||||||
|
|
||||||
|
var tableContent = noticeTableMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Find all td elements with onclick containing viewer URL and extract title from <b>
|
||||||
|
// HTML structure: <td ... onclick="...location='/viewer/3330612';"><b>Title</b>
|
||||||
|
var postMatches = Regex.Matches(tableContent,
|
||||||
|
@"onclick=""[^""]*location='/viewer/(\d+)'[^""]*""[^>]*><b>([^<]+)</b>");
|
||||||
|
|
||||||
|
uint order = 1;
|
||||||
|
foreach (Match match in postMatches)
|
||||||
|
{
|
||||||
|
string viewerId = match.Groups[1].Value;
|
||||||
|
string title = WebUtility.HtmlDecode(match.Groups[2].Value.Trim());
|
||||||
|
|
||||||
|
posts.Add(new ChapterMetadata
|
||||||
|
{
|
||||||
|
Revision = 0,
|
||||||
|
Order = order,
|
||||||
|
Url = $"https://novelpia.com/viewer/{viewerId}",
|
||||||
|
Name = title
|
||||||
|
});
|
||||||
|
order++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,5 +19,15 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "NovelService"
|
"ClientIdentifier": "NovelService"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"subgraph": "Novels",
|
"subgraph": "Novels",
|
||||||
"http": {
|
"http": {
|
||||||
"baseAddress": "http://localhost:5101/graphql"
|
"baseAddress": "https://localhost:7208/graphql"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using FictionArchive.Service.SchedulerService.Models;
|
using FictionArchive.Service.SchedulerService.Models;
|
||||||
using FictionArchive.Service.SchedulerService.Services;
|
using FictionArchive.Service.SchedulerService.Services;
|
||||||
|
using FictionArchive.Service.Shared.Constants;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
using HotChocolate.Types;
|
using HotChocolate.Types;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
@@ -10,18 +12,21 @@ public class Mutation
|
|||||||
{
|
{
|
||||||
[Error<DuplicateNameException>]
|
[Error<DuplicateNameException>]
|
||||||
[Error<FormatException>]
|
[Error<FormatException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
|
public async Task<SchedulerJob> ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
|
||||||
{
|
{
|
||||||
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
|
return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Error<JobPersistenceException>]
|
[Error<JobPersistenceException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
|
public async Task<bool> RunJob(string jobKey, JobManagerService jobManager)
|
||||||
{
|
{
|
||||||
return await jobManager.TriggerJob(jobKey);
|
return await jobManager.TriggerJob(jobKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Error<KeyNotFoundException>]
|
[Error<KeyNotFoundException>]
|
||||||
|
[Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
|
||||||
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
|
public async Task<bool> DeleteJob(string jobKey, JobManagerService jobManager)
|
||||||
{
|
{
|
||||||
bool deleted = await jobManager.DeleteJob(jobKey);
|
bool deleted = await jobManager.DeleteJob(jobKey);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FictionArchive.Service.SchedulerService.GraphQL;
|
using FictionArchive.Service.SchedulerService.GraphQL;
|
||||||
using FictionArchive.Service.SchedulerService.Services;
|
using FictionArchive.Service.SchedulerService.Services;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
@@ -11,54 +12,79 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddTransient<JobManagerService>();
|
builder.Services.AddTransient<JobManagerService>();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<SchedulerServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
});
|
{
|
||||||
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Quartz
|
#region Quartz
|
||||||
|
|
||||||
builder.Services.AddQuartz(opt =>
|
if (isSchemaExport)
|
||||||
{
|
{
|
||||||
opt.UsePersistentStore(pso =>
|
// Schema export mode: use in-memory store (no DB connection needed)
|
||||||
|
builder.Services.AddQuartz(opt =>
|
||||||
{
|
{
|
||||||
pso.UsePostgres(pgsql =>
|
opt.UseInMemoryStore();
|
||||||
{
|
|
||||||
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
|
||||||
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
|
|
||||||
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
|
|
||||||
});
|
|
||||||
pso.UseNewtonsoftJsonSerializer();
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
builder.Services.AddQuartzHostedService(opt =>
|
else
|
||||||
{
|
{
|
||||||
opt.WaitForJobsToComplete = true;
|
builder.Services.AddQuartz(opt =>
|
||||||
});
|
{
|
||||||
|
opt.UsePersistentStore(pso =>
|
||||||
|
{
|
||||||
|
pso.UsePostgres(pgsql =>
|
||||||
|
{
|
||||||
|
pgsql.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||||
|
pgsql.UseDriverDelegate<PostgreSQLDelegate>();
|
||||||
|
pgsql.TablePrefix = "quartz.qrtz_"; // Needed for Postgres due to the differing schema used
|
||||||
|
});
|
||||||
|
pso.UseNewtonsoftJsonSerializer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.Services.AddQuartzHostedService(opt =>
|
||||||
|
{
|
||||||
|
opt.WaitForJobsToComplete = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
// Update database (skip in schema export mode)
|
||||||
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<SchedulerServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -66,7 +92,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -12,5 +12,15 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
|
"DefaultConnection": "Host=localhost;Database=FictionArchive_SchedulerService;Username=postgres;password=postgres"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace FictionArchive.Service.Shared.Constants;
|
||||||
|
|
||||||
|
public static class AuthorizationConstants
|
||||||
|
{
|
||||||
|
public static class Roles
|
||||||
|
{
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Policies
|
||||||
|
{
|
||||||
|
public const string Admin = "Admin";
|
||||||
|
public const string User = "User";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using FictionArchive.Service.Shared.Constants;
|
||||||
|
using FictionArchive.Service.Shared.Models.Authentication;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.Shared.Extensions;
|
||||||
|
|
||||||
|
public static class AuthenticationExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
|
||||||
|
|
||||||
|
if (oidcConfig == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateOidcConfiguration(oidcConfig);
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = oidcConfig.Authority;
|
||||||
|
options.Audience = oidcConfig.Audience;
|
||||||
|
options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
|
||||||
|
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = oidcConfig.ValidateIssuer,
|
||||||
|
ValidIssuer = oidcConfig.ValidIssuer,
|
||||||
|
ValidateAudience = oidcConfig.ValidateAudience,
|
||||||
|
ValidateLifetime = oidcConfig.ValidateLifetime,
|
||||||
|
ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(5)
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events = CreateLoggingJwtBearerEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JwtBearerEvents CreateLoggingJwtBearerEvents(JwtBearerEvents? existingEvents = null)
|
||||||
|
{
|
||||||
|
return new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = existingEvents?.OnMessageReceived ?? (_ => Task.CompletedTask),
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("JwtBearerAuthentication");
|
||||||
|
|
||||||
|
logger.LogWarning(context.Exception, "JWT authentication failed: {Message}", context.Exception.Message);
|
||||||
|
|
||||||
|
return existingEvents?.OnAuthenticationFailed?.Invoke(context) ?? Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnChallenge = context =>
|
||||||
|
{
|
||||||
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("JwtBearerAuthentication");
|
||||||
|
|
||||||
|
logger.LogDebug(
|
||||||
|
"JWT challenge issued. Error: {Error}, ErrorDescription: {ErrorDescription}",
|
||||||
|
context.Error,
|
||||||
|
context.ErrorDescription);
|
||||||
|
|
||||||
|
return existingEvents?.OnChallenge?.Invoke(context) ?? Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("JwtBearerAuthentication");
|
||||||
|
|
||||||
|
logger.LogDebug(
|
||||||
|
"JWT token validated for subject: {Subject}",
|
||||||
|
context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown");
|
||||||
|
|
||||||
|
return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddOidcCookieAuthentication(this IServiceCollection services, IConfiguration configuration, string cookieName = "fa_session")
|
||||||
|
{
|
||||||
|
var oidcConfig = configuration.GetSection("OIDC").Get<OidcConfiguration>();
|
||||||
|
|
||||||
|
if (oidcConfig == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OIDC configuration is required but not found in app settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateOidcConfiguration(oidcConfig);
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = oidcConfig.Authority;
|
||||||
|
options.Audience = oidcConfig.Audience;
|
||||||
|
options.RequireHttpsMetadata = !string.IsNullOrEmpty(oidcConfig.Authority) && oidcConfig.Authority.StartsWith("https://");
|
||||||
|
|
||||||
|
var cookieEvents = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
// Try to get token from cookie first, then from Authorization header
|
||||||
|
if (context.Request.Cookies.ContainsKey(cookieName))
|
||||||
|
{
|
||||||
|
context.Token = context.Request.Cookies[cookieName];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
options.Events = CreateLoggingJwtBearerEvents(cookieEvents);
|
||||||
|
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = oidcConfig.ValidateIssuer,
|
||||||
|
ValidIssuer = oidcConfig.ValidIssuer,
|
||||||
|
ValidateAudience = oidcConfig.ValidateAudience,
|
||||||
|
ValidateLifetime = oidcConfig.ValidateLifetime,
|
||||||
|
ValidateIssuerSigningKey = oidcConfig.ValidateIssuerSigningKey,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(5)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddFictionArchiveAuthorization(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddAuthorizationBuilder()
|
||||||
|
.AddPolicy(AuthorizationConstants.Policies.Admin, policy => policy.RequireRole(AuthorizationConstants.Roles.Admin))
|
||||||
|
.AddPolicy(AuthorizationConstants.Policies.User, policy => policy.RequireAuthenticatedUser());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateOidcConfiguration(OidcConfiguration config)
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(config.Authority))
|
||||||
|
errors.Add("OIDC Authority is required");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(config.ClientId))
|
||||||
|
errors.Add("OIDC ClientId is required");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(config.Audience))
|
||||||
|
errors.Add("OIDC Audience is required");
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(config.Authority, UriKind.Absolute, out var authorityUri))
|
||||||
|
errors.Add($"OIDC Authority '{config.Authority}' is not a valid URI");
|
||||||
|
else if (!authorityUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!authorityUri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||||
|
errors.Add("OIDC Authority must use HTTPS unless running on localhost");
|
||||||
|
|
||||||
|
if (errors.Any())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OIDC configuration validation failed:{Environment.NewLine}{string.Join(Environment.NewLine, errors)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,29 @@ namespace FictionArchive.Service.Shared.Extensions;
|
|||||||
|
|
||||||
public static class DatabaseExtensions
|
public static class DatabaseExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection RegisterDbContext<TContext>(this IServiceCollection services,
|
public static IServiceCollection RegisterDbContext<TContext>(
|
||||||
string connectionString) where TContext : FictionArchiveDbContext
|
this IServiceCollection services,
|
||||||
|
string connectionString,
|
||||||
|
bool skipInfrastructure = false) where TContext : FictionArchiveDbContext
|
||||||
{
|
{
|
||||||
services.AddDbContext<TContext>(options =>
|
if (skipInfrastructure)
|
||||||
{
|
{
|
||||||
options.UseNpgsql(connectionString, o =>
|
// For schema export: use in-memory provider to allow EF Core entity discovery
|
||||||
|
services.AddDbContext<TContext>(options =>
|
||||||
{
|
{
|
||||||
o.UseNodaTime();
|
options.UseInMemoryDatabase($"SchemaExport_{typeof(TContext).Name}");
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddDbContext<TContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseNpgsql(connectionString, o =>
|
||||||
|
{
|
||||||
|
o.UseNodaTime();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ public static class GraphQLExtensions
|
|||||||
.AddErrorFilter<LoggingErrorFilter>()
|
.AddErrorFilter<LoggingErrorFilter>()
|
||||||
.AddType<UnsignedIntType>()
|
.AddType<UnsignedIntType>()
|
||||||
.AddType<InstantType>()
|
.AddType<InstantType>()
|
||||||
|
.ModifyCostOptions(opt => opt.MaxFieldCost = 10000)
|
||||||
.AddMutationConventions(applyToAllMutations: true)
|
.AddMutationConventions(applyToAllMutations: true)
|
||||||
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
|
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
|
||||||
.AddSorting()
|
.AddSorting()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
|
<PackageReference Include="GraphQL.Server.Ui.GraphiQL" Version="8.3.3" />
|
||||||
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
|
||||||
|
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" 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.Data.EntityFramework" Version="15.1.11" />
|
||||||
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
|
||||||
@@ -18,16 +19,20 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Polly" Version="8.6.5" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace FictionArchive.Service.Shared.Models.Authentication;
|
||||||
|
|
||||||
|
public class OidcConfiguration
|
||||||
|
{
|
||||||
|
public string Authority { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string Audience { get; set; } = string.Empty;
|
||||||
|
public string? ValidIssuer { get; set; }
|
||||||
|
public bool ValidateIssuer { get; set; } = true;
|
||||||
|
public bool ValidateAudience { get; set; } = true;
|
||||||
|
public bool ValidateLifetime { get; set; } = true;
|
||||||
|
public bool ValidateIssuerSigningKey { get; set; } = true;
|
||||||
|
}
|
||||||
22
FictionArchive.Service.Shared/SchemaExportDetector.cs
Normal file
22
FictionArchive.Service.Shared/SchemaExportDetector.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace FictionArchive.Service.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects if the application is running in schema export mode (for HotChocolate CLI commands).
|
||||||
|
/// In this mode, infrastructure like RabbitMQ and databases should not be initialized.
|
||||||
|
/// </summary>
|
||||||
|
public static class SchemaExportDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the current run is a schema export command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">Command line arguments passed to Main()</param>
|
||||||
|
/// <returns>True if running schema export, false otherwise</returns>
|
||||||
|
public static bool IsSchemaExportMode(string[] args)
|
||||||
|
{
|
||||||
|
// HotChocolate CLI pattern: "schema export" after "--" delimiter
|
||||||
|
// Handles: dotnet run -- schema export --output schema.graphql
|
||||||
|
var normalizedArgs = args.SkipWhile(a => a == "--").ToArray();
|
||||||
|
return normalizedArgs.Length > 0 &&
|
||||||
|
normalizedArgs[0].Equals("schema", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ public class RabbitMQEventBus : IEventBus, IHostedService
|
|||||||
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
|
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken);
|
||||||
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
|
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ using FictionArchive.Service.TranslationService.Models.Enums;
|
|||||||
using FictionArchive.Service.TranslationService.Services;
|
using FictionArchive.Service.TranslationService.Services;
|
||||||
using FictionArchive.Service.TranslationService.Services.Database;
|
using FictionArchive.Service.TranslationService.Services.Database;
|
||||||
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
|
||||||
namespace FictionArchive.Service.TranslationService.GraphQL;
|
namespace FictionArchive.Service.TranslationService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
|
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
|
||||||
{
|
{
|
||||||
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
|
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,41 @@
|
|||||||
using FictionArchive.Service.TranslationService.Models;
|
using FictionArchive.Service.TranslationService.Models;
|
||||||
using FictionArchive.Service.TranslationService.Models.Database;
|
using FictionArchive.Service.TranslationService.Models.DTOs;
|
||||||
using FictionArchive.Service.TranslationService.Services.Database;
|
using FictionArchive.Service.TranslationService.Services.Database;
|
||||||
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using HotChocolate.Authorization;
|
||||||
|
using HotChocolate.Data;
|
||||||
|
|
||||||
namespace FictionArchive.Service.TranslationService.GraphQL;
|
namespace FictionArchive.Service.TranslationService.GraphQL;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
[UseSorting]
|
[UseSorting]
|
||||||
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
|
public IEnumerable<TranslationEngineDescriptor> GetTranslationEngines(IEnumerable<ITranslationEngine> engines)
|
||||||
{
|
{
|
||||||
return engines.Select(engine => engine.Descriptor);
|
return engines.Select(engine => engine.Descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[UsePaging]
|
[UsePaging]
|
||||||
[UseProjection]
|
[UseProjection]
|
||||||
[UseFiltering]
|
[UseFiltering]
|
||||||
[UseSorting]
|
[UseSorting]
|
||||||
public IQueryable<TranslationRequest> GetTranslationRequests(TranslationServiceDbContext dbContext)
|
public IQueryable<TranslationRequestDto> GetTranslationRequests(TranslationServiceDbContext dbContext)
|
||||||
{
|
{
|
||||||
return dbContext.TranslationRequests.AsQueryable();
|
return dbContext.TranslationRequests.Select(request => new TranslationRequestDto
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
CreatedTime = request.CreatedTime,
|
||||||
|
LastUpdatedTime = request.LastUpdatedTime,
|
||||||
|
OriginalText = request.OriginalText,
|
||||||
|
TranslatedText = request.TranslatedText,
|
||||||
|
From = request.From,
|
||||||
|
To = request.To,
|
||||||
|
TranslationEngineKey = request.TranslationEngineKey,
|
||||||
|
Status = request.Status,
|
||||||
|
BilledCharacterCount = request.BilledCharacterCount
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FictionArchive.Common.Enums;
|
||||||
|
using FictionArchive.Service.TranslationService.Models.Enums;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.TranslationService.Models.DTOs;
|
||||||
|
|
||||||
|
public class TranslationRequestDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Instant CreatedTime { get; init; }
|
||||||
|
public Instant LastUpdatedTime { get; init; }
|
||||||
|
public required string OriginalText { get; init; }
|
||||||
|
public string? TranslatedText { get; init; }
|
||||||
|
public Language From { get; init; }
|
||||||
|
public Language To { get; init; }
|
||||||
|
public required string TranslationEngineKey { get; init; }
|
||||||
|
public TranslationRequestStatus Status { get; init; }
|
||||||
|
public uint BilledCharacterCount { get; init; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using DeepL;
|
using DeepL;
|
||||||
using FictionArchive.Common.Extensions;
|
using FictionArchive.Common.Extensions;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.Shared.Services.GraphQL;
|
using FictionArchive.Service.Shared.Services.GraphQL;
|
||||||
@@ -18,6 +19,8 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.AddLocalAppsettings();
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
@@ -25,24 +28,30 @@ public class Program
|
|||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
|
})
|
||||||
|
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
#region Database
|
#region Database
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<TranslationServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
builder.Services.RegisterDbContext<TranslationServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -55,14 +64,19 @@ public class Program
|
|||||||
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
|
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
|
||||||
|
|
||||||
builder.Services.AddTransient<TranslationEngineService>();
|
builder.Services.AddTransient<TranslationEngineService>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<TranslationServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<TranslationServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
@@ -70,7 +84,10 @@ public class Program
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -15,5 +15,15 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "TranslationService"
|
"ClientIdentifier": "TranslationService"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.UserService\FictionArchive.Service.UserService.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
using FictionArchive.Service.Shared.Services.EventBus;
|
||||||
|
using FictionArchive.Service.UserService.Models.Database;
|
||||||
|
using FictionArchive.Service.UserService.Services;
|
||||||
|
using FictionArchive.Service.UserService.Services.AuthenticationClient;
|
||||||
|
using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Tests;
|
||||||
|
|
||||||
|
public class UserManagementServiceTests
|
||||||
|
{
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
private static UserServiceDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<UserServiceDbContext>()
|
||||||
|
.UseInMemoryDatabase($"UserManagementServiceTests-{Guid.NewGuid()}")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new UserServiceDbContext(options, NullLogger<UserServiceDbContext>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserManagementService CreateService(
|
||||||
|
UserServiceDbContext dbContext,
|
||||||
|
IAuthenticationServiceClient authClient,
|
||||||
|
IEventBus? eventBus = null)
|
||||||
|
{
|
||||||
|
return new UserManagementService(
|
||||||
|
dbContext,
|
||||||
|
NullLogger<UserManagementService>.Instance,
|
||||||
|
authClient,
|
||||||
|
eventBus ?? Substitute.For<IEventBus>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User CreateTestUser(string username, string email, int availableInvites = 5)
|
||||||
|
{
|
||||||
|
return new User
|
||||||
|
{
|
||||||
|
Username = username,
|
||||||
|
Email = email,
|
||||||
|
OAuthProviderId = Guid.NewGuid().ToString(),
|
||||||
|
Disabled = false,
|
||||||
|
AvailableInvites = availableInvites
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region InviteUserAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WithValidInviter_CreatesUserAndDecrementsInvites()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
|
||||||
|
dbContext.Users.Add(inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" });
|
||||||
|
authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(true);
|
||||||
|
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Username.Should().Be("newuser");
|
||||||
|
result.Email.Should().Be("new@test.com");
|
||||||
|
result.InviterId.Should().Be(inviter.Id);
|
||||||
|
result.AvailableInvites.Should().Be(0);
|
||||||
|
inviter.AvailableInvites.Should().Be(2);
|
||||||
|
|
||||||
|
await authClient.Received(1).CreateUserAsync("newuser", "new@test.com", "newuser");
|
||||||
|
await authClient.Received(1).SendRecoveryEmailAsync(123);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WithNoAvailableInvites_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 0);
|
||||||
|
dbContext.Users.Add(inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
await authClient.DidNotReceive().CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WithDuplicateEmail_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var existingUser = CreateTestUser("existing", "existing@test.com");
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
|
||||||
|
dbContext.Users.AddRange(existingUser, inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "existing@test.com", "newuser");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
await authClient.DidNotReceive().CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
inviter.AvailableInvites.Should().Be(3); // Not decremented
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WithDuplicateUsername_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var existingUser = CreateTestUser("existinguser", "existing@test.com");
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
|
||||||
|
dbContext.Users.AddRange(existingUser, inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "new@test.com", "existinguser");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
await authClient.DidNotReceive().CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
|
||||||
|
inviter.AvailableInvites.Should().Be(3); // Not decremented
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WhenAuthentikFails_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
|
||||||
|
dbContext.Users.Add(inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns((AuthentikUserResponse?)null);
|
||||||
|
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
await authClient.DidNotReceive().SendRecoveryEmailAsync(Arg.Any<int>());
|
||||||
|
|
||||||
|
// Verify no user was added to the database
|
||||||
|
var usersInDb = await dbContext.Users.ToListAsync();
|
||||||
|
usersInDb.Should().HaveCount(1); // Only the inviter
|
||||||
|
inviter.AvailableInvites.Should().Be(3); // Not decremented
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_WhenRecoveryEmailFails_StillCreatesUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
|
||||||
|
dbContext.Users.Add(inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" });
|
||||||
|
authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(false); // Email fails
|
||||||
|
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
|
||||||
|
|
||||||
|
// Assert - User should still be created despite email failure
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Username.Should().Be("newuser");
|
||||||
|
inviter.AvailableInvites.Should().Be(2);
|
||||||
|
|
||||||
|
// Verify user was added to database
|
||||||
|
var usersInDb = await dbContext.Users.ToListAsync();
|
||||||
|
usersInDb.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InviteUserAsync_SetsCorrectUserProperties()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 5);
|
||||||
|
dbContext.Users.Add(inviter);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authentikUid = "authentik-uid-789";
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||||
|
.Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid });
|
||||||
|
authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(true);
|
||||||
|
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.InviteUserAsync(inviter, "newuser@test.com", "newusername");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Username.Should().Be("newusername");
|
||||||
|
result.Email.Should().Be("newuser@test.com");
|
||||||
|
result.OAuthProviderId.Should().Be(authentikUid);
|
||||||
|
result.InviterId.Should().Be(inviter.Id);
|
||||||
|
result.AvailableInvites.Should().Be(0);
|
||||||
|
result.Disabled.Should().BeFalse();
|
||||||
|
result.Id.Should().NotBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetUserByOAuthProviderIdAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserByOAuthProviderIdAsync_WithExistingUser_ReturnsUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var oAuthProviderId = "oauth-provider-123";
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Username = "testuser",
|
||||||
|
Email = "test@test.com",
|
||||||
|
OAuthProviderId = oAuthProviderId,
|
||||||
|
Disabled = false,
|
||||||
|
AvailableInvites = 5
|
||||||
|
};
|
||||||
|
dbContext.Users.Add(user);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetUserByOAuthProviderIdAsync(oAuthProviderId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Id.Should().Be(user.Id);
|
||||||
|
result.Username.Should().Be("testuser");
|
||||||
|
result.OAuthProviderId.Should().Be(oAuthProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserByOAuthProviderIdAsync_WithNonExistingUser_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetUserByOAuthProviderIdAsync("non-existing-id");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetUsers Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsers_ReturnsAllUsers()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var user1 = CreateTestUser("user1", "user1@test.com");
|
||||||
|
var user2 = CreateTestUser("user2", "user2@test.com");
|
||||||
|
var user3 = CreateTestUser("user3", "user3@test.com");
|
||||||
|
dbContext.Users.AddRange(user1, user2, user3);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetUsers().ToListAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().HaveCount(3);
|
||||||
|
result.Select(u => u.Username).Should().BeEquivalentTo(new[] { "user1", "user2", "user3" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsers_WithEmptyDb_ReturnsEmptyQueryable()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dbContext = CreateDbContext();
|
||||||
|
var authClient = Substitute.For<IAuthenticationServiceClient>();
|
||||||
|
var service = CreateService(dbContext, authClient);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetUsers().ToListAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Models\IntegrationEvents\" />
|
||||||
|
<Folder Include="Services\EventHandlers\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,13 +1,53 @@
|
|||||||
using FictionArchive.Service.UserService.Models.Database;
|
using System.Security.Claims;
|
||||||
|
using FictionArchive.Service.UserService.Models.DTOs;
|
||||||
using FictionArchive.Service.UserService.Services;
|
using FictionArchive.Service.UserService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
using HotChocolate.Types;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.GraphQL;
|
namespace FictionArchive.Service.UserService.GraphQL;
|
||||||
|
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
|
[Authorize]
|
||||||
string? inviterOAuthProviderId, UserManagementService userManagementService)
|
[Error<InvalidOperationException>]
|
||||||
|
public async Task<UserDto> InviteUser(
|
||||||
|
string email,
|
||||||
|
string username,
|
||||||
|
UserManagementService userManagementService,
|
||||||
|
ClaimsPrincipal claimsPrincipal)
|
||||||
{
|
{
|
||||||
return await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId);
|
// Get the current user's OAuth provider ID from claims
|
||||||
|
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(oAuthProviderId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Unable to determine current user identity");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the inviter from the database
|
||||||
|
var inviter = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId);
|
||||||
|
if (inviter == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Current user not found in the system");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invite the new user
|
||||||
|
var newUser = await userManagementService.InviteUserAsync(inviter, email, username);
|
||||||
|
if (newUser == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Failed to invite user. Either you have no available invites, or the email/username is already in use.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserDto
|
||||||
|
{
|
||||||
|
Id = newUser.Id,
|
||||||
|
CreatedTime = newUser.CreatedTime,
|
||||||
|
LastUpdatedTime = newUser.LastUpdatedTime,
|
||||||
|
Username = newUser.Username,
|
||||||
|
Email = newUser.Email,
|
||||||
|
Disabled = newUser.Disabled,
|
||||||
|
AvailableInvites = newUser.AvailableInvites,
|
||||||
|
InviterId = newUser.InviterId
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
using FictionArchive.Service.UserService.Models.Database;
|
using System.Security.Claims;
|
||||||
|
using FictionArchive.Service.UserService.Models.DTOs;
|
||||||
using FictionArchive.Service.UserService.Services;
|
using FictionArchive.Service.UserService.Services;
|
||||||
|
using HotChocolate.Authorization;
|
||||||
|
using HotChocolate.Data;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.GraphQL;
|
namespace FictionArchive.Service.UserService.GraphQL;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
{
|
{
|
||||||
public async Task<IQueryable<User>> GetUsers(UserManagementService userManagementService)
|
[Authorize]
|
||||||
|
[UseProjection]
|
||||||
|
[UseFirstOrDefault]
|
||||||
|
public IQueryable<UserDto> GetCurrentUser(
|
||||||
|
UserServiceDbContext dbContext,
|
||||||
|
ClaimsPrincipal claimsPrincipal)
|
||||||
{
|
{
|
||||||
return userManagementService.GetUsers();
|
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(oAuthProviderId))
|
||||||
|
{
|
||||||
|
return Enumerable.Empty<UserDto>().AsQueryable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbContext.Users
|
||||||
|
.Where(u => u.OAuthProviderId == oAuthProviderId)
|
||||||
|
.Select(u => new UserDto
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
CreatedTime = u.CreatedTime,
|
||||||
|
LastUpdatedTime = u.LastUpdatedTime,
|
||||||
|
Username = u.Username,
|
||||||
|
Email = u.Email,
|
||||||
|
Disabled = u.Disabled,
|
||||||
|
AvailableInvites = u.AvailableInvites,
|
||||||
|
InviterId = u.InviterId,
|
||||||
|
InvitedUsers = u.InvitedUsers.Select(iu => new InvitedUserDto
|
||||||
|
{
|
||||||
|
Username = iu.Username,
|
||||||
|
Email = iu.Email
|
||||||
|
}).ToList()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs
generated
Normal file
83
FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs
generated
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FictionArchive.Service.UserService.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.UserService.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(UserServiceDbContext))]
|
||||||
|
[Migration("20251229151921_AddAvailableInvites")]
|
||||||
|
partial class AddAvailableInvites
|
||||||
|
{
|
||||||
|
/// <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.UserService.Models.Database.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AvailableInvites")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("Disabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("InviterId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Instant>("LastUpdatedTime")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("OAuthProviderId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InviterId");
|
||||||
|
|
||||||
|
b.HasIndex("OAuthProviderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FictionArchive.Service.UserService.Models.Database.User", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FictionArchive.Service.UserService.Models.Database.User", "Inviter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InviterId");
|
||||||
|
|
||||||
|
b.Navigation("Inviter");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAvailableInvites : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "AvailableInvites",
|
||||||
|
table: "Users",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AvailableInvites",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ namespace FictionArchive.Service.UserService.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AvailableInvites")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedTime")
|
b.Property<Instant>("CreatedTime")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FictionArchive.Service.UserService.Models.DTOs;
|
||||||
|
|
||||||
|
public class InvitedUserDto
|
||||||
|
{
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public required string Email { get; init; }
|
||||||
|
}
|
||||||
17
FictionArchive.Service.UserService/Models/DTOs/UserDto.cs
Normal file
17
FictionArchive.Service.UserService/Models/DTOs/UserDto.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Models.DTOs;
|
||||||
|
|
||||||
|
public class UserDto
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public Instant CreatedTime { get; init; }
|
||||||
|
public Instant LastUpdatedTime { get; init; }
|
||||||
|
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public required string Email { get; init; }
|
||||||
|
public bool Disabled { get; init; }
|
||||||
|
public int AvailableInvites { get; init; }
|
||||||
|
public Guid? InviterId { get; init; }
|
||||||
|
public List<InvitedUserDto>? InvitedUsers { get; init; }
|
||||||
|
}
|
||||||
@@ -6,15 +6,14 @@ namespace FictionArchive.Service.UserService.Models.Database;
|
|||||||
[Index(nameof(OAuthProviderId), IsUnique = true)]
|
[Index(nameof(OAuthProviderId), IsUnique = true)]
|
||||||
public class User : BaseEntity<Guid>
|
public class User : BaseEntity<Guid>
|
||||||
{
|
{
|
||||||
public string Username { get; set; }
|
public required string Username { get; set; }
|
||||||
public string Email { get; set; }
|
public required string Email { get; set; }
|
||||||
public string OAuthProviderId { get; set; }
|
public required string OAuthProviderId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
public bool Disabled { get; set; }
|
public bool Disabled { get; set; }
|
||||||
|
public int AvailableInvites { get; set; } = 0;
|
||||||
/// <summary>
|
|
||||||
/// The user that generated an invite used by this user.
|
// Navigation properties
|
||||||
/// </summary>
|
public Guid? InviterId { get; set; }
|
||||||
public User? Inviter { get; set; }
|
public User? Inviter { get; set; }
|
||||||
|
public ICollection<User> InvitedUsers { get; set; } = new List<User>();
|
||||||
}
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using FictionArchive.Service.Shared.Services.EventBus;
|
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.Models.IntegrationEvents;
|
|
||||||
|
|
||||||
public class AuthUserAddedEvent : IIntegrationEvent
|
|
||||||
{
|
|
||||||
public string OAuthProviderId { get; set; }
|
|
||||||
|
|
||||||
public string InviterOAuthProviderId { get; set; }
|
|
||||||
|
|
||||||
// The email of the user that created the event
|
|
||||||
public string EventUserEmail { get; set; }
|
|
||||||
|
|
||||||
// The username of the user that created the event
|
|
||||||
public string EventUserUsername { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FictionArchive.Service.Shared.Services.EventBus;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Models.IntegrationEvents;
|
||||||
|
|
||||||
|
public class UserInvitedEvent : IIntegrationEvent
|
||||||
|
{
|
||||||
|
// Invited user info
|
||||||
|
public Guid InvitedUserId { get; set; }
|
||||||
|
public required string InvitedUsername { get; set; }
|
||||||
|
public required string InvitedEmail { get; set; }
|
||||||
|
public required string InvitedOAuthProviderId { get; set; }
|
||||||
|
|
||||||
|
// Inviter info
|
||||||
|
public Guid InviterId { get; set; }
|
||||||
|
public required string InviterUsername { get; set; }
|
||||||
|
public required string InviterOAuthProviderId { get; set; }
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using FictionArchive.Common.Extensions;
|
||||||
|
using FictionArchive.Service.Shared;
|
||||||
using FictionArchive.Service.Shared.Extensions;
|
using FictionArchive.Service.Shared.Extensions;
|
||||||
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
|
||||||
using FictionArchive.Service.UserService.GraphQL;
|
using FictionArchive.Service.UserService.GraphQL;
|
||||||
using FictionArchive.Service.UserService.Models.IntegrationEvents;
|
|
||||||
using FictionArchive.Service.UserService.Services;
|
using FictionArchive.Service.UserService.Services;
|
||||||
using FictionArchive.Service.UserService.Services.EventHandlers;
|
using FictionArchive.Service.UserService.Services.AuthenticationClient;
|
||||||
|
using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService;
|
namespace FictionArchive.Service.UserService;
|
||||||
|
|
||||||
@@ -11,40 +14,72 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
var isSchemaExport = SchemaExportDetector.IsSchemaExportMode(args);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
builder.AddLocalAppsettings();
|
||||||
|
|
||||||
#region Event Bus
|
#region Event Bus
|
||||||
|
|
||||||
builder.Services.AddRabbitMQ(opt =>
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
builder.Services.AddRabbitMQ(opt =>
|
||||||
})
|
{
|
||||||
.Subscribe<AuthUserAddedEvent, AuthUserAddedEventHandler>();
|
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region GraphQL
|
#region GraphQL
|
||||||
|
|
||||||
builder.Services.AddDefaultGraphQl<Query, Mutation>();
|
builder.Services.AddDefaultGraphQl<Query, Mutation>()
|
||||||
|
.AddAuthorization();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
builder.Services.RegisterDbContext<UserServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
|
#region Authentik Client
|
||||||
|
|
||||||
|
builder.Services.Configure<AuthentikConfiguration>(
|
||||||
|
builder.Configuration.GetSection("Authentik"));
|
||||||
|
|
||||||
|
var authentikConfig = builder.Configuration.GetSection("Authentik").Get<AuthentikConfiguration>();
|
||||||
|
builder.Services.AddHttpClient<IAuthenticationServiceClient, AuthentikClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(authentikConfig?.BaseUrl ?? "https://localhost");
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", authentikConfig?.ApiToken ?? "");
|
||||||
|
})
|
||||||
|
.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
builder.Services.RegisterDbContext<UserServiceDbContext>(
|
||||||
|
builder.Configuration.GetConnectionString("DefaultConnection"),
|
||||||
|
skipInfrastructure: isSchemaExport);
|
||||||
builder.Services.AddTransient<UserManagementService>();
|
builder.Services.AddTransient<UserManagementService>();
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
builder.Services.AddOidcAuthentication(builder.Configuration);
|
||||||
|
builder.Services.AddFictionArchiveAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database
|
// Update database (skip in schema export mode)
|
||||||
using (var scope = app.Services.CreateScope())
|
if (!isSchemaExport)
|
||||||
{
|
{
|
||||||
|
using var scope = app.Services.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<UserServiceDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<UserServiceDbContext>();
|
||||||
dbContext.UpdateDatabase();
|
dbContext.UpdateDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGraphQL();
|
app.MapGraphQL();
|
||||||
|
|
||||||
app.MapHealthChecks("/healthz");
|
app.MapHealthChecks("/healthz");
|
||||||
|
|
||||||
app.RunWithGraphQLCommands(args);
|
app.RunWithGraphQLCommands(args);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
|
public class AuthentikAddUserRequest
|
||||||
|
{
|
||||||
|
[JsonProperty("username")]
|
||||||
|
public required string Username { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("name")]
|
||||||
|
public required string DisplayName { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("email")]
|
||||||
|
public required string Email { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("is_active")]
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
[JsonProperty("type")]
|
||||||
|
public string Type { get; } = "external";
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
|
public class AuthentikClient : IAuthenticationServiceClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<AuthentikClient> _logger;
|
||||||
|
private readonly AuthentikConfiguration _configuration;
|
||||||
|
|
||||||
|
public AuthentikClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<AuthentikClient> logger,
|
||||||
|
IOptions<AuthentikConfiguration> configuration)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
_configuration = configuration.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthentikUserResponse?> CreateUserAsync(string username, string email, string displayName)
|
||||||
|
{
|
||||||
|
var request = new AuthentikAddUserRequest
|
||||||
|
{
|
||||||
|
Username = username,
|
||||||
|
Email = email,
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(request);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.PostAsync("/api/v3/core/users/", content);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogError(
|
||||||
|
"Failed to create user in Authentik. Status: {StatusCode}, Error: {Error}",
|
||||||
|
response.StatusCode, errorContent);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseJson = await response.Content.ReadAsStringAsync();
|
||||||
|
var userResponse = JsonConvert.DeserializeObject<AuthentikUserResponse>(responseJson);
|
||||||
|
_logger.LogInformation("Successfully created user {Username} in Authentik with pk {Pk}",
|
||||||
|
username, userResponse?.Pk);
|
||||||
|
|
||||||
|
return userResponse;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Exception while creating user {Username} in Authentik", username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendRecoveryEmailAsync(int authentikUserId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync(
|
||||||
|
$"/api/v3/core/users/{authentikUserId}/recovery_email/?email_stage={_configuration.EmailStageId}",
|
||||||
|
null);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogError(
|
||||||
|
"Failed to send recovery email for user {UserId}. Status: {StatusCode}, Error: {Error}",
|
||||||
|
authentikUserId, response.StatusCode, errorContent);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully sent recovery email to Authentik user {UserId}", authentikUserId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Exception while sending recovery email to Authentik user {UserId}", authentikUserId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
|
public class AuthentikConfiguration
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
public string ApiToken { get; set; } = string.Empty;
|
||||||
|
public string EmailStageId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
|
public class AuthentikUserResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("pk")]
|
||||||
|
public int Pk { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("username")]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("email")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonProperty("is_active")]
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("is_superuser")]
|
||||||
|
public bool IsSuperuser { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("uid")]
|
||||||
|
public string Uid { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
|
||||||
|
|
||||||
|
namespace FictionArchive.Service.UserService.Services.AuthenticationClient;
|
||||||
|
|
||||||
|
public interface IAuthenticationServiceClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new user in the authentication provider.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username for the new user</param>
|
||||||
|
/// <param name="email">The email address for the new user</param>
|
||||||
|
/// <param name="displayName">The display name for the new user</param>
|
||||||
|
/// <returns>The created user response, or null if creation failed</returns>
|
||||||
|
Task<AuthentikUserResponse?> CreateUserAsync(string username, string email, string displayName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a password recovery email to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authentikUserId">The Authentik user ID (pk)</param>
|
||||||
|
/// <returns>True if the email was sent successfully, false otherwise</returns>
|
||||||
|
Task<bool> SendRecoveryEmailAsync(int authentikUserId);
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using FictionArchive.Service.Shared.Services.EventBus;
|
|
||||||
using FictionArchive.Service.UserService.Models.IntegrationEvents;
|
|
||||||
using FictionArchive.Service.UserService.Models.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore; // Add this line to include the UserModel
|
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.Services.EventHandlers;
|
|
||||||
|
|
||||||
public class AuthUserAddedEventHandler : IIntegrationEventHandler<AuthUserAddedEvent>
|
|
||||||
{
|
|
||||||
private readonly UserManagementService _userManagementService;
|
|
||||||
private readonly ILogger<AuthUserAddedEventHandler> _logger;
|
|
||||||
|
|
||||||
public AuthUserAddedEventHandler(UserServiceDbContext dbContext, ILogger<AuthUserAddedEventHandler> logger, UserManagementService userManagementService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_userManagementService = userManagementService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Handle(AuthUserAddedEvent @event)
|
|
||||||
{
|
|
||||||
await _userManagementService.RegisterUser(@event.EventUserUsername, @event.EventUserEmail, @event.OAuthProviderId, @event.InviterOAuthProviderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using FictionArchive.Service.Shared.Services.EventBus;
|
||||||
using FictionArchive.Service.UserService.Models.Database;
|
using FictionArchive.Service.UserService.Models.Database;
|
||||||
|
using FictionArchive.Service.UserService.Models.IntegrationEvents;
|
||||||
|
using FictionArchive.Service.UserService.Services.AuthenticationClient;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace FictionArchive.Service.UserService.Services;
|
namespace FictionArchive.Service.UserService.Services;
|
||||||
@@ -7,39 +10,142 @@ public class UserManagementService
|
|||||||
{
|
{
|
||||||
private readonly ILogger<UserManagementService> _logger;
|
private readonly ILogger<UserManagementService> _logger;
|
||||||
private readonly UserServiceDbContext _dbContext;
|
private readonly UserServiceDbContext _dbContext;
|
||||||
|
private readonly IAuthenticationServiceClient _authClient;
|
||||||
|
private readonly IEventBus _eventBus;
|
||||||
|
|
||||||
public UserManagementService(UserServiceDbContext dbContext, ILogger<UserManagementService> logger)
|
public UserManagementService(
|
||||||
|
UserServiceDbContext dbContext,
|
||||||
|
ILogger<UserManagementService> logger,
|
||||||
|
IAuthenticationServiceClient authClient,
|
||||||
|
IEventBus eventBus)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_authClient = authClient;
|
||||||
|
_eventBus = eventBus;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<User> RegisterUser(string username, string email, string oAuthProviderId,
|
/// <summary>
|
||||||
string? inviterOAuthProviderId)
|
/// Invites a new user by creating them in Authentik, saving to the database, and sending a recovery email.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inviter">The user sending the invite</param>
|
||||||
|
/// <param name="email">Email address of the invitee</param>
|
||||||
|
/// <param name="username">Username for the invitee</param>
|
||||||
|
/// <returns>The created user, or null if the invite failed</returns>
|
||||||
|
public async Task<User?> InviteUserAsync(User inviter, string email, string username)
|
||||||
{
|
{
|
||||||
var newUser = new User();
|
// Check if inviter has available invites
|
||||||
User? inviter =
|
if (inviter.AvailableInvites <= 0)
|
||||||
await _dbContext.Users.FirstOrDefaultAsync(user => user.OAuthProviderId == inviterOAuthProviderId);
|
|
||||||
if (inviter == null && inviterOAuthProviderId != null)
|
|
||||||
{
|
{
|
||||||
_logger.LogCritical(
|
_logger.LogWarning("User {InviterId} has no available invites", inviter.Id);
|
||||||
"A user with OAuthProviderId {OAuthProviderId} was marked as having inviter with OAuthProviderId {inviterOAuthProviderId}, but no user was found with that value.",
|
return null;
|
||||||
inviterOAuthProviderId, inviterOAuthProviderId);
|
|
||||||
newUser.Disabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newUser.Username = username;
|
// Check if email is already in use
|
||||||
newUser.Email = email;
|
var existingUser = await _dbContext.Users
|
||||||
newUser.OAuthProviderId = oAuthProviderId;
|
.AsQueryable()
|
||||||
|
.FirstOrDefaultAsync(u => u.Email == email);
|
||||||
|
|
||||||
|
if (existingUser != null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Email {Email} is already in use", email);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is already in use
|
||||||
|
var existingUsername = await _dbContext.Users
|
||||||
|
.AsQueryable()
|
||||||
|
.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
|
|
||||||
|
if (existingUsername != null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Username {Username} is already in use", username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in Authentik
|
||||||
|
var authentikUser = await _authClient.CreateUserAsync(username, email, username);
|
||||||
|
if (authentikUser == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to create user {Username} in Authentik", username);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send recovery email via Authentik
|
||||||
|
var emailSent = await _authClient.SendRecoveryEmailAsync(authentikUser.Pk);
|
||||||
|
if (!emailSent)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"User {Username} was created in Authentik but recovery email failed to send. Authentik pk: {Pk}",
|
||||||
|
username, authentikUser.Pk);
|
||||||
|
// Continue anyway - the user is created, admin can resend email manually
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in local database
|
||||||
|
var newUser = new User
|
||||||
|
{
|
||||||
|
Username = username,
|
||||||
|
Email = email,
|
||||||
|
OAuthProviderId = authentikUser.Uid,
|
||||||
|
Disabled = false,
|
||||||
|
AvailableInvites = 0,
|
||||||
|
InviterId = inviter.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.Users.Add(newUser);
|
||||||
|
|
||||||
|
// Decrement inviter's available invites
|
||||||
|
inviter.AvailableInvites--;
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _eventBus.Publish(new UserInvitedEvent
|
||||||
|
{
|
||||||
|
InvitedUserId = newUser.Id,
|
||||||
|
InvitedUsername = newUser.Username,
|
||||||
|
InvitedEmail = newUser.Email,
|
||||||
|
InvitedOAuthProviderId = newUser.OAuthProviderId,
|
||||||
|
InviterId = inviter.Id,
|
||||||
|
InviterUsername = inviter.Username,
|
||||||
|
InviterOAuthProviderId = inviter.OAuthProviderId
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"User {Username} was successfully invited by {InviterId}. New user id: {NewUserId}",
|
||||||
|
username, inviter.Id, newUser.Id);
|
||||||
|
|
||||||
_dbContext.Users.Add(newUser); // Add the new user to the DbContext
|
|
||||||
await _dbContext.SaveChangesAsync(); // Save changes to the database
|
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a user by their OAuth provider ID (Authentik UID).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<User?> GetUserByOAuthProviderIdAsync(string oAuthProviderId)
|
||||||
|
{
|
||||||
|
return await _dbContext.Users
|
||||||
|
.AsQueryable()
|
||||||
|
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all users as a queryable for GraphQL.
|
||||||
|
/// </summary>
|
||||||
public IQueryable<User> GetUsers()
|
public IQueryable<User> GetUsers()
|
||||||
{
|
{
|
||||||
return _dbContext.Users.AsQueryable();
|
return _dbContext.Users.AsQueryable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all users invited by a specific user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inviterId">The ID of the user who sent the invites</param>
|
||||||
|
/// <returns>List of users invited by the specified user</returns>
|
||||||
|
public async Task<List<User>> GetInvitedByUserAsync(Guid inviterId)
|
||||||
|
{
|
||||||
|
return await _dbContext.Users
|
||||||
|
.AsQueryable()
|
||||||
|
.Where(u => u.InviterId == inviterId)
|
||||||
|
.OrderByDescending(u => u.CreatedTime)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace FictionArchive.Service.UserService.Services;
|
|||||||
public class UserServiceDbContext : FictionArchiveDbContext
|
public class UserServiceDbContext : FictionArchiveDbContext
|
||||||
{
|
{
|
||||||
public DbSet<User> Users { get; set; }
|
public DbSet<User> Users { get; set; }
|
||||||
|
|
||||||
public UserServiceDbContext(DbContextOptions options, ILogger<UserServiceDbContext> logger) : base(options, logger)
|
public UserServiceDbContext(DbContextOptions options, ILogger<UserServiceDbContext> logger) : base(options, logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,20 @@
|
|||||||
"ConnectionString": "amqp://localhost",
|
"ConnectionString": "amqp://localhost",
|
||||||
"ClientIdentifier": "UserService"
|
"ClientIdentifier": "UserService"
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"Authentik": {
|
||||||
|
"BaseUrl": "https://auth.orfl.xyz",
|
||||||
|
"ApiToken": "REPLACE_ME",
|
||||||
|
"EmailStageId": "10df0c18-8802-4ec7-852e-3cdd355514d3"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"OIDC": {
|
||||||
|
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
|
||||||
|
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true,
|
||||||
|
"ValidateLifetime": true,
|
||||||
|
"ValidateIssuerSigningKey": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
#
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}"
|
||||||
@@ -14,12 +15,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Sche
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService", "FictionArchive.Service.UserService\FictionArchive.Service.UserService.csproj", "{EE4D4795-2F79-4614-886D-AF8DA77120AC}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService", "FictionArchive.Service.UserService\FictionArchive.Service.UserService.csproj", "{EE4D4795-2F79-4614-886D-AF8DA77120AC}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.AuthenticationService", "FictionArchive.Service.AuthenticationService\FictionArchive.Service.AuthenticationService.csproj", "{70C4AE82-B01E-421D-B590-C0F47E63CD0C}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}"
|
||||||
EndProject
|
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}"
|
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
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService.Tests", "FictionArchive.Service.UserService.Tests\FictionArchive.Service.UserService.Tests.csproj", "{10C38C89-983D-4544-8911-F03099F66AB8}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -54,10 +55,6 @@ Global
|
|||||||
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{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.ActiveCfg = Debug|Any CPU
|
||||||
{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
@@ -66,5 +63,9 @@ Global
|
|||||||
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.Build.0 = Release|Any CPU
|
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -4,25 +4,31 @@ services:
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
networks:
|
||||||
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- /srv/docker_volumes/fictionarchive/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 4321:5432
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3-management-alpine
|
image: rabbitmq:3-management-alpine
|
||||||
|
networks:
|
||||||
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
|
||||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- /srv/docker_volumes/fictionarchive/rabbitmq:/var/lib/rabbitmq
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
|
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -30,48 +36,48 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
vpn:
|
||||||
|
image: dperson/openvpn-client # or gluetun, wireguard, etc.
|
||||||
|
networks:
|
||||||
|
fictionarchive:
|
||||||
|
aliases:
|
||||||
|
- novel-service
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun
|
||||||
|
volumes:
|
||||||
|
- /srv/docker_volumes/korean_vpn:/vpn
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Backend Services
|
# Backend Services
|
||||||
# ===========================================
|
# ===========================================
|
||||||
novel-service:
|
novel-service:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-novel-service:latest
|
||||||
context: .
|
|
||||||
dockerfile: FictionArchive.Service.NovelService/Dockerfile
|
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
|
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
|
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
||||||
Novelpia__Username: ${NOVELPIA_USERNAME}
|
Novelpia__Username: ${NOVELPIA_USERNAME}
|
||||||
Novelpia__Password: ${NOVELPIA_PASSWORD}
|
Novelpia__Password: ${NOVELPIA_PASSWORD}
|
||||||
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
|
UpdateService__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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
vpn:
|
||||||
|
condition: service_started
|
||||||
|
network_mode: "service:vpn"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
scheduler-service:
|
scheduler-service:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest
|
||||||
context: .
|
networks:
|
||||||
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
|
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
|
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -80,12 +86,15 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
user-service:
|
user-service:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-user-service:latest
|
||||||
context: .
|
networks:
|
||||||
dockerfile: FictionArchive.Service.UserService/Dockerfile
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
|
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
|
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
||||||
|
Authentik__BaseUrl: https://auth.orfl.xyz
|
||||||
|
Authentik__ApiToken: ${AUTHENTIK_API_TOKEN}
|
||||||
|
Authentik__EmailStageId: 10df0c18-8802-4ec7-852e-3cdd355514d3
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -93,33 +102,21 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
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:
|
file-service:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-file-service:latest
|
||||||
context: .
|
networks:
|
||||||
dockerfile: FictionArchive.Service.FileService/Dockerfile
|
- web
|
||||||
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
RabbitMQ__ConnectionString: 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__AccessKey: ${S3_ACCESS_KEY}
|
||||||
S3__SecretKey: ${S3_SECRET_KEY}
|
S3__SecretKey: ${S3_SECRET_KEY}
|
||||||
Proxy__BaseUrl: https://files.orfl.xyz/api
|
ProxyConfiguration__BaseUrl: https://files.fictionarchive.orfl.xyz/api
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)"
|
- "traefik.http.routers.file-service.rule=Host(`files.fictionarchive.orfl.xyz`)"
|
||||||
- "traefik.http.routers.file-service.entrypoints=websecure"
|
- "traefik.http.routers.file-service.tls=true"
|
||||||
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.file-service.tls.certresolver=lets-encrypt"
|
||||||
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
|
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
@@ -130,48 +127,41 @@ services:
|
|||||||
# API Gateway
|
# API Gateway
|
||||||
# ===========================================
|
# ===========================================
|
||||||
api-gateway:
|
api-gateway:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-api:latest
|
||||||
context: .
|
networks:
|
||||||
dockerfile: FictionArchive.API/Dockerfile
|
- web
|
||||||
|
- fictionarchive
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
|
Cors__AllowedOrigin: https://fictionarchive.orfl.xyz
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
|
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
|
||||||
- "traefik.http.routers.api-gateway.entrypoints=websecure"
|
- "traefik.http.routers.api-gateway.tls=true"
|
||||||
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.api-gateway.tls.certresolver=lets-encrypt"
|
||||||
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
|
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- novel-service
|
- novel-service
|
||||||
- translation-service
|
|
||||||
- scheduler-service
|
- scheduler-service
|
||||||
- user-service
|
|
||||||
- authentication-service
|
|
||||||
- file-service
|
- file-service
|
||||||
|
- user-service
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Frontend
|
# Frontend
|
||||||
# ===========================================
|
# ===========================================
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: git.orfl.xyz/conco/fictionarchive-frontend:latest
|
||||||
context: ./fictionarchive-web
|
networks:
|
||||||
dockerfile: Dockerfile
|
- web
|
||||||
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- traefik.http.routers.fafrontend.rule=Host(`fictionarchive.orfl.xyz`)
|
||||||
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)"
|
- traefik.http.routers.fafrontend.tls=true
|
||||||
- "traefik.http.routers.frontend.entrypoints=websecure"
|
- traefik.http.routers.fafrontend.tls.certresolver=lets-encrypt
|
||||||
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
- traefik.http.services.fafrontend.loadbalancer.server.port=80
|
||||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
- traefik.enable=true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
networks:
|
||||||
postgres_data:
|
web:
|
||||||
rabbitmq_data:
|
external: yes
|
||||||
letsencrypt:
|
fictionarchive:
|
||||||
|
|||||||
38
fictionarchive-web-astro/.dockerignore
Normal file
38
fictionarchive-web-astro/.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*.test.*
|
||||||
|
*.spec.*
|
||||||
|
__tests__
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
12
fictionarchive-web-astro/.env.example
Normal file
12
fictionarchive-web-astro/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# GraphQL endpoint
|
||||||
|
PUBLIC_GRAPHQL_URI=https://localhost:7063/graphql/
|
||||||
|
|
||||||
|
# OIDC Configuration
|
||||||
|
PUBLIC_OIDC_AUTHORITY=https://auth.orfl.xyz/application/o/fiction-archive/
|
||||||
|
PUBLIC_OIDC_CLIENT_ID=ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh
|
||||||
|
PUBLIC_OIDC_REDIRECT_URI=http://localhost:4321/
|
||||||
|
PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:4321/
|
||||||
|
PUBLIC_OIDC_SCOPE=openid profile email
|
||||||
|
|
||||||
|
# Optional: Token for GraphQL codegen (for authenticated schema introspection)
|
||||||
|
# CODEGEN_TOKEN=your_token_here
|
||||||
24
fictionarchive-web-astro/.gitignore
vendored
Normal file
24
fictionarchive-web-astro/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
4
fictionarchive-web-astro/.vscode/extensions.json
vendored
Normal file
4
fictionarchive-web-astro/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
fictionarchive-web-astro/.vscode/launch.json
vendored
Normal file
11
fictionarchive-web-astro/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
fictionarchive-web-astro/Dockerfile
Normal file
45
fictionarchive-web-astro/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build arguments for environment variables
|
||||||
|
ARG PUBLIC_GRAPHQL_URI
|
||||||
|
ARG PUBLIC_OIDC_AUTHORITY
|
||||||
|
ARG PUBLIC_OIDC_CLIENT_ID
|
||||||
|
ARG PUBLIC_OIDC_REDIRECT_URI
|
||||||
|
ARG PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI
|
||||||
|
ARG PUBLIC_OIDC_SCOPE
|
||||||
|
|
||||||
|
# Set environment variables for build
|
||||||
|
ENV PUBLIC_GRAPHQL_URI=$PUBLIC_GRAPHQL_URI
|
||||||
|
ENV PUBLIC_OIDC_AUTHORITY=$PUBLIC_OIDC_AUTHORITY
|
||||||
|
ENV PUBLIC_OIDC_CLIENT_ID=$PUBLIC_OIDC_CLIENT_ID
|
||||||
|
ENV PUBLIC_OIDC_REDIRECT_URI=$PUBLIC_OIDC_REDIRECT_URI
|
||||||
|
ENV PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI=$PUBLIC_OIDC_POST_LOGOUT_REDIRECT_URI
|
||||||
|
ENV PUBLIC_OIDC_SCOPE=$PUBLIC_OIDC_SCOPE
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production runtime
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built output and production dependencies
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
|
||||||
|
# Runtime configuration
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start the Node.js server
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
43
fictionarchive-web-astro/README.md
Normal file
43
fictionarchive-web-astro/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
17
fictionarchive-web-astro/astro.config.mjs
Normal file
17
fictionarchive-web-astro/astro.config.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import svelte from '@astrojs/svelte';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server', // SSR mode - use prerender = true for static pages
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone',
|
||||||
|
}),
|
||||||
|
integrations: [
|
||||||
|
svelte(),
|
||||||
|
],
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
});
|
||||||
28
fictionarchive-web-astro/codegen.ts
Normal file
28
fictionarchive-web-astro/codegen.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { CodegenConfig } from '@graphql-codegen/cli';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const schema = process.env.PUBLIC_GRAPHQL_URI ?? 'https://localhost:7063/graphql/';
|
||||||
|
const authToken = process.env.CODEGEN_TOKEN;
|
||||||
|
|
||||||
|
const config: CodegenConfig = {
|
||||||
|
schema: {
|
||||||
|
[schema]: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : {},
|
||||||
|
},
|
||||||
|
documents: 'src/**/*.graphql',
|
||||||
|
generates: {
|
||||||
|
'src/lib/graphql/__generated__/graphql.ts': {
|
||||||
|
plugins: ['typescript', 'typescript-operations', 'typed-document-node'],
|
||||||
|
config: {
|
||||||
|
avoidOptionals: { field: true },
|
||||||
|
enumsAsConst: true,
|
||||||
|
skipTypename: true,
|
||||||
|
useTypeImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
16
fictionarchive-web-astro/components.json
Normal file
16
fictionarchive-web-astro/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src\\styles\\global.css",
|
||||||
|
"baseColor": "gray"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
35
fictionarchive-web-astro/eslint.config.js
Normal file
35
fictionarchive-web-astro/eslint.config.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import astro from 'eslint-plugin-astro';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
...astro.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Disabled because we sanitize HTML with DOMPurify before rendering
|
||||||
|
'svelte/no-at-html-tags': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['node_modules/', 'dist/', '.astro/', 'src/lib/graphql/__generated__/']
|
||||||
|
}
|
||||||
|
);
|
||||||
12789
fictionarchive-web-astro/package-lock.json
generated
Normal file
12789
fictionarchive-web-astro/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user