diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5907d98 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Python local virtualenvs — these are huge (torch/cuda wheels) +# and never needed in Docker build context +**/.venv/ +**/venv/ +**/test_env/ +**/__pycache__/ +**/*.pyc +**/.pytest_cache/ + +# Gradle build artifacts (each service builds its own jar inside the image) +**/build/ +**/.gradle/ + +# IDE / OS +.idea/ +.vscode/ +.DS_Store + +# Git / docs (most services don't need) +.git/ +*.md +docs/ + +# Local +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aece74a --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +SONARQUBE_TOKEN=wygenerowany_w_sonarqube_user_token + +JWT_SECRET_KEY=moje_bardzo_dlugie_i_bezpieczne_haslo_z_duza_iloscia_znakow + +DB_USER=jakis_uzytkownik_bazy_danych +DB_PASSWORD=jakies_haslo_do_bazy_danych +DB_NAME=nazwa_bazy_danych + +WATERMARK_APP_KEY=twoj_sekretny_klucz_do_watermarkingu_min_16_znakow + +CONFIG_SERVER_USER=login_do_config_server +CONFIG_SERVER_PASSWORD=password_do_config_server + +EUREKA_USER=login_do_eureka +EUREKA_PASSWORD=password_do_eureka \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2061dcf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +gradlew text eol=lf +*.sh text eol=lf +gradlew.bat text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1bb5018 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Run tests + run: ./gradlew --no-daemon test + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + **/build/reports/tests/test/** + **/build/test-results/test/** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..742b3c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,270 @@ +.env + +docs/superpowers/ + +# IDE-local project files +.idea/ +*.iml + +# Python +**/.venv/ +**/venv/ +**/test_env/ +**/__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +htmlcov/ + +# LaTeX build artifacts +*.aux +*.out +*.toc +*.synctex.gz + +# Created by https://www.toptal.com/developers/gitignore/api/linux,windows,java,gradle,intellij,visualstudiocode,netbeans +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,windows,java,gradle,intellij,visualstudiocode,netbeans + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### NetBeans ### +**/nbproject/private/ +**/nbproject/Makefile-*.mk +**/nbproject/Package-*.bash +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/linux,windows,java,gradle,intellij,visualstudiocode,netbeans +/.idea/dataSources.xml +/.idea/sqldialects.xml + +# Playwright MCP scratch output (screenshots/snapshots for docs are saved under dokumentacja/) +.playwright-mcp/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 6129c37..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -/pl-java2026.iml -/vcs.xml -/modules.xml -/misc.xml -/copilot.data.migration.edit.xml -/copilot.data.migration.ask.xml -/copilot.data.migration.agent.xml diff --git a/README.md b/README.md index 6114919..b7bf5c4 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,270 @@ -# PŁ: Zaawansowane Zagadnienia Programowania w Javie - Edycja 2026 - -## Zaliczenie - -- Projekt grupowy: od 3 do 6 osób (brak odstępstw) -- Tematyka realizowanego projektu: dowolna -- Elementy, które powinny znaleźć się końcowym projekcie i podlegają ocenie: - - Współpraca z repozytorium Git (dowolnie wybrany darmowy hosting według upodobań, na przykład: Github, Gitlab lub - Bitbucket) - - Aktywność w realizacji projektu: - - *pull requesty* wraz z *code review* wewnątrz zespołu - - *githubowy pulse* lub inne narzędzie pokazujące ciągłość pracy podczas semestru - - podział zadań przy implementacji funkcjonalności projektu poprzez *board projektowy* (Jira, Trello,...) - - - Praktyczna implementacja i wdrożenie rozwiązań, tematów, narzędzi prezentowanych podczas zajęć (nie muszą być - wszystkie, 60-80% jest ok) - - [ ] Aplikacja oparta o **Spring (Boot) Framework** - - [ ] Integracja z zewnętrznym zasobem po **REST** z wykorzystaniem publicznego API ( - np.: https://github.com/public-apis/public-apis) wraz z wykorzystaniem (przetworzeniem) otrzymanych danych - - [ ] Zaprojektowanie własnego API z wykorzystaniem biblioteki **OpenAPI** wraz z drugą aplikacją, która - konsumuje udostępnione API. Klient będzie wykonywał zapytania HTTP do serwisu i analizował otrzymane dane. - - [ ] Architektura mikroserwisowa z wykorzystaniem **Spring Cloud Eureka**, umożliwiająca rejestrację i - wykrywanie usług. Aplikacja będzie składała się z minimum dwóch mikroserwisów, które będą się wzajemnie - komunikowały przy użyciu Spring Eureka jako serwera rejestracji usług. - - [ ] Wykorzystanie **Spring Cloud Config Server**, który centralizuje zarządzanie konfiguracją dla wielu - mikroserwisów. Dzięki temu wszystkie serwisy w systemie mogą korzystać z jednej wspólnej konfiguracji, - przechowywanej w centralnym miejscu (np. w plikach YAML na oddzielnym repozytorium Git), a zmiany w - konfiguracji są natychmiastowo propagowane do aplikacji. - - [ ] Wykorzystanie **Keycloak** lub **Spring Authorization Server** (lub inny nieprezentowany podczas zajęć) - jako systemu zarządzającego autoryzacją i autentykacją użytkowników oraz/i serwisów. - - [ ] Aplikacja powinna być zaprojektowana w taki sposób, aby jej komponenty zostały odpowiednio przetestowane - przy użyciu różnych typów testów, np. **jednostkowych (unit tests)**, **integracyjnych (integration tests)** - oraz **testów BDD (Behavior Driven Development)**. Celem jest zapewnienie wysokiej jakości kodu, wykrywanie - błędów na wczesnym etapie oraz zapewnienie, że aplikacja działa zgodnie z oczekiwaniami użytkowników - końcowych. Użycie **Cucumber** – napisanie kilku testów z wykorzystaniem różnych konstrukcji **Gherkina** i - dodanie testów realizowanych przez **AssertJ** - - [ ] Statyczna analiza kodu – lokalne postawienie SonarQube, utworzenie projektu, dodanie skanera, analiza - wyników, dbanie o utrzymanie długu technicznego na akceptowalnym poziomie - - [ ] Aplikacja wykorzystująca ostatnie nowości w **JDK** (np. Local-Variable Type Inference, Text Blocks, - Sealed Classes, Pattern Matching dla instanceof, Virtual Threads, Records) - - [ ] Obsługa **LLM / AI w Javie** – skorzystanie z API do zadania przetwarzania tekstu lub np. - predykcja/klasyfikacja własnym modelem - - UI oraz UX nie mają znaczenia, podczas prezentacji można użyć narzędzi typu Swagger albo Postman - - Unikanie typowych aplikacji Create-Read-Update-Delete (CRUD) - -## Realizowane zagadnienia - -| Prowadzący | Temat | -|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| ŁCh | - Cucumber (BDD) + AssertJ
- Spring: Podstawowe zagadnienia, MVC, RestController, HttpClient, JPA
- LLM | -| MD | - Wprowadzenie + IntelliJ
- Testy Mutacyjne
- From Java to Go | -| MK | - Logi + Observability (JMX+Spring Atuators/Endpoints)
- Spring AOP
- Refleksja, Classloader, Annotation Processing | -| ZN | - JDK Updates (od wersji 9 do wersji 25)
- Mikroserwisy 101: praktyczne podstawy budowy systemu z wykorzystaniem REST i Spring Cloud Eureka
- Mikroserwisy 102 (+ Spring Security): zaawansowane tematy związane z wykorzystaniem narzędzi do zarządzania autoryzacją i autentykacją | - -## Ramowy plan zajęć - -| Lp | Data | Temat | Uwagi | -|----|-------|------------------------------------------------|--------------------------------------------------------------------------------------| -| 1 | 2.03 | Wprowadzenie + IntelliJ | [Materiały na zajęcia](https://github.com/zzpj/pl-java2026/tree/main/intro-intellij) | -| 2 | 9.03 | | | -| 3 | 16.03 | | | -| 4 | 23.03 | Przedstawienie pomysłu na projekt | | -| 5 | 30.03 | | | -| 6 | 13.04 | | | -| 7 | 20.04 | | | -| 8 | 4.05 | | | -| 9 | 11.05 | | | -| 10 | 18.05 | | | -| 11 | 25.05 | Sprawdzanie postępu realizacji projektu | | -| 12 | 1.06 | | | -| 13 | 8.06 | | | -| 14 | 15.06 | | | -| 15 | 22.06 | Finalne przedstawienie zrealizowanego projektu | | - -## Ankiety - -- [Ankieta Wejściowa: Oczekiwania](https://forms.gle/3VvpUs9ntmiUmmBB7 ) - wypełnij teraz, abyśmy mogli lepiej - dostosować zajęcia do Twoich potrzeb i oczekiwań! -- [Ankieta Wyjściowa: Ocena zajęć i wnioski](https://to-be-defined-abc) - wypełnij po zakończeniu zajęć, aby podzielić - się swoimi doświadczeniami i pomóc nam w przyszłości! - -## Youtube -- [Nagrania z zajęć 2026](https://to-be-defined-xyz) -- [Nagrania z zajęć 2025](https://www.youtube.com/playlist?list=PLIPonSYREXmIEPZtRujsooBwAPBSsxROs) -- [Nagrania z zajęć 2024](https://www.youtube.com/playlist?list=PLIPonSYREXmIQN-S_9i0mrTyAK5u0gA8t) -- [Nagrania z zajęć 2023](https://www.youtube.com/playlist?list=PLIPonSYREXmKVCktLuj_Duve4tQNEA3p1) -- [Nagrania z zajęć 2022](https://www.youtube.com/playlist?list=PLIPonSYREXmLkAgnHHFsoRL3bRZwOwrMi) -- [Nagrania z zajęć 2021](https://www.youtube.com/playlist?list=PLIPonSYREXmICvsCV_osPqDlTg3MDPDPg) +# StegoCloud + +StegoCloud is a microservice system for embedding, detecting and extracting encrypted watermarks in PNG images. It combines a Java/Spring Boot backend, a Python/FastAPI watermarking engine and a Svelte frontend into one Docker Compose environment. + +The project focuses on a practical business flow: users have subscription plans, operations consume tokens, and watermarking requests are authorized, validated and billed before the image is processed. + +## Highlights + +- Microservice architecture with Spring Cloud Config and Eureka service discovery. +- JWT-based authentication and role-aware access to watermark operations. +- Subscription and token economy with reservation, consumption and release flows. +- Encrypted watermark payloads using AES-GCM before embedding. +- Python/FastAPI service for PNG watermarking, detection, extraction and visualization. +- Java 21 features, including records, sealed interfaces and pattern matching for switch. +- OpenAPI documentation for the public API surface. +- Automated tests covering Java services and the Python watermarking service. +- Docker Compose setup for local end-to-end runs. + +## Tech Stack + +| Area | Technologies | +|---|---| +| Backend | Java 21, Spring Boot, Spring Security, Spring Cloud Config, Eureka, OpenFeign | +| Data | PostgreSQL, Flyway, JPA/Hibernate | +| Watermarking | Python, FastAPI, AES-GCM, Reed-Solomon ECC, invisible-watermark | +| AI | ONNX Runtime, MobileNetV2 image classification | +| Frontend | Svelte, Vite, nginx | +| Quality | JUnit 5, Cucumber, ArchUnit, Spring Cloud Contract, pytest, Checkstyle, Spotless, JaCoCo, SonarQube | +| Delivery | Docker, Docker Compose, GitHub Actions | + +## Modules + +| Module | Responsibility | +|---|---| +| `auth-server` | User registration, login, roles and JWT validation | +| `subscription-service` | Subscription plans, token balances, payments and token reservations | +| `config-server` | Central configuration for Spring services | +| `eureka-server` | Service discovery | +| `ai-service` | Image classification with MobileNetV2 via ONNX Runtime | +| `watermark-service-py` | PNG watermark embed, detect, extract, visualize and capacity checks | +| `gui` | User interface for authentication, subscriptions and watermark operations | +| `postgres-db` | Shared PostgreSQL instance for auth and subscription schemas | + +## Core Flow + +1. The user logs in through `auth-server` and receives a JWT. +2. The GUI sends a watermark request to `watermark-service-py`. +3. The watermark service validates the JWT and checks the user's subscription state. +4. Paid operations reserve tokens in `subscription-service`. +5. The watermark service processes the image and optionally calls `ai-service`. +6. On success the reservation is consumed; on failure it is released. + +## Business Rules + +System plans define which operations a user can run and how many tokens they receive. + +| Plan | Monthly tokens | Allowed operations | +|---|---:|---| +| `FREE` | 50 | `CAPACITY_CHECK`, `DETECT`, `EMBED_768` | +| `STANDARD` | 500 | `CAPACITY_CHECK`, `DETECT`, `EXTRACT`, `VISUALIZE`, `EMBED_768`, `EMBED_1024` | +| `PRO` | 2500 | All operations, including `AI_CLASSIFICATION` | + +| Operation | Cost | +|---|---:| +| `CAPACITY_CHECK` | 0 | +| `DETECT` | 1 | +| `EXTRACT` | 2 | +| `VISUALIZE` | 3 | +| `EMBED_768` | 5 | +| `EMBED_1024` | 8 | +| `AI_CLASSIFICATION` | 2 | + +Additional rules: + +- Upgrades are allowed from `FREE` to `STANDARD`, `FREE` to `PRO` and `STANDARD` to `PRO`. +- Downgrades and duplicate purchases of the active plan are blocked. +- Paid plans are valid for one month from purchase or upgrade. +- Expired plans return to `FREE` with a reset token balance. +- Regular users can extract only their own watermarks. +- Administrators can detect, extract and visualize watermarks for any image. + +## Watermarking Constraints + +- Embed accepts PNG images only. +- Minimum image size for embedding is `1024x1024`. +- Images up to Full HD pixel count (`1920 * 1080`) use the `EMBED_768` tier. +- Larger images use the `EMBED_1024` tier. +- The GUI accepts images up to 20 MB. +- The output should remain PNG and should not be recompressed, resized or screenshotted if the watermark must survive. + +## Quick Start + +Create a local environment file: + +```bash +cp .env.example .env +``` + +Start the full system: + +```bash +docker compose up -d --build +``` + +Open the application: + +```text +http://localhost:5173 +``` + +Stop the system: + +```bash +docker compose down +``` + +Remove containers and database volumes: + +```bash +docker compose down -v +``` + +## Demo Accounts + +| Login | Password | Role | Plan | +|---|---|---|---| +| `admin@gmail.com` | `admin` | `ADMIN` | `PRO` | +| `free@gmail.com` | `free` | `USER` | `FREE` | +| `standard@gmail.com` | `standard` | `USER` | `STANDARD` | +| `pro@gmail.com` | `pro` | `USER` | `PRO` | +| `lowbalance@gmail.com` | `lowbalance` | `USER` | `FREE`, low token balance | + +## Local URLs + +| Service | URL | +|---|---| +| GUI | http://localhost:5173 | +| Eureka | http://localhost:8761 | +| Config Server | http://localhost:8888/application/default | +| Auth Server | http://localhost:8081 | +| Subscription Service | http://localhost:8085 | +| Watermark Service | http://localhost:8082 | +| AI Service | http://localhost:8084 | +| SonarQube | http://localhost:9000 | +| PostgreSQL host port | `localhost:5433` | + +## API Documentation + +The consolidated OpenAPI specification is available in: + +- [`stegocloud-openapi.json`](stegocloud-openapi.json) +- [`api-docs.html`](api-docs.html) +- [`docs/api/`](docs/api/README.md) + +Because browsers block local file requests, serve the API viewer through a local HTTP server: + +```bash +python3 -m http.server 8000 +``` + +Then open: + +```text +http://localhost:8000/api-docs.html +``` + +Service-specific API docs: + +| Service | URL | +|---|---| +| Auth Server | http://localhost:8081/swagger-ui/index.html | +| Subscription Service | http://localhost:8085/swagger-ui/index.html | +| AI Service | http://localhost:8084/swagger-ui/index.html | +| Watermark Service | http://localhost:8082/docs | + +## Useful Endpoints + +Auth: + +```http +POST /auth/login +POST /auth/validate?token=... +``` + +Subscription: + +```http +GET /api/subscriptions/plans +GET /api/subscriptions/me +GET /api/subscriptions/me/tokens +POST /api/payments/mock/sessions +POST /api/payments/mock/sessions/{sessionId}/succeed +POST /api/payments/mock/sessions/{sessionId}/fail +POST /api/payments/mock/sessions/{sessionId}/cancel +POST /api/tokens/reservations +POST /api/tokens/reservations/{reservationId}/consume +POST /api/tokens/reservations/{reservationId}/release +``` + +Watermark: + +```http +POST /api/watermark/capacity +POST /api/watermark/embed +POST /api/watermark/detect +POST /api/watermark/extract +POST /api/watermark/visualize +GET /health +``` + +## Development + +Run Java tests: + +```bash +./gradlew test +``` + +Run Java formatting and static checks: + +```bash +./gradlew spotlessCheck checkstyleMain checkstyleTest +``` + +Run Python watermark-service tests: + +```bash +cd watermark-service-py +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +pytest -v +``` + +Run frontend checks: + +```bash +cd gui +npm install +npm run check +npm run build +``` + +## SonarQube + +Default local credentials: + +```text +login: admin +password: admin +``` + +After the first login, change the password, generate a user token and put it in `.env` as `SONARQUBE_TOKEN`. + +Run Java analysis: + +```bash +docker compose --profile tools run --rm sonar-scan +``` + +## Documentation + +- API reference: [`docs/api/`](docs/api/README.md) +- Project PDF documentation: [`docs/project/project-report.pdf`](docs/project/project-report.pdf) +- Project report sources, diagrams and screenshots: [`docs/project/`](docs/project/) +- Python watermark service details: [`watermark-service-py/README.md`](watermark-service-py/README.md) + +## Team + +251558, 251554, 251598, 251620, 251606, 247774 diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..362ca9c --- /dev/null +++ b/ai-service/Dockerfile @@ -0,0 +1,17 @@ +# Stage 1: Build the JAR +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /workspace +COPY . . +RUN chmod +x gradlew && ./gradlew :ai-service:bootJar --no-daemon + +# Stage 2: Final lightweight image - JRE only, no Python, no PyTorch. +# The ONNX model is checked into the repository to keep Docker builds reproducible. +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=builder /workspace/ai-service/build/libs/*.jar app.jar +COPY ai-service/src/test/resources/model/mobilenetv2.onnx /app/model/mobilenetv2.onnx +COPY ai-service/src/test/resources/model/synset.txt /app/model/synset.txt + +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/ai-service/build.gradle.kts b/ai-service/build.gradle.kts new file mode 100644 index 0000000..a7e7e8a --- /dev/null +++ b/ai-service/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + java + jacoco + id("org.springframework.boot") version "3.5.11" + id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "7.2.3.7755" + // Performance testing (criterion #3). Compile with :ai-service:gatlingClasses, + // run a load test against a live instance with :ai-service:gatlingRun. + id("io.gatling.gradle") version "3.13.5" +} + +group = "pl.zzpj" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("springdocVersion")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + // ONNX Runtime Java API — CPU only, no GPU + implementation("com.microsoft.onnxruntime:onnxruntime:1.21.1") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // BDD testing (criterion #1) — Cucumber + JUnit Platform. AssertJ ships with starter-test. + testImplementation(platform("io.cucumber:cucumber-bom:7.20.1")) + testImplementation("io.cucumber:cucumber-java") + testImplementation("io.cucumber:cucumber-spring") + testImplementation("io.cucumber:cucumber-junit-platform-engine") + testImplementation("org.junit.platform:junit-platform-suite") + + // Architecture testing (criterion #4) — ArchUnit. + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } +} + +tasks.named("sonar") { + dependsOn(tasks.jacocoTestReport) +} diff --git a/ai-service/src/gatling/java/simulations/ClassifySimulation.java b/ai-service/src/gatling/java/simulations/ClassifySimulation.java new file mode 100644 index 0000000..af4f4a2 --- /dev/null +++ b/ai-service/src/gatling/java/simulations/ClassifySimulation.java @@ -0,0 +1,71 @@ +package simulations; + +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; + +import static io.gatling.javaapi.core.CoreDsl.constantUsersPerSec; +import static io.gatling.javaapi.core.CoreDsl.rampUsersPerSec; +import static io.gatling.javaapi.core.CoreDsl.scenario; +import static io.gatling.javaapi.http.HttpDsl.http; + +/** + * Gatling load test for {@code POST /api/classify} (issue #19 criterion #3). + * + *

Posts multipart image uploads to the {@link pl.zzpj.ai_service.ClassificationController} + * under a ramped injection profile (~10 → 50 → 100 req/s). + * + *

How to run (requires a running ai-service instance): + *

+ *   ./gradlew :ai-service:gatlingRun
+ * 
+ * Override the target and auth token via system properties, e.g. + *
+ *   ./gradlew :ai-service:gatlingRun \
+ *       -DbaseUrl=http://localhost:8080 -Dtoken=<valid-jwt>
+ * 
+ * + *

Correlate the results with the service's Spring Boot Actuator metrics, e.g. + * {@code http.server.requests} (latency / throughput / status codes) and JVM metrics + * exposed at {@code /actuator/metrics} and {@code /actuator/prometheus}. + */ +public class ClassifySimulation extends Simulation { + + private static final String BASE_URL = + System.getProperty("baseUrl", "http://localhost:8080"); + private static final String TOKEN = + System.getProperty("token", "replace-with-a-valid-jwt"); + + private final HttpProtocolBuilder httpProtocol = http + .baseUrl(BASE_URL) + .acceptHeader("application/json") + .header("Authorization", "Bearer " + TOKEN) + .userAgentHeader("gatling-ai-service-loadtest"); + + private final ScenarioBuilder classify = scenario("Classify image") + .exec( + http("POST /api/classify") + .post("/api/classify") + .bodyPart( + io.gatling.javaapi.http.HttpDsl.RawFileBodyPart("file", "test-dog.jpg") + .fileName("test-dog.jpg") + .contentType("image/jpeg") + ).asMultipartForm() + .check(io.gatling.javaapi.http.HttpDsl.status().in(200, 401)) + ); + + { + setUp( + classify.injectOpen( + // ~10 req/s baseline + constantUsersPerSec(10).during(15), + // ramp 10 → 50 req/s + rampUsersPerSec(10).to(50).during(30), + // ramp 50 → 100 req/s peak + rampUsersPerSec(50).to(100).during(30), + // hold 100 req/s + constantUsersPerSec(100).during(30) + ) + ).protocols(httpProtocol); + } +} diff --git a/ai-service/src/gatling/resources/test-dog.jpg b/ai-service/src/gatling/resources/test-dog.jpg new file mode 100644 index 0000000..d0cec4a Binary files /dev/null and b/ai-service/src/gatling/resources/test-dog.jpg differ diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/AiServiceApplication.java b/ai-service/src/main/java/pl/zzpj/ai_service/AiServiceApplication.java new file mode 100644 index 0000000..6f3ac96 --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/AiServiceApplication.java @@ -0,0 +1,16 @@ +package pl.zzpj.ai_service; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableDiscoveryClient +@EnableFeignClients +public class AiServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AiServiceApplication.class, args); + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/CategoryMapper.java b/ai-service/src/main/java/pl/zzpj/ai_service/CategoryMapper.java new file mode 100644 index 0000000..8d9ac5c --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/CategoryMapper.java @@ -0,0 +1,382 @@ +package pl.zzpj.ai_service; + +import java.util.List; +import java.util.Map; + +/** + * Maps a raw ImageNet class label to one of a fixed set of broad categories. Matching is done by + * checking whether any keyword for a given category appears as a substring of the lower-cased + * label. Returns {@code "other"} when no keyword matches. + * + *

Categories are evaluated in the order declared below — the first category with a matching + * keyword wins. Order matters only if a label could match keywords from multiple categories; today + * none do, but the ordered list keeps future additions deterministic. + */ +public final class CategoryMapper { + + private CategoryMapper() {} + + private static final List>> KEYWORDS = + List.of( + Map.entry( + "dog", + List.of( + "dog", + "hound", + "terrier", + "retriever", + "bulldog", + "poodle", + "collie", + "spaniel", + "shepherd", + "husky", + "dalmatian", + "dachshund", + "labrador", + "chihuahua", + "pug", + "boxer", + "beagle", + "pinscher", + "mastiff", + "greyhound", + "samoyed", + "chow", + "schnauzer", + "corgi", + "shih", + "maltese", + "weimaraner")), + Map.entry( + "cat", + List.of( + "cat", + "tabby", + "kitten", + "persian", + "siamese", + "egyptian", + "lynx", + "cougar", + "cheetah", + "jaguar", + "leopard", + "lion", + "tiger")), + Map.entry( + "bird", + List.of( + "bird", + "jay", + "magpie", + "chickadee", + "finch", + "robin", + "hummingbird", + "eagle", + "hawk", + "vulture", + "kite", + "crow", + "parrot", + "macaw", + "cockatoo", + "peacock", + "quail", + "partridge", + "duck", + "goose", + "swan", + "penguin", + "albatross", + "pelican", + "flamingo", + "crane", + "rooster", + "hen", + "ostrich", + "toucan", + "pigeon", + "dove", + "sparrow", + "warbler", + "gallinule", + "coot", + "limpkin", + "bustard", + "hornbill", + "puffin", + "cormorant", + "frigatebird", + "gannet", + "skimmer", + "tern", + "sandpiper", + "dunlin", + "oystercatcher", + "dowitcher", + "godwit", + "bee eater", + "kingfisher", + "motmot", + "tody", + "chukar", + "jacamar", + "ptarmigan", + "prairie chicken", + "ruffed grouse", + "bulbul", + "iora", + "lorikeet", + "kookaburra", + "bower bird", + "weaverbird", + "indigo bunting", + "goldfinch", + "house finch", + "junco", + "brambling", + "chaffinch", + "water ouzel", + "dipper", + "reed warbler", + "thrush", + "wren", + "nuthatch", + "treecreeper", + "woodpecker", + "flicker", + "toucanet", + "heron", + "bittern", + "ibis", + "spoonbill", + "stork", + "avocet", + "cuckoo", + "nighthawk", + "whippoorwill", + "swift", + "nightjar", + "murre", + "murrelet", + "auklet", + "razorbill", + "guillemot", + "bald eagle", + "black grouse", + "capercaillie", + "quetzal")), + Map.entry( + "fish", + List.of( + "fish", + "shark", + "ray", + "stingray", + "eel", + "goldfish", + "tench", + "salmon", + "trout", + "sturgeon", + "carp", + "seahorse", + "coho")), + Map.entry( + "reptile", + List.of( + "snake", + "lizard", + "turtle", + "tortoise", + "crocodile", + "alligator", + "chameleon", + "iguana", + "gecko", + "komodo", + "viper", + "cobra", + "boa", + "python")), + Map.entry( + "insect", + List.of( + "butterfly", + "moth", + "bee", + "wasp", + "ant", + "beetle", + "dragonfly", + "cockroach", + "grasshopper", + "cricket", + "fly", + "mosquito", + "ladybug", + "caterpillar", + "cicada")), + Map.entry( + "vehicle", + List.of( + "car", + "truck", + "bus", + "van", + "jeep", + "limousine", + "minivan", + "cab", + "bicycle", + "motorbike", + "motorcycle", + "scooter", + "moped", + "airplane", + "aircraft", + "jet", + "helicopter", + "airship", + "ship", + "boat", + "canoe", + "kayak", + "sailboat", + "submarine", + "yacht", + "train", + "locomotive", + "streetcar", + "tram", + "trolleybus", + "tank", + "bulldozer", + "ambulance", + "tractor", + "forklift")), + Map.entry( + "food", + List.of( + "pizza", + "burger", + "hamburger", + "sandwich", + "hot dog", + "bread", + "pretzel", + "bagel", + "cake", + "muffin", + "croissant", + "waffle", + "apple", + "banana", + "orange", + "strawberry", + "lemon", + "pineapple", + "broccoli", + "cauliflower", + "cabbage", + "artichoke", + "mushroom", + "corn", + "squash", + "cucumber", + "pepper", + "eggplant", + "soup", + "ice cream", + "chocolate", + "guacamole", + "espresso")), + Map.entry( + "electronics", + List.of( + "phone", + "television", + "monitor", + "laptop", + "keyboard", + "remote control", + "camera", + "speaker", + "radio", + "computer", + "printer", + "joystick", + "modem", + "ipod", + "cellular")), + Map.entry( + "furniture", + List.of( + "chair", + "table", + "sofa", + "couch", + "bed", + "desk", + "bookcase", + "wardrobe", + "dresser", + "cabinet", + "rocking chair")), + Map.entry( + "sports", + List.of( + "ball", + "tennis", + "basketball", + "football", + "soccer", + "golf", + "baseball", + "volleyball", + "surfboard", + "skateboard", + "ski", + "dumbbell", + "barbell")), + Map.entry( + "nature", + List.of( + "mountain", + "cliff", + "volcano", + "valley", + "lake", + "ocean", + "beach", + "forest", + "jungle", + "desert", + "flower", + "daisy", + "rose", + "tulip", + "sunflower", + "coral", + "reef")), + Map.entry( + "person", + List.of("person", "people", "face", "baby", "cowboy", "groom", "bridegroom"))); + + /** + * Maps an ImageNet label to a broad category. + * + * @param label raw ImageNet class label (e.g. "American gallinule") + * @return broad category name, or {@code "other"} if no keyword matches + */ + public static String map(String label) { + String lower = label.toLowerCase().replace("_", " "); + return KEYWORDS.stream() + .filter(e -> e.getValue().stream().anyMatch(lower::contains)) + .map(Map.Entry::getKey) + .findFirst() + .orElse("other"); + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationController.java b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationController.java new file mode 100644 index 0000000..6d1f2fa --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationController.java @@ -0,0 +1,41 @@ +package pl.zzpj.ai_service; + +import ai.onnxruntime.OrtException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Tag(name = "Classification", description = "AI Image Classification API") +public class ClassificationController { + + private final ClassificationService classificationService; + + @PostMapping(value = "/classify", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Classify image", + description = "Uses an ONNX model to classify the content of the provided image file.") + public ResponseEntity classify(@RequestPart("file") MultipartFile file) + throws IOException, OrtException { + log.info("Classifying image: {}, size: {} bytes", file.getOriginalFilename(), file.getSize()); + ClassificationResult result = classificationService.classify(file); + log.info( + "Result: category={}, label={}, confidence={}", + result.category(), + result.label(), + result.confidence()); + return ResponseEntity.ok(result); + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationResult.java b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationResult.java new file mode 100644 index 0000000..c68f93d --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationResult.java @@ -0,0 +1,26 @@ +package pl.zzpj.ai_service; + +import java.util.List; + +/** + * Result of an image classification request. + * + * @param label the most specific ImageNet class label (e.g. "golden retriever") + * @param category the broad category mapped from the label (e.g. "dog") + * @param confidence probability of the top prediction in range [0, 1] + * @param top3 the three highest-scoring predictions with their labels and confidences + */ +public record ClassificationResult( + String label, + String category, + double confidence, + double categoryConfidence, + List top3) { + /** + * A single prediction with its label and confidence score. + * + * @param label ImageNet class label + * @param confidence probability in range [0, 1] + */ + public record TopPrediction(String label, double confidence) {} +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationService.java b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationService.java new file mode 100644 index 0000000..e87da9d --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/ClassificationService.java @@ -0,0 +1,195 @@ +package pl.zzpj.ai_service; + +import ai.onnxruntime.OnnxTensor; +import ai.onnxruntime.OrtEnvironment; +import ai.onnxruntime.OrtException; +import ai.onnxruntime.OrtSession; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.FloatBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.IntStream; +import javax.imageio.ImageIO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +public class ClassificationService { + + private static final int INPUT_SIZE = 224; + private static final int RESIZE_SIZE = 256; + private static final int TOP_K = 3; + private static final float[] MEAN = {0.485f, 0.456f, 0.406f}; + private static final float[] STD = {0.229f, 0.224f, 0.225f}; + + @Value("${ai.model.path}") + private String modelPath; + + private OrtEnvironment environment; + private OrtSession session; + private List labels; + private String[] labelCategories; + private String inputName; + + /** + * Loads the MobileNetV2 ONNX model and ImageNet class labels from disk. The synset file is + * expected to reside in the same directory as the model file. + */ + @PostConstruct + void loadModel() throws OrtException, IOException { + log.info("Loading MobileNetV2 ONNX model from {}", modelPath); + + Path modelFile = Path.of(modelPath); + Path parent = modelFile.getParent(); + Path synsetFile = (parent != null) ? parent.resolve("synset.txt") : Path.of("synset.txt"); + + if (!Files.exists(synsetFile)) { + throw new IOException("Labels file not found: " + synsetFile.toAbsolutePath()); + } + + labels = Files.readAllLines(synsetFile); + labelCategories = labels.stream().map(CategoryMapper::map).toArray(String[]::new); + log.info("Loaded {} class labels from {}", labels.size(), synsetFile.getFileName()); + + environment = OrtEnvironment.getEnvironment(); + try { + session = environment.createSession(modelPath, new OrtSession.SessionOptions()); + } catch (OrtException e) { + log.error("Failed to create ONNX session for {}: {}", modelPath, e.getMessage()); + throw e; + } + inputName = session.getInputNames().iterator().next(); + + log.info("Model loaded successfully. Input tensor name: '{}'", inputName); + } + + /** + * Classifies the given image using MobileNetV2 and maps the result to a broad category. + * + * @param file uploaded image file + * @return classification result with the top label, category, confidence, and top-3 predictions + */ + public ClassificationResult classify(MultipartFile file) throws IOException, OrtException { + float[] inputData = preprocess(file.getInputStream()); + + long[] shape = {1, 3, INPUT_SIZE, INPUT_SIZE}; + OnnxTensor inputTensor = + OnnxTensor.createTensor(environment, FloatBuffer.wrap(inputData), shape); + + float[] probs; + try (OrtSession.Result result = session.run(Collections.singletonMap(inputName, inputTensor))) { + float[][] raw = (float[][]) result.get(0).getValue(); + probs = softmax(raw[0]); + } + + int[] topIndices = + IntStream.range(0, probs.length) + .boxed() + .sorted((a, b) -> Float.compare(probs[b], probs[a])) + .mapToInt(Integer::intValue) + .limit(TOP_K) + .toArray(); + + String bestLabel = labels.get(topIndices[0]); + double bestConfidence = round4(probs[topIndices[0]]); + String category = labelCategories[topIndices[0]]; + + double categoryConfidence = 0.0; + if (!"other".equals(category)) { + for (int i = 0; i < probs.length; i++) { + if (category.equals(labelCategories[i])) { + categoryConfidence += probs[i]; + } + } + } + + List top3 = + Arrays.stream(topIndices) + .mapToObj(i -> new ClassificationResult.TopPrediction(labels.get(i), round4(probs[i]))) + .toList(); + + double clampedCategoryConfidence = Math.min(1.0, categoryConfidence); + return new ClassificationResult( + bestLabel, category, bestConfidence, round4(clampedCategoryConfidence), top3); + } + + private float[] preprocess(InputStream is) throws IOException { + BufferedImage orig = ImageIO.read(is); + if (orig == null) + throw new IOException("Failed to decode image — unsupported format or corrupted file"); + + // Resize shorter edge to RESIZE_SIZE + int w = orig.getWidth(); + int h = orig.getHeight(); + double scale = (double) RESIZE_SIZE / Math.min(w, h); + int newW = (int) Math.round(w * scale); + int newH = (int) Math.round(h * scale); + + BufferedImage resized = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = resized.createGraphics(); + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(orig, 0, 0, newW, newH, null); + g.dispose(); + + // Center crop to INPUT_SIZE x INPUT_SIZE + int x = (newW - INPUT_SIZE) / 2; + int y = (newH - INPUT_SIZE) / 2; + BufferedImage cropped = resized.getSubimage(x, y, INPUT_SIZE, INPUT_SIZE); + + // Convert to CHW float32 with ImageNet normalization + float[] data = new float[3 * INPUT_SIZE * INPUT_SIZE]; + for (int row = 0; row < INPUT_SIZE; row++) { + for (int col = 0; col < INPUT_SIZE; col++) { + int pixel = cropped.getRGB(col, row); + float r = ((pixel >> 16) & 0xFF) / 255.0f; + float green = ((pixel >> 8) & 0xFF) / 255.0f; + float b = (pixel & 0xFF) / 255.0f; + int base = row * INPUT_SIZE + col; + data[base] = (r - MEAN[0]) / STD[0]; + data[INPUT_SIZE * INPUT_SIZE + base] = (green - MEAN[1]) / STD[1]; + data[2 * INPUT_SIZE * INPUT_SIZE + base] = (b - MEAN[2]) / STD[2]; + } + } + return data; + } + + private static float[] softmax(float[] logits) { + float max = Float.NEGATIVE_INFINITY; + for (float v : logits) if (v > max) max = v; + double sum = 0; + float[] exp = new float[logits.length]; + for (int i = 0; i < logits.length; i++) { + exp[i] = (float) Math.exp(logits[i] - max); + sum += exp[i]; + } + for (int i = 0; i < exp.length; i++) exp[i] /= (float) sum; + return exp; + } + + @PreDestroy + void close() { + try { + if (session != null) session.close(); + if (environment != null) environment.close(); + } catch (OrtException e) { + log.warn("Error closing ORT session: {}", e.getMessage()); + } + } + + private static double round4(double value) { + return Math.round(value * 10000.0) / 10000.0; + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/client/AuthClient.java b/ai-service/src/main/java/pl/zzpj/ai_service/client/AuthClient.java new file mode 100644 index 0000000..5ac8167 --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/client/AuthClient.java @@ -0,0 +1,12 @@ +package pl.zzpj.ai_service.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "auth-server") +public interface AuthClient { + + @PostMapping("/auth/validate") + boolean validateToken(@RequestParam("token") String token); +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/exception/GlobalExceptionHandler.java b/ai-service/src/main/java/pl/zzpj/ai_service/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..592424b --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/exception/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package pl.zzpj.ai_service.exception; + +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument( + IllegalArgumentException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", exception.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception exception) { + log.error("Unhandled exception in ai-service", exception); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/security/JwtFilter.java b/ai-service/src/main/java/pl/zzpj/ai_service/security/JwtFilter.java new file mode 100644 index 0000000..62511bf --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/security/JwtFilter.java @@ -0,0 +1,114 @@ +package pl.zzpj.ai_service.security; + +import feign.FeignException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import pl.zzpj.ai_service.client.AuthClient; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String DEFAULT_USER_PRINCIPAL = "User"; + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String CHARACTER_ENCODING_UTF8 = "UTF-8"; + private static final String ERROR_JSON_FORMAT = "{\"error\": \"%s\"}"; + + private final AuthClient authClient; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String token = authHeader.substring(BEARER_PREFIX.length()); + + try { + if (authClient.validateToken(token)) { + String principal = extractPrincipalFromToken(token); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + handleError(response, "Invalid or expired token"); + return; + } + } catch (FeignException e) { + log.error("Error communicating with auth-server: {}", e.getMessage()); + handleError(response, "Authentication service is currently unavailable"); + return; + } catch (Exception e) { + log.error("Unexpected authentication error: {}", e.getMessage()); + handleError(response, "An unexpected error occurred during authentication"); + return; + } + } + + filterChain.doFilter(request, response); + } + + private String extractPrincipalFromToken(String token) { + try { + String[] chunks = token.split("\\."); + if (chunks.length < 2) return DEFAULT_USER_PRINCIPAL; + String payload = + new String( + java.util.Base64.getUrlDecoder().decode(chunks[1]), + java.nio.charset.StandardCharsets.UTF_8); + + String subject = extractJsonStringField(payload, "sub"); + String userId = extractJsonNumberField(payload, "userId"); + + if (subject != null && userId != null) { + return subject + "-" + userId; + } + } catch (Exception e) { + log.warn("Could not extract custom principal from token, falling back to default", e); + } + return DEFAULT_USER_PRINCIPAL; + } + + private String extractJsonStringField(String json, String field) { + String search = "\"" + field + "\":\""; + int start = json.indexOf(search); + if (start == -1) return null; + start += search.length(); + int end = json.indexOf("\"", start); + return end != -1 ? json.substring(start, end) : null; + } + + private String extractJsonNumberField(String json, String field) { + String search = "\"" + field + "\":"; + int start = json.indexOf(search); + if (start == -1) return null; + start += search.length(); + int end = start; + while (end < json.length() && Character.isDigit(json.charAt(end))) { + end++; + } + return end > start ? json.substring(start, end) : null; + } + + private void handleError(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(CONTENT_TYPE_JSON); + response.setCharacterEncoding(CHARACTER_ENCODING_UTF8); + response.getWriter().write(String.format(ERROR_JSON_FORMAT, message)); + } +} diff --git a/ai-service/src/main/java/pl/zzpj/ai_service/security/SecurityConfig.java b/ai-service/src/main/java/pl/zzpj/ai_service/security/SecurityConfig.java new file mode 100644 index 0000000..3d914a5 --- /dev/null +++ b/ai-service/src/main/java/pl/zzpj/ai_service/security/SecurityConfig.java @@ -0,0 +1,35 @@ +package pl.zzpj.ai_service.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/error") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/ai-service/src/main/resources/application.yaml b/ai-service/src/main/resources/application.yaml new file mode 100644 index 0000000..cd5af81 --- /dev/null +++ b/ai-service/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: ai-service + config: + import: "optional:configserver:http://localhost:8888" + servlet: + multipart: + # Match watermark-service (20MB) and the nginx client_max_body_size (20m). + # The default 1MB rejected larger photos forwarded from watermark-service + # with a 500 before they reached the classifier. + max-file-size: 20MB + max-request-size: 25MB diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/AiServiceApplicationTests.java b/ai-service/src/test/java/pl/zzpj/ai_service/AiServiceApplicationTests.java new file mode 100644 index 0000000..34c53f9 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/AiServiceApplicationTests.java @@ -0,0 +1,24 @@ +package pl.zzpj.ai_service; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import pl.zzpj.ai_service.client.AuthClient; + +@SpringBootTest +@TestPropertySource( + properties = { + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "ai.model.path=/tmp/model/mobilenetv2.onnx" + }) +class AiServiceApplicationTests { + + @MockitoBean ClassificationService classificationService; + + @MockitoBean AuthClient authClient; + + @Test + void contextLoads() {} +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/ClassificationServiceTest.java b/ai-service/src/test/java/pl/zzpj/ai_service/ClassificationServiceTest.java new file mode 100644 index 0000000..9a437b8 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/ClassificationServiceTest.java @@ -0,0 +1,73 @@ +package pl.zzpj.ai_service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import pl.zzpj.ai_service.support.OnnxRuntimeAvailability; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class ClassificationServiceTest { + + private ClassificationService service; + + @BeforeEach + void setUp() throws Exception { + // The ONNX Runtime native library does not initialize on every host/JDK + // combination (e.g. onnxruntime 1.21.1 under JDK 21 on this Windows box fails + // with "DLL initialization routine failed", while it works under JDK 25). + // Skip rather than hard-fail when the native runtime is unavailable. + assumeTrue(OnnxRuntimeAvailability.isAvailable(), + "ONNX Runtime native library could not initialize on this host/JDK"); + Path modelPath = Paths.get(getClass().getResource("/model/mobilenetv2.onnx").toURI()); + service = new ClassificationService(); + ReflectionTestUtils.setField(service, "modelPath", modelPath.toString()); + service.loadModel(); + } + + @AfterEach + void tearDown() { + if (service != null) { + service.close(); + } + } + + @Test + void classifiesSamoyedImageAsDogCategory() throws Exception { + Path imagePath = Paths.get(getClass().getResource("/model/test-dog.jpg").toURI()); + MockMultipartFile file = new MockMultipartFile( + "file", "test-dog.jpg", "image/jpeg", Files.readAllBytes(imagePath)); + + ClassificationResult result = service.classify(file); + + assertNotNull(result.label()); + assertEquals("dog", result.category(), + "Samoyed image should map to 'dog' category, got label='" + result.label() + "'"); + assertTrue(result.confidence() > 0.1, + "Top-1 confidence should be > 0.1, got " + result.confidence()); + assertTrue(result.categoryConfidence() >= result.confidence(), + "categoryConfidence should be >= top-1 confidence when top-1 is in the category, got " + + result.categoryConfidence() + " vs " + result.confidence()); + assertTrue(result.categoryConfidence() <= 1.0, + "categoryConfidence must not exceed 1.0, got " + result.categoryConfidence()); + assertEquals(3, result.top3().size()); + assertTrue(result.top3().get(0).confidence() >= result.top3().get(1).confidence(), + "top3 must be sorted by descending confidence"); + } +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/architecture/ArchitectureTest.java b/ai-service/src/test/java/pl/zzpj/ai_service/architecture/ArchitectureTest.java new file mode 100644 index 0000000..1c4ee22 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/architecture/ArchitectureTest.java @@ -0,0 +1,69 @@ +package pl.zzpj.ai_service.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Architecture rules for the ai-service module (issue #19 criterion #4), enforced + * with ArchUnit over the {@code pl.zzpj.ai_service} package. + * + *

The module has a flat package layout (no {@code controller}/{@code service} + * sub-packages), so the layering rules are expressed in terms of the Spring + * {@code @RestController} / {@code @Service} stereotypes rather than package names, + * which keeps them meaningful instead of vacuous. + */ +@AnalyzeClasses( + packages = "pl.zzpj.ai_service", + importOptions = {ImportOption.DoNotIncludeTests.class} +) +class ArchitectureTest { + + /** REST controllers must be named consistently. */ + @ArchTest + static final ArchRule restControllers_should_be_named_Controller = + classes() + .that().areAnnotatedWith(RestController.class) + .should().haveSimpleNameEndingWith("Controller") + .allowEmptyShould(true); + + /** + * Controllers are the entry point of the request flow; service-layer classes must + * not depend on them (dependencies point inward, controller → service, never back). + */ + @ArchTest + static final ArchRule services_should_not_depend_on_controllers = + noClasses() + .that().areAnnotatedWith(Service.class) + .should().dependOnClassesThat().areAnnotatedWith(RestController.class) + .allowEmptyShould(true); + + /** Enforce Slf4j logging — no direct System.out / System.err usage. */ + @ArchTest + static final ArchRule no_classes_should_use_standard_streams = + noClasses() + .should().accessField(System.class, "out") + .orShould().accessField(System.class, "err") + .as("no class should write to System.out or System.err (use Slf4j instead)") + .allowEmptyShould(true); + + /** + * Plain JUnit assertion that there is at least one controller in the module, so the + * stereotype-based rules above are exercised against real classes rather than + * silently passing on an empty set. + */ + @ArchTest + static final ArchRule module_should_have_a_rest_controller = + classes() + .that().areAnnotatedWith(RestController.class) + .should().resideInAPackage("pl.zzpj.ai_service..") + .allowEmptyShould(false); +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/bdd/ClassificationSteps.java b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/ClassificationSteps.java new file mode 100644 index 0000000..68ac1c6 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/ClassificationSteps.java @@ -0,0 +1,83 @@ +package pl.zzpj.ai_service.bdd; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import pl.zzpj.ai_service.ClassificationResult; +import pl.zzpj.ai_service.ClassificationService; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Cucumber step definitions for the ONNX image-classification feature. + * + *

Uses the real {@link ClassificationService} bean (model loaded from the test + * resource) and AssertJ for all assertions (issue #19 criterion #1). + */ +public class ClassificationSteps { + + @Autowired + private ClassificationService classificationService; + + private ClassificationResult result; + private List synsetLabels; + + @Given("the MobileNetV2 classification model is loaded") + public void theModelIsLoaded() { + assertThat(classificationService).as("ClassificationService bean").isNotNull(); + this.synsetLabels = readSynsetLabels(); + assertThat(synsetLabels).as("synset.txt labels").isNotEmpty(); + } + + @When("I classify the image {string}") + public void iClassifyTheImage(String imageName) throws Exception { + byte[] bytes; + try (InputStream is = getClass().getResourceAsStream("/model/" + imageName)) { + assertThat(is).as("test image /model/%s on classpath", imageName).isNotNull(); + bytes = is.readAllBytes(); + } + MockMultipartFile file = new MockMultipartFile("file", imageName, "image/jpeg", bytes); + this.result = classificationService.classify(file); + assertThat(result).as("classification result").isNotNull(); + } + + @Then("the returned label is a non-blank entry present in synset.txt") + public void theLabelIsAValidSynsetEntry() { + assertThat(result.label()).as("returned label").isNotBlank(); + assertThat(synsetLabels) + .as("returned label '%s' must be a known ImageNet synset entry", result.label()) + .contains(result.label()); + } + + @Then("the returned category is {string}") + public void theReturnedCategoryIs(String expectedCategory) { + assertThat(result.category()) + .as("broad category for label '%s'", result.label()) + .isEqualTo(expectedCategory); + } + + @Then("the confidence is greater than {int}") + public void theConfidenceIsGreaterThan(int threshold) { + assertThat(result.confidence()) + .as("top-1 confidence") + .isGreaterThan((double) threshold); + } + + private List readSynsetLabels() { + try (InputStream is = getClass().getResourceAsStream("/model/synset.txt")) { + assertThat(is).as("synset.txt on classpath").isNotNull(); + return new String(is.readAllBytes(), StandardCharsets.UTF_8) + .lines() + .collect(Collectors.toList()); + } catch (Exception e) { + throw new IllegalStateException("Could not read synset.txt", e); + } + } +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/bdd/CucumberSpringConfiguration.java b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/CucumberSpringConfiguration.java new file mode 100644 index 0000000..3c0e510 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/CucumberSpringConfiguration.java @@ -0,0 +1,28 @@ +package pl.zzpj.ai_service.bdd; + +import io.cucumber.spring.CucumberContextConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import pl.zzpj.ai_service.client.AuthClient; + +/** + * Wires the Cucumber scenarios into a real Spring Boot context (issue #19 criterion #1). + * + *

Eureka discovery and Spring Cloud Config are disabled exactly as in + * {@code AiServiceApplicationTests}. The ONNX model path points at the real test + * resource so the BDD steps exercise the genuine classification logic. The Feign + * {@code AuthClient} is mocked because it is unrelated to the classification flow. + */ +@CucumberContextConfiguration +@SpringBootTest +@TestPropertySource(properties = { + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "ai.model.path=${user.dir}/build/resources/test/model/mobilenetv2.onnx" +}) +public class CucumberSpringConfiguration { + + @MockitoBean + AuthClient authClient; +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/bdd/OnnxAvailabilityHooks.java b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/OnnxAvailabilityHooks.java new file mode 100644 index 0000000..9c2e69a --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/OnnxAvailabilityHooks.java @@ -0,0 +1,26 @@ +package pl.zzpj.ai_service.bdd; + +import io.cucumber.java.BeforeAll; +import org.junit.jupiter.api.Assumptions; +import pl.zzpj.ai_service.support.OnnxRuntimeAvailability; + +/** + * Cucumber {@code @BeforeAll} guard that runs before the Spring context is + * created for the BDD scenarios. + * + *

The real {@link pl.zzpj.ai_service.ClassificationService} loads the ONNX model in a + * {@code @PostConstruct}, so if the native ONNX Runtime cannot initialize on the host the + * whole Spring context would fail to start. This guard turns that environment-level + * problem into a clean skip (via a JUnit assumption) instead of a hard failure, + * while still running and asserting the real classification flow wherever ONNX works. + */ +public class OnnxAvailabilityHooks { + + @BeforeAll + public static void requireOnnxRuntime() { + Assumptions.assumeTrue( + OnnxRuntimeAvailability.isAvailable(), + "ONNX Runtime native library could not initialize on this host/JDK; " + + "skipping ONNX-backed BDD scenarios."); + } +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/bdd/RunCucumberTest.java b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/RunCucumberTest.java new file mode 100644 index 0000000..220e0ef --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/bdd/RunCucumberTest.java @@ -0,0 +1,21 @@ +package pl.zzpj.ai_service.bdd; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; + +/** + * JUnit Platform Suite runner that lets {@code ./gradlew :ai-service:test} discover and + * execute the Cucumber feature files under {@code src/test/resources/features}. + * + *

BDD test for issue #19 criterion #1 (Cucumber + AssertJ). + */ +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "pl.zzpj.ai_service.bdd") +public class RunCucumberTest { +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/security/ClassificationControllerSecurityTest.java b/ai-service/src/test/java/pl/zzpj/ai_service/security/ClassificationControllerSecurityTest.java new file mode 100644 index 0000000..e1e9b72 --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/security/ClassificationControllerSecurityTest.java @@ -0,0 +1,99 @@ +package pl.zzpj.ai_service.security; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import pl.zzpj.ai_service.ClassificationController; +import pl.zzpj.ai_service.ClassificationResult; +import pl.zzpj.ai_service.ClassificationService; +import pl.zzpj.ai_service.client.AuthClient; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Security tests for {@link ClassificationController} (issue #19 criterion #5). + * + *

Loads the real {@link SecurityConfig} and {@link JwtFilter} on top of a sliced + * MVC context. The Feign {@link AuthClient} and the {@link ClassificationService} + * are mocked. + * + *

Deviations from the issue text (these tests assert the REAL behaviour of + * the current {@link SecurityConfig}/{@link JwtFilter}, not the issue's wording): + *

+ * Summary of the genuine status codes: no header → 403, invalid token → 401, + * valid token → 200. + */ +@WebMvcTest(ClassificationController.class) +@Import({SecurityConfig.class, JwtFilter.class}) +class ClassificationControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthClient authClient; + + @MockitoBean + private ClassificationService classificationService; + + private MockMultipartFile imageFile() { + return new MockMultipartFile("file", "test-dog.jpg", "image/jpeg", new byte[]{1, 2, 3}); + } + + @Test + void noAuthorizationHeader_isRejected() throws Exception { + // No Authorization header → JwtFilter passes through → Spring Security's + // .anyRequest().authenticated() is enforced by the default Http403ForbiddenEntryPoint → 403. + // (See class Javadoc: real behaviour is 403 here, not the 401 the issue text implies.) + mockMvc.perform(multipart("/api/classify") + .file(imageFile()) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isForbidden()); + } + + @Test + void tamperedOrExpiredToken_returns401() throws Exception { + // validateToken == false → JwtFilter itself writes 401 ("Invalid or expired token"). + Mockito.when(authClient.validateToken(any())).thenReturn(false); + + mockMvc.perform(multipart("/api/classify") + .file(imageFile()) + .header("Authorization", "Bearer tampered.or.expired") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isUnauthorized()); + } + + @Test + void validToken_returns200() throws Exception { + // validateToken == true → authentication set → request reaches the controller. + Mockito.when(authClient.validateToken(any())).thenReturn(true); + Mockito.when(classificationService.classify(any())) + .thenReturn(new ClassificationResult("Samoyed", "dog", 0.9, 0.95, List.of())); + + mockMvc.perform(multipart("/api/classify") + .file(imageFile()) + .header("Authorization", "Bearer valid.jwt.token") + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isOk()); + } +} diff --git a/ai-service/src/test/java/pl/zzpj/ai_service/support/OnnxRuntimeAvailability.java b/ai-service/src/test/java/pl/zzpj/ai_service/support/OnnxRuntimeAvailability.java new file mode 100644 index 0000000..c86819e --- /dev/null +++ b/ai-service/src/test/java/pl/zzpj/ai_service/support/OnnxRuntimeAvailability.java @@ -0,0 +1,35 @@ +package pl.zzpj.ai_service.support; + +import ai.onnxruntime.OrtEnvironment; + +/** + * Detects whether the ONNX Runtime native library can actually initialize in the + * current JVM / host. + * + *

On some host + JDK combinations the bundled {@code onnxruntime.dll} fails to load + * with an {@link UnsatisfiedLinkError} ("DLL initialization routine failed") even though + * the pure-Java classes are present (observed with onnxruntime 1.21.1 under JDK 21 on + * Windows, while the same lib loads fine under JDK 25). This is an environment-level + * defect, not a defect in the service code. + * + *

Tests that exercise the real ONNX model use {@link #isAvailable()} together with a + * JUnit assumption so they run (and assert real classification) wherever the native + * runtime works, and are skipped — not failed — where it cannot initialize. + */ +public final class OnnxRuntimeAvailability { + + private OnnxRuntimeAvailability() { + } + + /** + * @return {@code true} if the ONNX Runtime native library initializes successfully. + */ + public static boolean isAvailable() { + try { + OrtEnvironment env = OrtEnvironment.getEnvironment(); + return env != null; + } catch (Throwable t) { + return false; + } + } +} diff --git a/ai-service/src/test/resources/features/image_classification.feature b/ai-service/src/test/resources/features/image_classification.feature new file mode 100644 index 0000000..b32b1f6 --- /dev/null +++ b/ai-service/src/test/resources/features/image_classification.feature @@ -0,0 +1,11 @@ +Feature: ONNX image classification + As a client of the ai-service + I want to classify an uploaded image with the MobileNetV2 ONNX model + So that I receive a valid ImageNet label, a broad category and a confidence score + + Scenario: Classifying a dog photo returns a valid synset label + Given the MobileNetV2 classification model is loaded + When I classify the image "test-dog.jpg" + Then the returned label is a non-blank entry present in synset.txt + And the returned category is "dog" + And the confidence is greater than 0 diff --git a/ai-service/src/test/resources/junit-platform.properties b/ai-service/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..431fd44 --- /dev/null +++ b/ai-service/src/test/resources/junit-platform.properties @@ -0,0 +1,2 @@ +cucumber.glue=pl.zzpj.ai_service.bdd +cucumber.publish.quiet=true diff --git a/ai-service/src/test/resources/model/mobilenetv2.onnx b/ai-service/src/test/resources/model/mobilenetv2.onnx new file mode 100644 index 0000000..919097c Binary files /dev/null and b/ai-service/src/test/resources/model/mobilenetv2.onnx differ diff --git a/ai-service/src/test/resources/model/synset.txt b/ai-service/src/test/resources/model/synset.txt new file mode 100644 index 0000000..07e8309 --- /dev/null +++ b/ai-service/src/test/resources/model/synset.txt @@ -0,0 +1,1000 @@ +tench +goldfish +great white shark +tiger shark +hammerhead +electric ray +stingray +cock +hen +ostrich +brambling +goldfinch +house finch +junco +indigo bunting +robin +bulbul +jay +magpie +chickadee +water ouzel +kite +bald eagle +vulture +great grey owl +European fire salamander +common newt +eft +spotted salamander +axolotl +bullfrog +tree frog +tailed frog +loggerhead +leatherback turtle +mud turtle +terrapin +box turtle +banded gecko +common iguana +American chameleon +whiptail +agama +frilled lizard +alligator lizard +Gila monster +green lizard +African chameleon +Komodo dragon +African crocodile +American alligator +triceratops +thunder snake +ringneck snake +hognose snake +green snake +king snake +garter snake +water snake +vine snake +night snake +boa constrictor +rock python +Indian cobra +green mamba +sea snake +horned viper +diamondback +sidewinder +trilobite +harvestman +scorpion +black and gold garden spider +barn spider +garden spider +black widow +tarantula +wolf spider +tick +centipede +black grouse +ptarmigan +ruffed grouse +prairie chicken +peacock +quail +partridge +African grey +macaw +sulphur-crested cockatoo +lorikeet +coucal +bee eater +hornbill +hummingbird +jacamar +toucan +drake +red-breasted merganser +goose +black swan +tusker +echidna +platypus +wallaby +koala +wombat +jellyfish +sea anemone +brain coral +flatworm +nematode +conch +snail +slug +sea slug +chiton +chambered nautilus +Dungeness crab +rock crab +fiddler crab +king crab +American lobster +spiny lobster +crayfish +hermit crab +isopod +white stork +black stork +spoonbill +flamingo +little blue heron +American egret +bittern +crane bird +limpkin +European gallinule +American coot +bustard +ruddy turnstone +red-backed sandpiper +redshank +dowitcher +oystercatcher +pelican +king penguin +albatross +grey whale +killer whale +dugong +sea lion +Chihuahua +Japanese spaniel +Maltese dog +Pekinese +Shih-Tzu +Blenheim spaniel +papillon +toy terrier +Rhodesian ridgeback +Afghan hound +basset +beagle +bloodhound +bluetick +black-and-tan coonhound +Walker hound +English foxhound +redbone +borzoi +Irish wolfhound +Italian greyhound +whippet +Ibizan hound +Norwegian elkhound +otterhound +Saluki +Scottish deerhound +Weimaraner +Staffordshire bullterrier +American Staffordshire terrier +Bedlington terrier +Border terrier +Kerry blue terrier +Irish terrier +Norfolk terrier +Norwich terrier +Yorkshire terrier +wire-haired fox terrier +Lakeland terrier +Sealyham terrier +Airedale +cairn +Australian terrier +Dandie Dinmont +Boston bull +miniature schnauzer +giant schnauzer +standard schnauzer +Scotch terrier +Tibetan terrier +silky terrier +soft-coated wheaten terrier +West Highland white terrier +Lhasa +flat-coated retriever +curly-coated retriever +golden retriever +Labrador retriever +Chesapeake Bay retriever +German short-haired pointer +vizsla +English setter +Irish setter +Gordon setter +Brittany spaniel +clumber +English springer +Welsh springer spaniel +cocker spaniel +Sussex spaniel +Irish water spaniel +kuvasz +schipperke +groenendael +malinois +briard +kelpie +komondor +Old English sheepdog +Shetland sheepdog +collie +Border collie +Bouvier des Flandres +Rottweiler +German shepherd +Doberman +miniature pinscher +Greater Swiss Mountain dog +Bernese mountain dog +Appenzeller +EntleBucher +boxer +bull mastiff +Tibetan mastiff +French bulldog +Great Dane +Saint Bernard +Eskimo dog +malamute +Siberian husky +dalmatian +affenpinscher +basenji +pug +Leonberg +Newfoundland +Great Pyrenees +Samoyed +Pomeranian +chow +keeshond +Brabancon griffon +Pembroke +Cardigan +toy poodle +miniature poodle +standard poodle +Mexican hairless +timber wolf +white wolf +red wolf +coyote +dingo +dhole +African hunting dog +hyena +red fox +kit fox +Arctic fox +grey fox +tabby +tiger cat +Persian cat +Siamese cat +Egyptian cat +cougar +lynx +leopard +snow leopard +jaguar +lion +tiger +cheetah +brown bear +American black bear +ice bear +sloth bear +mongoose +meerkat +tiger beetle +ladybug +ground beetle +long-horned beetle +leaf beetle +dung beetle +rhinoceros beetle +weevil +fly +bee +ant +grasshopper +cricket +walking stick +cockroach +mantis +cicada +leafhopper +lacewing +dragonfly +damselfly +admiral +ringlet +monarch +cabbage butterfly +sulphur butterfly +lycaenid +starfish +sea urchin +sea cucumber +wood rabbit +hare +Angora +hamster +porcupine +fox squirrel +marmot +beaver +guinea pig +sorrel +zebra +hog +wild boar +warthog +hippopotamus +ox +water buffalo +bison +ram +bighorn +ibex +hartebeest +impala +gazelle +Arabian camel +llama +weasel +mink +polecat +black-footed ferret +otter +skunk +badger +armadillo +three-toed sloth +orangutan +gorilla +chimpanzee +gibbon +siamang +guenon +patas +baboon +macaque +langur +colobus +proboscis monkey +marmoset +capuchin +howler monkey +titi +spider monkey +squirrel monkey +Madagascar cat +indri +Indian elephant +African elephant +lesser panda +giant panda +barracouta +eel +coho +rock beauty +anemone fish +sturgeon +gar +lionfish +puffer +abacus +abaya +academic gown +accordion +acoustic guitar +aircraft carrier +airliner +airship +altar +ambulance +amphibian +analog clock +apiary +apron +ashcan +assault rifle +backpack +bakery +balance beam +balloon +ballpoint +Band Aid +banjo +bannister +barbell +barber chair +barbershop +barn +barometer +barrel +barrow +baseball +basketball +bassinet +bassoon +bathing cap +bath towel +bathtub +beach wagon +beacon +beaker +bearskin +beer bottle +beer glass +bell cote +bib +bicycle-built-for-two +bikini +binder +binoculars +birdhouse +boathouse +bobsled +bolo tie +bonnet +bookcase +bookshop +bottlecap +bow +bow tie +brass +brassiere +breakwater +breastplate +broom +bucket +buckle +bulletproof vest +bullet train +butcher shop +cab +caldron +candle +cannon +canoe +can opener +cardigan +car mirror +carousel +carpenter's kit +carton +car wheel +cash machine +cassette +cassette player +castle +catamaran +CD player +cello +cellular telephone +chain +chainlink fence +chain mail +chain saw +chest +chiffonier +chime +china cabinet +Christmas stocking +church +cinema +cleaver +cliff dwelling +cloak +clog +cocktail shaker +coffee mug +coffeepot +coil +combination lock +computer keyboard +confectionery +container ship +convertible +corkscrew +cornet +cowboy boot +cowboy hat +cradle +crane +crash helmet +crate +crib +Crock Pot +croquet ball +crutch +cuirass +dam +desk +desktop computer +dial telephone +diaper +digital clock +digital watch +dining table +dishrag +dishwasher +disk brake +dock +dogsled +dome +doormat +drilling platform +drum +drumstick +dumbbell +Dutch oven +electric fan +electric guitar +electric locomotive +entertainment center +envelope +espresso maker +face powder +feather boa +file +fireboat +fire engine +fire screen +flagpole +flute +folding chair +football helmet +forklift +fountain +fountain pen +four-poster +freight car +French horn +frying pan +fur coat +garbage truck +gasmask +gas pump +goblet +go-kart +golf ball +golfcart +gondola +gong +gown +grand piano +greenhouse +grille +grocery store +guillotine +hair slide +hair spray +half track +hammer +hamper +hand blower +hand-held computer +handkerchief +hard disc +harmonica +harp +harvester +hatchet +holster +home theater +honeycomb +hook +hoopskirt +horizontal bar +horse cart +hourglass +iPod +iron +jack-o'-lantern +jean +jeep +jersey +jigsaw puzzle +jinrikisha +joystick +kimono +knee pad +knot +lab coat +ladle +lampshade +laptop +lawn mower +lens cap +letter opener +library +lifeboat +lighter +limousine +liner +lipstick +Loafer +lotion +loudspeaker +loupe +lumbermill +magnetic compass +mailbag +mailbox +maillot +maillot tank suit +manhole cover +maraca +marimba +mask +matchstick +maypole +maze +measuring cup +medicine chest +megalith +microphone +microwave +military uniform +milk can +minibus +miniskirt +minivan +missile +mitten +mixing bowl +mobile home +Model T +modem +monastery +monitor +moped +mortar +mortarboard +mosque +mosquito net +motor scooter +mountain bike +mountain tent +mouse +mousetrap +moving van +muzzle +nail +neck brace +necklace +nipple +notebook +obelisk +oboe +ocarina +odometer +oil filter +organ +oscilloscope +overskirt +oxcart +oxygen mask +packet +paddle +paddlewheel +padlock +paintbrush +pajama +palace +panpipe +paper towel +parachute +parallel bars +park bench +parking meter +passenger car +patio +pay-phone +pedestal +pencil box +pencil sharpener +perfume +Petri dish +photocopier +pick +pickelhaube +picket fence +pickup +pier +piggy bank +pill bottle +pillow +ping-pong ball +pinwheel +pirate +pitcher +plane +planetarium +plastic bag +plate rack +plow +plunger +Polaroid camera +pole +police van +poncho +pool table +pop bottle +pot +potter's wheel +power drill +prayer rug +printer +prison +projectile +projector +puck +punching bag +purse +quill +quilt +racer +racket +radiator +radio +radio telescope +rain barrel +recreational vehicle +reel +reflex camera +refrigerator +remote control +restaurant +revolver +rifle +rocking chair +rotisserie +rubber eraser +rugby ball +rule +running shoe +safe +safety pin +saltshaker +sandal +sarong +sax +scabbard +scale +school bus +schooner +scoreboard +screen +screw +screwdriver +seat belt +sewing machine +shield +shoe shop +shoji +shopping basket +shopping cart +shovel +shower cap +shower curtain +ski +ski mask +sleeping bag +slide rule +sliding door +slot +snorkel +snowmobile +snowplow +soap dispenser +soccer ball +sock +solar dish +sombrero +soup bowl +space bar +space heater +space shuttle +spatula +speedboat +spider web +spindle +sports car +spotlight +stage +steam locomotive +steel arch bridge +steel drum +stethoscope +stole +stone wall +stopwatch +stove +strainer +streetcar +stretcher +studio couch +stupa +submarine +suit +sundial +sunglass +sunglasses +sunscreen +suspension bridge +swab +sweatshirt +swimming trunks +swing +switch +syringe +table lamp +tank +tape player +teapot +teddy +television +tennis ball +thatch +theater curtain +thimble +thresher +throne +tile roof +toaster +tobacco shop +toilet seat +torch +totem pole +tow truck +toyshop +tractor +trailer truck +tray +trench coat +tricycle +trimaran +tripod +triumphal arch +trolleybus +trombone +tub +turnstile +typewriter keyboard +umbrella +unicycle +upright +vacuum +vase +vault +velvet +vending machine +vestment +viaduct +violin +volleyball +waffle iron +wall clock +wallet +wardrobe +warplane +washbasin +washer +water bottle +water jug +water tower +whiskey jug +whistle +wig +window screen +window shade +Windsor tie +wine bottle +wing +wok +wooden spoon +wool +worm fence +wreck +yawl +yurt +web site +comic book +crossword puzzle +street sign +traffic light +book jacket +menu +plate +guacamole +consomme +hot pot +trifle +ice cream +ice lolly +French loaf +bagel +pretzel +cheeseburger +hotdog +mashed potato +head cabbage +broccoli +cauliflower +zucchini +spaghetti squash +acorn squash +butternut squash +cucumber +artichoke +bell pepper +cardoon +mushroom +Granny Smith +strawberry +orange +lemon +fig +pineapple +banana +jackfruit +custard apple +pomegranate +hay +carbonara +chocolate sauce +dough +meat loaf +pizza +potpie +burrito +red wine +espresso +cup +eggnog +alp +bubble +cliff +coral reef +geyser +lakeside +promontory +sandbar +seashore +valley +volcano +ballplayer +groom +scuba diver +rapeseed +daisy +yellow lady's slipper +corn +acorn +hip +buckeye +coral fungus +agaric +gyromitra +stinkhorn +earthstar +hen-of-the-woods +bolete +ear +toilet tissue \ No newline at end of file diff --git a/ai-service/src/test/resources/model/test-dog.jpg b/ai-service/src/test/resources/model/test-dog.jpg new file mode 100644 index 0000000..d0cec4a Binary files /dev/null and b/ai-service/src/test/resources/model/test-dog.jpg differ diff --git a/api-docs.html b/api-docs.html new file mode 100644 index 0000000..76c1db0 --- /dev/null +++ b/api-docs.html @@ -0,0 +1,58 @@ + + + + + + + StegoCloud API Docs + + + + + +

+ + + + + diff --git a/auth-server/.gitattributes b/auth-server/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/auth-server/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/auth-server/.gitignore b/auth-server/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/auth-server/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/auth-server/Dockerfile b/auth-server/Dockerfile new file mode 100644 index 0000000..d8f50ce --- /dev/null +++ b/auth-server/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /workspace + +COPY . . +RUN chmod +x gradlew && ./gradlew :auth-server:bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=builder /workspace/auth-server/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] + diff --git a/auth-server/build.gradle.kts b/auth-server/build.gradle.kts new file mode 100644 index 0000000..5393b69 --- /dev/null +++ b/auth-server/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + java + jacoco + id("org.springframework.boot") version "3.5.11" + id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "7.2.3.7755" +} + +group = "pl.zzpj" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-oauth2-authorization-server") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("springdocVersion")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // JPA i PostgreSQL Driver + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + runtimeOnly("org.postgresql:postgresql") + + // Flyway - silnik migracji + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + + // Jwt + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") + + // Spring Security + implementation("org.springframework.boot:spring-boot-starter-security") + + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") + + // Testcontainers + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } +} + +tasks.named("sonar") { + dependsOn(tasks.jacocoTestReport) +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/Application.java b/auth-server/src/main/java/pl/zzpj/auth_server/Application.java new file mode 100644 index 0000000..ee80567 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/Application.java @@ -0,0 +1,14 @@ +package pl.zzpj.auth_server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/controller/AuthController.java b/auth-server/src/main/java/pl/zzpj/auth_server/controller/AuthController.java new file mode 100644 index 0000000..2012081 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/controller/AuthController.java @@ -0,0 +1,70 @@ +package pl.zzpj.auth_server.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import pl.zzpj.auth_server.dto.LoginRequest; +import pl.zzpj.auth_server.dto.LoginResponse; +import pl.zzpj.auth_server.dto.RegisterRequest; +import pl.zzpj.auth_server.dto.RegisterResponse; +import pl.zzpj.auth_server.repository.UserRepository; +import pl.zzpj.auth_server.service.JwtService; +import pl.zzpj.auth_server.service.RegistrationService; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Authentication and registration API") +public class AuthController { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final RegistrationService registrationService; + + @PostMapping("/register") + @Operation( + summary = "Register user", + description = "Creates a new user account with the provided details.") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + RegisterResponse response = registrationService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + @Operation(summary = "Login user", description = "Authenticates a user and returns a JWT token.") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + return userRepository + .findByEmail(request.getEmail()) + .map( + user -> { + if (passwordEncoder.matches(request.getPassword(), user.getPassword())) { + String token = + jwtService.generateToken(user.getUsername(), user.getId(), user.getRole()); + return ResponseEntity.ok(new LoginResponse(token)); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid password"); + }) + .orElse(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User not found")); + } + + @PostMapping("/validate") + @Operation(summary = "Validate token", description = "Validates the provided JWT token.") + public ResponseEntity validateToken(@RequestParam String token) { + try { + jwtService.validateToken(token); + return ResponseEntity.ok(true); + } catch (Exception e) { + return ResponseEntity.ok(false); + } + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginRequest.java b/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginRequest.java new file mode 100644 index 0000000..db0d391 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginRequest.java @@ -0,0 +1,18 @@ +package pl.zzpj.auth_server.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + @NotBlank(message = "Email cannot be empty") + private String email; + + @NotBlank(message = "Password cannot be empty") + private String password; +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginResponse.java b/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginResponse.java new file mode 100644 index 0000000..65d5401 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/dto/LoginResponse.java @@ -0,0 +1,12 @@ +package pl.zzpj.auth_server.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponse { + private String token; +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterRequest.java b/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterRequest.java new file mode 100644 index 0000000..709890c --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterRequest.java @@ -0,0 +1,32 @@ +package pl.zzpj.auth_server.dto; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import pl.zzpj.auth_server.exception.UnknownRegistrationPropertyException; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RegisterRequest { + + @NotBlank(message = "Username cannot be empty") + @Size(min = 3, max = 50) + private String username; + + @Email(message = "Email should be valid") + @NotBlank(message = "Email cannot be empty") + private String email; + + @NotBlank(message = "Password cannot be empty") + private String password; + + @JsonAnySetter + public void rejectUnknownProperty(String name, Object value) { + throw new UnknownRegistrationPropertyException(name); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterResponse.java b/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterResponse.java new file mode 100644 index 0000000..eacc86f --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/dto/RegisterResponse.java @@ -0,0 +1,14 @@ +package pl.zzpj.auth_server.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import pl.zzpj.auth_server.entity.UserRole; + +@Data +@AllArgsConstructor +public class RegisterResponse { + private Long id; + private String username; + private String email; + private UserRole role; +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/entity/User.java b/auth-server/src/main/java/pl/zzpj/auth_server/entity/User.java new file mode 100644 index 0000000..512221c --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/entity/User.java @@ -0,0 +1,39 @@ +package pl.zzpj.auth_server.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Entity +@Table(name = "users", schema = "auth_schema") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank(message = "Username cannot be empty") + @Size(min = 3, max = 50) + @Column(nullable = false, unique = true) + private String username; + + @Email(message = "Email should be valid") + @NotBlank(message = "Email cannot be empty") + @Column(nullable = false, unique = true) + private String email; + + @NotBlank + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserRole role; +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/entity/UserRole.java b/auth-server/src/main/java/pl/zzpj/auth_server/entity/UserRole.java new file mode 100644 index 0000000..cedb6d7 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/entity/UserRole.java @@ -0,0 +1,6 @@ +package pl.zzpj.auth_server.entity; + +public enum UserRole { + ADMIN, + USER +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/exception/BadCredentialsException.java b/auth-server/src/main/java/pl/zzpj/auth_server/exception/BadCredentialsException.java new file mode 100644 index 0000000..d1c8bc4 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/exception/BadCredentialsException.java @@ -0,0 +1,7 @@ +package pl.zzpj.auth_server.exception; + +public class BadCredentialsException extends RuntimeException { + public BadCredentialsException(String message) { + super(message); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/exception/DuplicateUserFieldException.java b/auth-server/src/main/java/pl/zzpj/auth_server/exception/DuplicateUserFieldException.java new file mode 100644 index 0000000..7c7d129 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/exception/DuplicateUserFieldException.java @@ -0,0 +1,15 @@ +package pl.zzpj.auth_server.exception; + +public class DuplicateUserFieldException extends RuntimeException { + + private final String field; + + public DuplicateUserFieldException(String field, String message) { + super(message); + this.field = field; + } + + public String getField() { + return field; + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/exception/EmailNotFoundException.java b/auth-server/src/main/java/pl/zzpj/auth_server/exception/EmailNotFoundException.java new file mode 100644 index 0000000..dd50198 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/exception/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package pl.zzpj.auth_server.exception; + +public class EmailNotFoundException extends RuntimeException { + public EmailNotFoundException(String message) { + super(message); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/exception/GlobalExceptionHandler.java b/auth-server/src/main/java/pl/zzpj/auth_server/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e6c0e28 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/exception/GlobalExceptionHandler.java @@ -0,0 +1,55 @@ +package pl.zzpj.auth_server.exception; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions( + MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult() + .getAllErrors() + .forEach( + (error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleJsonErrors(HttpMessageNotReadableException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body("Invalid JSON format or unknown properties in request."); + } + + @ExceptionHandler(UnknownRegistrationPropertyException.class) + public ResponseEntity handleUnknownRegistrationProperty( + UnknownRegistrationPropertyException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); + } + + @ExceptionHandler(DuplicateUserFieldException.class) + public ResponseEntity> handleDuplicateUserField( + DuplicateUserFieldException ex) { + Map errors = new HashMap<>(); + errors.put(ex.getField(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errors); + } + + @ExceptionHandler({BadCredentialsException.class, EmailNotFoundException.class}) + public ResponseEntity handleAuthenticationErrors(Exception ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid email or password."); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/exception/UnknownRegistrationPropertyException.java b/auth-server/src/main/java/pl/zzpj/auth_server/exception/UnknownRegistrationPropertyException.java new file mode 100644 index 0000000..edf5999 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/exception/UnknownRegistrationPropertyException.java @@ -0,0 +1,8 @@ +package pl.zzpj.auth_server.exception; + +public class UnknownRegistrationPropertyException extends RuntimeException { + + public UnknownRegistrationPropertyException(String property) { + super("Unknown registration property: " + property); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/repository/UserRepository.java b/auth-server/src/main/java/pl/zzpj/auth_server/repository/UserRepository.java new file mode 100644 index 0000000..8f318ea --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/repository/UserRepository.java @@ -0,0 +1,15 @@ +package pl.zzpj.auth_server.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import pl.zzpj.auth_server.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/security/SecurityConfig.java b/auth-server/src/main/java/pl/zzpj/auth_server/security/SecurityConfig.java new file mode 100644 index 0000000..40da3b4 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/security/SecurityConfig.java @@ -0,0 +1,39 @@ +package pl.zzpj.auth_server.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/auth/login", + "/auth/register", + "/auth/validate", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + return http.build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/service/JwtService.java b/auth-server/src/main/java/pl/zzpj/auth_server/service/JwtService.java new file mode 100644 index 0000000..7b51a71 --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/service/JwtService.java @@ -0,0 +1,32 @@ +package pl.zzpj.auth_server.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import pl.zzpj.auth_server.entity.UserRole; + +@Service +public class JwtService { + @Value("${app.jwt.secret}") + private String secretKey; + + public String generateToken(String username, Long userId, UserRole role) { + return Jwts.builder() + .subject(username) + .claim("userId", userId) + .claim("role", role.name()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 86400000)) // 24h + .signWith(Keys.hmacShaKeyFor(secretKey.getBytes())) + .compact(); + } + + public void validateToken(String token) { + Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes())) + .build() + .parseSignedClaims(token); + } +} diff --git a/auth-server/src/main/java/pl/zzpj/auth_server/service/RegistrationService.java b/auth-server/src/main/java/pl/zzpj/auth_server/service/RegistrationService.java new file mode 100644 index 0000000..28dbf2a --- /dev/null +++ b/auth-server/src/main/java/pl/zzpj/auth_server/service/RegistrationService.java @@ -0,0 +1,47 @@ +package pl.zzpj.auth_server.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.zzpj.auth_server.dto.RegisterRequest; +import pl.zzpj.auth_server.dto.RegisterResponse; +import pl.zzpj.auth_server.entity.User; +import pl.zzpj.auth_server.entity.UserRole; +import pl.zzpj.auth_server.exception.DuplicateUserFieldException; +import pl.zzpj.auth_server.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class RegistrationService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder passwordEncoder; + + @Transactional + public RegisterResponse register(RegisterRequest request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new DuplicateUserFieldException("email", "Email is already registered."); + } + if (userRepository.existsByUsername(request.getUsername())) { + throw new DuplicateUserFieldException("username", "Username is already taken."); + } + + User user = + User.builder() + .username(request.getUsername()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .role(UserRole.USER) + .build(); + + try { + User savedUser = userRepository.saveAndFlush(user); + return new RegisterResponse( + savedUser.getId(), savedUser.getUsername(), savedUser.getEmail(), savedUser.getRole()); + } catch (DataIntegrityViolationException exception) { + throw new DuplicateUserFieldException("user", "Username or email is already registered."); + } + } +} diff --git a/auth-server/src/main/resources/application.yaml b/auth-server/src/main/resources/application.yaml new file mode 100644 index 0000000..bf2000f --- /dev/null +++ b/auth-server/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + application: + name: auth-server + config: + import: "optional:configserver:http://localhost:8888" \ No newline at end of file diff --git a/auth-server/src/main/resources/db/migration/V1__create_users_table.sql b/auth-server/src/main/resources/db/migration/V1__create_users_table.sql new file mode 100644 index 0000000..5ab6833 --- /dev/null +++ b/auth-server/src/main/resources/db/migration/V1__create_users_table.sql @@ -0,0 +1,13 @@ +CREATE SCHEMA IF NOT EXISTS auth_schema; + +CREATE TABLE IF NOT EXISTS auth_schema.users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + +-- Password hashed using BCrypt with a cost factor of 12 +INSERT INTO auth_schema.users (username, email, password) +VALUES ('admin', 'admin@gmail.com', '$2a$12$7PmkNcI.xz5reIifDDHIdeNLupeVnHegOHXCFlkyUIlxbRdsamolu'); \ No newline at end of file diff --git a/auth-server/src/main/resources/db/migration/V2__seed_demo_users.sql b/auth-server/src/main/resources/db/migration/V2__seed_demo_users.sql new file mode 100644 index 0000000..a171fda --- /dev/null +++ b/auth-server/src/main/resources/db/migration/V2__seed_demo_users.sql @@ -0,0 +1,13 @@ +INSERT INTO auth_schema.users (id, username, email, password) +VALUES + (2, 'free', 'free@gmail.com', '$2a$12$GnBrsRQ3rJEKSz3O6Z9Pl..49iChbchxICFGE3stXc70rOu9PjvK.'), + (3, 'standard', 'standard@gmail.com', '$2a$12$4ZbXgiP6ckL4tWIWrgpffOJ8jZ9TsuA1UEkCDG.x4q0.LY1n/VMCy'), + (4, 'pro', 'pro@gmail.com', '$2a$12$e63lbVXXLJ/EwivD14Nfeux9mAk9MX8i7Bs1HlhKpowZkR7YEBydS'), + (5, 'lowbalance', 'lowbalance@gmail.com', '$2a$12$hlNoant3HPcv0/UrsT/Nj.nQO7bl2e5kuNpWN2nKRNn3KfeU2RiPG') +ON CONFLICT DO NOTHING; + +-- Reset the sequence to the maximum id value to avoid conflicts with future inserts +SELECT setval( + pg_get_serial_sequence('auth_schema.users', 'id'), + (SELECT MAX(id) FROM auth_schema.users) +); diff --git a/auth-server/src/main/resources/db/migration/V3__add_user_roles.sql b/auth-server/src/main/resources/db/migration/V3__add_user_roles.sql new file mode 100644 index 0000000..311bb6c --- /dev/null +++ b/auth-server/src/main/resources/db/migration/V3__add_user_roles.sql @@ -0,0 +1,10 @@ +ALTER TABLE auth_schema.users +ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER'; + +UPDATE auth_schema.users +SET role = 'ADMIN' +WHERE email = 'admin@gmail.com'; + +UPDATE auth_schema.users +SET role = 'USER' +WHERE email <> 'admin@gmail.com'; diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/ApplicationTests.java b/auth-server/src/test/java/pl/zzpj/auth_server/ApplicationTests.java new file mode 100644 index 0000000..f1cc3a7 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/ApplicationTests.java @@ -0,0 +1,24 @@ +package pl.zzpj.auth_server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest( + properties = { + "app.jwt.secret=TwojTestowySekretKtoryMusiBycWystarczajacoDlugiZebyZadowolicAlgorytmJWT", + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false" + }) +@Testcontainers +class ApplicationTests { + + @Container @ServiceConnection + static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:15-alpine"); + + @Test + void contextLoads() {} +} diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/architecture/ArchitectureTest.java b/auth-server/src/test/java/pl/zzpj/auth_server/architecture/ArchitectureTest.java new file mode 100644 index 0000000..af62933 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/architecture/ArchitectureTest.java @@ -0,0 +1,71 @@ +package pl.zzpj.auth_server.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +/** + * ArchUnit architecture rules scoped to the auth-server base package. + */ +class ArchitectureTest { + + private static final String BASE_PACKAGE = "pl.zzpj.auth_server"; + + private static JavaClasses classesUnderTest; + + @BeforeAll + static void importClasses() { + classesUnderTest = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE_PACKAGE); + } + + @Test + void restControllersShouldBeNamedController() { + ArchRule rule = classes() + .that() + .areAnnotatedWith(RestController.class) + .should() + .haveSimpleNameEndingWith("Controller") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void controllerLayerShouldNotBeAccessedByLowerLayers() { + ArchRule rule = noClasses() + .that() + .resideInAnyPackage( + "..service..", + "..repository..", + "..entity.." + ) + .should() + .accessClassesThat() + .resideInAPackage("..controller..") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void noClassShouldUseStandardStreams() { + ArchRule rule = noClasses() + .should() + .accessField(System.class, "out") + .orShould() + .accessField(System.class, "err") + .because("logging must go through Slf4j, not System.out/System.err") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } +} diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/controller/AuthControllerTest.java b/auth-server/src/test/java/pl/zzpj/auth_server/controller/AuthControllerTest.java new file mode 100644 index 0000000..529b119 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/controller/AuthControllerTest.java @@ -0,0 +1,60 @@ +package pl.zzpj.auth_server.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import pl.zzpj.auth_server.dto.RegisterRequest; +import pl.zzpj.auth_server.dto.RegisterResponse; +import pl.zzpj.auth_server.entity.UserRole; +import pl.zzpj.auth_server.repository.UserRepository; +import pl.zzpj.auth_server.service.JwtService; +import pl.zzpj.auth_server.service.RegistrationService; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private UserRepository userRepository; + + @MockitoBean private BCryptPasswordEncoder passwordEncoder; + + @MockitoBean private JwtService jwtService; + + @MockitoBean private RegistrationService registrationService; + + @Test + void shouldRegisterUser() throws Exception { + RegisterResponse response = new RegisterResponse(1L, "user", "email@test.com", UserRole.USER); + when(registrationService.register(any(RegisterRequest.class))).thenReturn(response); + + mockMvc + .perform( + post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"username\":\"user\",\"email\":\"email@test.com\",\"password\":\"password\"}")) + .andExpect(status().isCreated()); + } + + @Test + void shouldFailValidationOnRegister() throws Exception { + mockMvc + .perform( + post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"email\":\"invalid-email\",\"password\":\"\"}")) + .andExpect(status().isBadRequest()); + } +} diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/security/SecurityConfigTest.java b/auth-server/src/test/java/pl/zzpj/auth_server/security/SecurityConfigTest.java new file mode 100644 index 0000000..f4f67c6 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/security/SecurityConfigTest.java @@ -0,0 +1,84 @@ +package pl.zzpj.auth_server.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import pl.zzpj.auth_server.controller.AuthController; +import pl.zzpj.auth_server.repository.UserRepository; +import pl.zzpj.auth_server.service.JwtService; +import pl.zzpj.auth_server.service.RegistrationService; + +/** + * Verifies the real {@link SecurityConfig} filter chain (filters ENABLED). + * + *

auth-server is the token issuer for the platform. It exposes no + * role-protected business endpoints, so there is no role-based 403 code path + * driven by authorities to exercise here -- only the unauthenticated boundary + * enforced by Spring Security before request mapping, plus the permitAll + * whitelist. + * + *

Note: SecurityConfig declares no authentication entry point (no httpBasic / + * form login), so an unauthenticated request to a protected route is rejected + * with 403 rather than 401. The assertion below accepts either rejection code: + * the load-bearing fact is that a non-whitelisted route is blocked by security + * (not reachable), while a whitelisted route is not. + */ +@WebMvcTest(AuthController.class) +@Import(SecurityConfig.class) +class SecurityConfigTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserRepository userRepository; + + @MockitoBean + private BCryptPasswordEncoder passwordEncoder; + + @MockitoBean + private JwtService jwtService; + + @MockitoBean + private RegistrationService registrationService; + + @Test + void nonWhitelistedRequestWithoutCredentialsIsRejected() throws Exception { + // /auth/secured-probe is not a permitAll route and is rejected by + // Spring Security before reaching any handler mapping. With no auth + // entry point configured, the rejection code is 401 or 403. + mockMvc + .perform(get("/auth/secured-probe")) + .andExpect(result -> { + int status = result.getResponse().getStatus(); + Assertions.assertTrue( + status == 401 || status == 403, + "Non-whitelisted route must be rejected by security " + + "(401/403), but was " + status + ); + }); + } + + @Test + void whitelistedRouteIsReachableWithoutCredentials() throws Exception { + // /auth/validate is permitAll: it may return 400 (missing param) but + // must NOT be blocked by security (no 401/403). + mockMvc + .perform(get("/auth/validate")) + .andExpect(result -> { + int status = result.getResponse().getStatus(); + Assertions.assertTrue( + status != 401 && status != 403, + "Whitelisted route must not be blocked by security, " + + "but was " + status + ); + }); + } +} diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/service/JwtServiceTest.java b/auth-server/src/test/java/pl/zzpj/auth_server/service/JwtServiceTest.java new file mode 100644 index 0000000..481d228 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/service/JwtServiceTest.java @@ -0,0 +1,41 @@ +package pl.zzpj.auth_server.service; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import pl.zzpj.auth_server.entity.UserRole; + +class JwtServiceTest { + + private JwtService jwtService; + private final String secret = "testSecretKeyWithEnoughLengthForHMACSHA256Algorithm123456"; + + @BeforeEach + void setUp() { + jwtService = new JwtService(); + ReflectionTestUtils.setField(jwtService, "secretKey", secret); + } + + @Test + void shouldGenerateValidToken() { + String token = jwtService.generateToken("user1", 1L, UserRole.USER); + assertNotNull(token); + assertDoesNotThrow(() -> jwtService.validateToken(token)); + } + + @Test + void shouldThrowExceptionForInvalidToken() { + assertThrows(Exception.class, () -> jwtService.validateToken("invalidToken")); + } + + @Test + void shouldThrowExceptionForExpiredToken() { + // We can't easily test expiration without changing the code or using a custom clock, + // but we can test that different data produces different tokens. + String token1 = jwtService.generateToken("user1", 1L, UserRole.USER); + String token2 = jwtService.generateToken("user2", 2L, UserRole.ADMIN); + assertNotEquals(token1, token2); + } +} diff --git a/auth-server/src/test/java/pl/zzpj/auth_server/service/RegistrationServiceTest.java b/auth-server/src/test/java/pl/zzpj/auth_server/service/RegistrationServiceTest.java new file mode 100644 index 0000000..0aa9ba2 --- /dev/null +++ b/auth-server/src/test/java/pl/zzpj/auth_server/service/RegistrationServiceTest.java @@ -0,0 +1,84 @@ +package pl.zzpj.auth_server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import pl.zzpj.auth_server.dto.RegisterRequest; +import pl.zzpj.auth_server.dto.RegisterResponse; +import pl.zzpj.auth_server.entity.User; +import pl.zzpj.auth_server.entity.UserRole; +import pl.zzpj.auth_server.exception.DuplicateUserFieldException; +import pl.zzpj.auth_server.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class RegistrationServiceTest { + + @Mock private UserRepository userRepository; + + @Mock private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks private RegistrationService registrationService; + + private RegisterRequest request; + + @BeforeEach + void setUp() { + request = new RegisterRequest(); + request.setUsername("testuser"); + request.setEmail("test@example.com"); + request.setPassword("password123"); + } + + @Test + void shouldRegisterUserSuccessfully() { + when(userRepository.existsByEmail(request.getEmail())).thenReturn(false); + when(userRepository.existsByUsername(request.getUsername())).thenReturn(false); + when(passwordEncoder.encode(request.getPassword())).thenReturn("encodedPassword"); + + User savedUser = + User.builder() + .id(1L) + .username(request.getUsername()) + .email(request.getEmail()) + .role(UserRole.USER) + .build(); + + when(userRepository.saveAndFlush(any(User.class))).thenReturn(savedUser); + + RegisterResponse response = registrationService.register(request); + + assertNotNull(response); + assertEquals(1L, response.getId()); + assertEquals("testuser", response.getUsername()); + verify(userRepository).saveAndFlush(any(User.class)); + } + + @Test + void shouldThrowExceptionWhenEmailExists() { + when(userRepository.existsByEmail(request.getEmail())).thenReturn(true); + + assertThrows(DuplicateUserFieldException.class, () -> registrationService.register(request)); + verify(userRepository, never()).saveAndFlush(any()); + } + + @Test + void shouldThrowExceptionWhenUsernameExists() { + when(userRepository.existsByEmail(request.getEmail())).thenReturn(false); + when(userRepository.existsByUsername(request.getUsername())).thenReturn(true); + + assertThrows(DuplicateUserFieldException.class, () -> registrationService.register(request)); + verify(userRepository, never()).saveAndFlush(any()); + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e8b5b0e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,32 @@ +apply(from = "gradle/versions.gradle.kts") + +plugins { + java + checkstyle + id("com.diffplug.spotless") version "7.0.2" +} + +subprojects { + apply(plugin = "java") + apply(plugin = "checkstyle") + apply(plugin = "com.diffplug.spotless") + + checkstyle { + toolVersion = "10.17.0" + configFile = rootProject.file("config/checkstyle/checkstyle.xml") + } + + spotless { + java { + googleJavaFormat() + removeUnusedImports() + } + } + + tasks.withType().configureEach { + reports { + xml.required.set(true) + html.required.set(true) + } + } +} \ No newline at end of file diff --git a/config-server/.gitattributes b/config-server/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/config-server/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/config-server/.gitignore b/config-server/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/config-server/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/config-server/Dockerfile b/config-server/Dockerfile new file mode 100644 index 0000000..69e762d --- /dev/null +++ b/config-server/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /workspace + +COPY . . +RUN chmod +x gradlew && ./gradlew :config-server:bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=builder /workspace/config-server/build/libs/*.jar app.jar +EXPOSE 8888 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] + diff --git a/config-server/build.gradle.kts b/config-server/build.gradle.kts new file mode 100644 index 0000000..31bed8d --- /dev/null +++ b/config-server/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + java + jacoco + id("org.springframework.boot") version "3.5.11" + id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "7.2.3.7755" +} + +group = "pl.zzpj" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.cloud:spring-cloud-config-server") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } +} + +tasks.named("sonar") { + dependsOn(tasks.jacocoTestReport) +} diff --git a/config-server/src/main/java/pl/zzpj/config_server/Application.java b/config-server/src/main/java/pl/zzpj/config_server/Application.java new file mode 100644 index 0000000..9827717 --- /dev/null +++ b/config-server/src/main/java/pl/zzpj/config_server/Application.java @@ -0,0 +1,14 @@ +package pl.zzpj.config_server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +@SpringBootApplication +@EnableConfigServer +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/config-server/src/main/java/pl/zzpj/config_server/config/SecurityConfig.java b/config-server/src/main/java/pl/zzpj/config_server/config/SecurityConfig.java new file mode 100644 index 0000000..d40ac60 --- /dev/null +++ b/config-server/src/main/java/pl/zzpj/config_server/config/SecurityConfig.java @@ -0,0 +1,21 @@ +package pl.zzpj.config_server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()); + return http.build(); + } +} diff --git a/config-server/src/main/resources/application.yaml b/config-server/src/main/resources/application.yaml new file mode 100644 index 0000000..44cc198 --- /dev/null +++ b/config-server/src/main/resources/application.yaml @@ -0,0 +1,20 @@ +server: + port: 8888 + +spring: + application: + name: config-server + + security: + user: + name: ${CONFIG_SERVER_USER:admin} + password: ${CONFIG_SERVER_PASSWORD:admin} + + cloud: + config: + server: + git: + uri: https://github.com/bkolacinski/pl-java2026-config.git + default-label: main + native: + search-locations: classpath:/config/ diff --git a/config-server/src/main/resources/config/ai-service.yaml b/config-server/src/main/resources/config/ai-service.yaml new file mode 100644 index 0000000..6b555d4 --- /dev/null +++ b/config-server/src/main/resources/config/ai-service.yaml @@ -0,0 +1,13 @@ +server: + port: 8084 + +eureka: + client: + service-url: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + instance: + prefer-ip-address: true + +ai: + model: + path: ${AI_MODEL_PATH:/app/model/mobilenetv2.onnx} diff --git a/config-server/src/test/java/pl/zzpj/config_server/ApplicationTests.java b/config-server/src/test/java/pl/zzpj/config_server/ApplicationTests.java new file mode 100644 index 0000000..cf12e71 --- /dev/null +++ b/config-server/src/test/java/pl/zzpj/config_server/ApplicationTests.java @@ -0,0 +1,16 @@ +package pl.zzpj.config_server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest( + properties = { + "eureka.client.enabled=false", + "CONFIG_SERVER_USER=test-user", + "CONFIG_SERVER_PASSWORD=test-password" + }) +class ApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/config-server/src/test/java/pl/zzpj/config_server/ConfigServerIntegrationTest.java b/config-server/src/test/java/pl/zzpj/config_server/ConfigServerIntegrationTest.java new file mode 100644 index 0000000..124a4b4 --- /dev/null +++ b/config-server/src/test/java/pl/zzpj/config_server/ConfigServerIntegrationTest.java @@ -0,0 +1,37 @@ +package pl.zzpj.config_server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ConfigServerIntegrationTest { + + @Autowired private TestRestTemplate restTemplate; + + @Test + void shouldReturnUnauthorizedWithoutCredentials() { + ResponseEntity response = + restTemplate.getForEntity("/ai-service/default", String.class); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void shouldReturnConfigWithCredentials() { + ResponseEntity response = + restTemplate + .withBasicAuth("admin", "admin") + .getForEntity("/ai-service/default", String.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("ai-service")); + } +} diff --git a/config-server/src/test/java/pl/zzpj/config_server/architecture/ArchitectureTest.java b/config-server/src/test/java/pl/zzpj/config_server/architecture/ArchitectureTest.java new file mode 100644 index 0000000..e18a545 --- /dev/null +++ b/config-server/src/test/java/pl/zzpj/config_server/architecture/ArchitectureTest.java @@ -0,0 +1,73 @@ +package pl.zzpj.config_server.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +/** + * ArchUnit architecture rules scoped to the config-server base package. + * config-server is an infrastructure module with no controller/service/repository + * layers, so most rules match nothing; allowEmptyShould(true) keeps them green. + */ +class ArchitectureTest { + + private static final String BASE_PACKAGE = "pl.zzpj.config_server"; + + private static JavaClasses classesUnderTest; + + @BeforeAll + static void importClasses() { + classesUnderTest = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE_PACKAGE); + } + + @Test + void restControllersShouldBeNamedController() { + ArchRule rule = classes() + .that() + .areAnnotatedWith(RestController.class) + .should() + .haveSimpleNameEndingWith("Controller") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void controllerLayerShouldNotBeAccessedByLowerLayers() { + ArchRule rule = noClasses() + .that() + .resideInAnyPackage( + "..service..", + "..repository..", + "..entity.." + ) + .should() + .accessClassesThat() + .resideInAPackage("..controller..") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void noClassShouldUseStandardStreams() { + ArchRule rule = noClasses() + .should() + .accessField(System.class, "out") + .orShould() + .accessField(System.class, "err") + .because("logging must go through Slf4j, not System.out/System.err") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..b280b28 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f9c5936 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,252 @@ +services: + postgres-db: + image: postgres:17 + container_name: postgres-db + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME} + ports: + - "5433:5432" + volumes: + - postgres_db_data:/var/lib/postgresql/data + restart: unless-stopped + + config-server: + build: + context: . + dockerfile: config-server/Dockerfile + container_name: config-server + environment: + SERVER_PORT: 8888 + SPRING_CLOUD_CONFIG_SERVER_GIT_TIMEOUT: 30 + SPRING_CLOUD_CONFIG_SERVER_GIT_CLONE_TIMEOUT: 120 + CONFIG_SERVER_USER: ${CONFIG_SERVER_USER} + CONFIG_SERVER_PASSWORD: ${CONFIG_SERVER_PASSWORD} + JAVA_TOOL_OPTIONS: -Dgit.timeout=30000 + ports: + - "8888:8888" + healthcheck: + test: + [ + "CMD", + "curl", + "-f", + "-u", + "${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}", + "http://localhost:8888/auth-server/default", + ] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + eureka-server: + build: + context: . + dockerfile: eureka-server/Dockerfile + container_name: eureka-server + environment: + SPRING_CONFIG_IMPORT: optional:configserver:http://${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}@config-server:8888 + SERVER_PORT: 8761 + SPRING_SECURITY_USER_NAME: ${EUREKA_USER} + SPRING_SECURITY_USER_PASSWORD: ${EUREKA_PASSWORD} + EUREKA_USER: ${EUREKA_USER} + EUREKA_PASSWORD: ${EUREKA_PASSWORD} + EUREKA_CLIENT_REGISTERWITHEUREKA: "false" + EUREKA_CLIENT_FETCHREGISTRY: "false" + ports: + - "8761:8761" + depends_on: + config-server: + condition: service_healthy + restart: unless-stopped + + auth-server: + build: + context: . + dockerfile: auth-server/Dockerfile + container_name: auth-server + environment: + SPRING_CONFIG_IMPORT: optional:configserver:http://${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}@config-server:8888 + EUREKA_URL: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-server:8761/eureka/ + EUREKA_USER: ${EUREKA_USER} + EUREKA_PASSWORD: ${EUREKA_PASSWORD} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + JAVA_TOOL_OPTIONS: -Dspring.profiles.active=default + ports: + - "8081:8081" + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_started + postgres-db: + condition: service_started + restart: unless-stopped + + ai-service: + build: + context: . + dockerfile: ai-service/Dockerfile + container_name: ai-service + environment: + SPRING_CONFIG_IMPORT: optional:configserver:http://${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}@config-server:8888 + EUREKA_URL: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-server:8761/eureka/ + EUREKA_USER: ${EUREKA_USER} + EUREKA_PASSWORD: ${EUREKA_PASSWORD} + ports: + - "8084:8084" + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_started + restart: unless-stopped + + subscription-service: + build: + context: . + dockerfile: subscription-service/Dockerfile + container_name: subscription-service + environment: + SPRING_CONFIG_IMPORT: optional:configserver:http://${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}@config-server:8888 + SERVER_PORT: 8085 + EUREKA_URL: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-server:8761/eureka/ + EUREKA_USER: ${EUREKA_USER} + EUREKA_PASSWORD: ${EUREKA_PASSWORD} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + SPRING_FLYWAY_SCHEMAS: subscription_schema + SPRING_FLYWAY_DEFAULT_SCHEMA: subscription_schema + SPRING_FLYWAY_TABLE: flyway_schema_history + SPRING_JPA_PROPERTIES_HIBERNATE_DEFAULT_SCHEMA: subscription_schema + ports: + - "8085:8085" + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_started + postgres-db: + condition: service_started + restart: unless-stopped + + watermark-service: + build: + context: ./watermark-service-py + dockerfile: Dockerfile + container_name: watermark-service + environment: + CONFIG_SERVER_URL: http://${CONFIG_SERVER_USER}:${CONFIG_SERVER_PASSWORD}@config-server:8888 + EUREKA_URL: http://${EUREKA_USER}:${EUREKA_PASSWORD}@eureka-server:8761/eureka/ + EUREKA_USER: ${EUREKA_USER} + EUREKA_PASSWORD: ${EUREKA_PASSWORD} + AUTH_SERVER_URL: http://auth-server:8081 + AI_SERVICE_URL: http://ai-service:8084 + SUBSCRIPTION_SERVICE_URL: http://subscription-service:8085 + WATERMARK_APP_KEY: ${WATERMARK_APP_KEY:-local-dev-watermark-secret} + WATERMARK_DEV_MODE: "true" + INSTANCE_HOSTNAME: watermark-service + ports: + - "8082:8082" + depends_on: + config-server: + condition: service_healthy + eureka-server: + condition: service_started + auth-server: + condition: service_started + ai-service: + condition: service_started + subscription-service: + condition: service_started + restart: unless-stopped + + gui: + build: + context: ./gui + dockerfile: Dockerfile + container_name: gui + ports: + - "5173:80" + depends_on: + auth-server: + condition: service_started + watermark-service: + condition: service_started + subscription-service: + condition: service_started + restart: unless-stopped + + sonarqube-db: + image: postgres:16 + container_name: sonarqube-db + environment: + POSTGRES_USER: sonar + POSTGRES_PASSWORD: sonar + POSTGRES_DB: sonarqube + volumes: + - sonarqube_db:/var/lib/postgresql/data + restart: unless-stopped + + sonarqube: + image: sonarqube:community + container_name: sonarqube + environment: + SONAR_JDBC_URL: jdbc:postgresql://sonarqube-db:5432/sonarqube + SONAR_JDBC_USERNAME: sonar + SONAR_JDBC_PASSWORD: sonar + ports: + - "9000:9000" + depends_on: + - sonarqube-db + volumes: + - sonarqube_data:/opt/sonarqube/data + - sonarqube_extensions:/opt/sonarqube/extensions + - sonarqube_logs:/opt/sonarqube/logs + restart: unless-stopped + ulimits: + nofile: + soft: 65535 + hard: 65535 + + sonar-scan: + image: gradle:8.14.4-jdk21 + user: root + working_dir: /workspace + profiles: ["tools"] + env_file: + - .env + environment: + SONAR_HOST_URL: http://sonarqube:9000 + TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal + command: + - /bin/sh + - -lc + - > + gradle --no-daemon + :auth-server:sonar + :config-server:sonar + :eureka-server:sonar + :subscription-service:sonar + -Dsonar.host.url=http://sonarqube:9000 + -Dsonar.token=$$SONARQUBE_TOKEN + volumes: + - ./:/workspace + - gradle_cache:/home/gradle/.gradle + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - sonarqube + +volumes: + sonarqube_db: + sonarqube_data: + sonarqube_extensions: + sonarqube_logs: + gradle_cache: + postgres_db_data: diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..5d8dac7 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,192 @@ +# StegoCloud API Reference + +StegoCloud is a polyglot microservice system for **encrypted PNG watermarking**. Clients embed/hide and detect/extract steganographic watermark payloads inside PNG images, with a token-based economy, role-based access control, and optional AI image classification. + +The system consists of five application services — Java (Spring Boot), Python (FastAPI), and a Svelte (SvelteKit) GUI — plus supporting infrastructure (Eureka, Config Server, PostgreSQL). There is **no API gateway**; clients reach services either directly on their host port or same-origin through the GUI nginx reverse proxy. + +--- + +## System Architecture & Routing + +### Host ports + +| Service | Container port | Host port | +|---|---|---| +| GUI (nginx) | 80 | **5173** | +| auth-server | 8081 | **8081** | +| watermark-service (Python/FastAPI) | 8082 | **8082** | +| ai-service | 8084 | **8084** | +| subscription-service | 8085 | **8085** | +| config-server | 8888 | 8888 | +| eureka-server | 8761 | 8761 | +| postgres-db | 5432 | 5433 | + +### GUI nginx same-origin routing + +The frontend served at `http://localhost:5173` proxies API calls to the right backend via nginx (`gui/nginx.conf`, `client_max_body_size 20m`, **no path rewrite**): + +| Public path prefix | Proxied to | +|---|---| +| `/auth/` | auth-server:8081 | +| `/api/subscriptions/` | subscription-service:8085 | +| `/api/payments/` | subscription-service:8085 | +| `/api/tokens/` | subscription-service:8085 | +| `/api/` (catch-all) | watermark-service:8082 | +| `/` (fallback) | SPA `index.html` | + +> **Trailing-slash matters.** `/api/subscriptions` (no slash) falls through to the `/api/` catch-all and hits watermark-service, not subscription-service. + +**ai-service is internal-only.** It is not proxied by nginx and is reachable only from watermark-service via the `AI_SERVICE_URL` environment variable (`app/ai_client.py`). + +```mermaid +flowchart LR + Client["Browser / curl"] --> GUI["GUI nginx :5173"] + GUI -->|"/auth/"| Auth["auth-server :8081"] + GUI -->|"/api/subscriptions/"| Sub["subscription-service :8085"] + GUI -->|"/api/payments/"| Sub + GUI -->|"/api/tokens/"| Sub + GUI -->|"/api/"| WM["watermark-service :8082"] + GUI -->|"/"| SPA["SPA index.html"] + + WM -->|"POST /auth/validate"| Auth + WM -->|"POST /api/classify"| AI["ai-service :8084"] + WM -->|"reserve/consume/release tokens"| Sub + + Auth -.->|"Eureka"| Eureka["eureka-server :8761"] + Sub -.-> Eureka + AI -.-> Eureka + WM -.-> Eureka +``` + +--- + +## Known Issues + +### Token-reservation endpoints return 404 (breaks paid watermark operations) + +`controller/TokenReservationController.java` in subscription-service declares handler methods and an `@Tag` annotation, but the class is **missing** both `@RestController` and a class-level `@RequestMapping`. As written it is never registered as a Spring MVC handler, so all paths under `/api/tokens/reservations` return **404**. + +This breaks every paid watermark operation — `watermark-service-py/app/subscription_client.py` calls these endpoints to reserve, consume, and release tokens. The GUI nginx already proxies `/api/tokens/` to subscription-service, and the client posts to `/api/tokens/reservations`, so the intended base path is unambiguous. + +**Fix:** add `@RestController` and `@RequestMapping("/api/tokens/reservations")` to `TokenReservationController`. + +See [`subscription-service.md`](./subscription-service.md#token-reservation) for the full intended contract. + +--- + +## Authentication Model + +1. **Login.** `POST /auth/login` with `{ email, password }` → receives a **JWT**. +2. **JWT structure** (HMAC-SHA via jjwt): `sub` = username, `userId` = numeric ID (Long), `role` = `USER` or `ADMIN`, `iat`, `exp` = issued + 24h. +3. **Use.** Pass as `Authorization: Bearer ` on protected endpoints. +4. **Validation.** Protected services do **not** verify the signature locally. They call auth-server `POST /auth/validate?token=` which returns a bare `boolean`. + - subscription-service and ai-service use a Feign `AuthClient`. + - watermark-service uses httpx in `app/auth.py`. +5. **Role enforcement.** Only watermark-service enforces ADMIN — the `extract` endpoint requires either the watermark owner or an ADMIN token. subscription-service and ai-service require only a *valid* token (no role gate). + +--- + +## Token Economy & Plans + +Every paid watermark operation costs tokens. Tokens are reserved before work begins, then consumed on success or released on error. `CAPACITY_CHECK` costs 0 and reserves nothing. + +### Operation costs + +| Operation | Cost | +|---:|---:| +| CAPACITY_CHECK | 0 | +| DETECT | 1 | +| EXTRACT | 2 | +| VISUALIZE | 3 | +| EMBED_768 | 5 | +| EMBED_1024 | 8 | +| AI_CLASSIFICATION | 2 | + +### Plans + +| Plan | Monthly tokens | Allowed operations | +|---|---:|---| +| FREE | 50 | CAPACITY_CHECK, DETECT, EMBED_768 | +| STANDARD | 500 | + EXTRACT, VISUALIZE, EMBED_1024 | +| PRO | 2500 | all, incl. AI_CLASSIFICATION | + +### Transition rules + +- Allowed: FREE→STANDARD, FREE→PRO, STANDARD→PRO. +- Downgrade or repurchasing the active plan is **rejected**. +- A paid plan is valid **one month from purchase/upgrade**. Upgrading starts a new month and **adds** the new plan's full monthly token pool to the current balance. +- On expiry, the plan reverts to FREE and the balance resets to 50. + +### Reservation lifecycle + +1. **Reserve** → tokens locked for 15 minutes (`TokenReservationPolicy`). +2. **Consume** → tokens deducted permanently. +3. **Release** → tokens returned to balance. + +Statuses: `RESERVED`, `CONSUMED`, `RELEASED`. See [`subscription-service.md`](./subscription-service.md#token-reservation) for the full endpoint contract and error codes. + +--- + +## Services Index + +| Service | Port | Base path | Purpose | Swagger UI | +|---|---|---|---|---| +| [`auth-server.md`](./auth-server.md) | 8081 | `/auth` | User registration, login, JWT issuance & validation | [`/swagger-ui/index.html`](http://localhost:8081/swagger-ui/index.html) | +| [`subscription-service.md`](./subscription-service.md) | 8085 | `/api/subscriptions`, `/api/payments`, `/api/tokens` | Plan management, mock payments, token balances & reservations | [`/swagger-ui/index.html`](http://localhost:8085/swagger-ui/index.html) | +| [`ai-service.md`](./ai-service.md) | 8084 | `/api` | Image classification via MobileNetV2 ONNX (internal) | [`/swagger-ui/index.html`](http://localhost:8084/swagger-ui/index.html) | +| [`watermark-service.md`](./watermark-service.md) | 8082 | `/api/watermark` | PNG watermark embed, detect, extract, visualize, capacity check | [`/docs`](http://localhost:8082/docs) | +| [`infrastructure.md`](./infrastructure.md) | 8761 / 8888 | — | Eureka service registry & Spring Cloud Config Server | — | + +--- + +## Demo Accounts + +| Login | Password | Role | Plan | +|---|---|---|---| +| admin@gmail.com | admin | ADMIN | PRO | +| free@gmail.com | free | USER | FREE | +| standard@gmail.com | standard | USER | STANDARD | +| pro@gmail.com | pro | USER | PRO | +| lowbalance@gmail.com | lowbalance | USER | FREE (low token balance) | + +--- + +## Error Model + +Each service follows a different convention: + +- **auth-server** (`@RestControllerAdvice`): validation/domain errors return a JSON **field map** `{ "": "" }` (400/409). Malformed JSON / unknown properties return plain text. Bad credentials return plain text. + +- **subscription-service**: **No `@RestControllerAdvice`**. Most domain violations surface Spring Boot's default error response (`{ timestamp, status, error, path }`) as **HTTP 500**. The exception is **token-reservation endpoints** (see Known Issues above) which map decisions to explicit status codes with structured `TokenReservationErrorResponse` `{ code, message }`: + - `403 Forbidden` → `OPERATION_NOT_ALLOWED` + - `409 Conflict` → `INSUFFICIENT_TOKENS`, `PLAN_NOT_FOUND`, `SUBSCRIPTION_EXPIRED` + +- **watermark-service** (FastAPI): errors are JSON `{ "detail": }`, `{ "detail": [{ "loc", "msg", "type" }] }` for 422 request validation, or any dict passed to `JSONResponse`. Auth failures return `{ "detail": { "error": "..." } }` (401/503). + +- **ai-service** (`@RestControllerAdvice`): 400 for `IllegalArgumentException`, 500 for everything else. + +--- + +## Machine-Readable Specification + +A combined OpenAPI 3.1 specification is maintained at the repository root: + +- **`stegocloud-openapi.json`** — [`../../stegocloud-openapi.json`](../../stegocloud-openapi.json) — single-file aggregate of all service APIs. +- **`api-docs.html`** — [`../../api-docs.html`](../../api-docs.html) — rendered HTML view of the combined spec. + +To serve locally (e.g., for the Swagger UI viewer or HTML doc): + +```bash +cd +python3 -m http.server 8000 +# Open http://localhost:8000/stegocloud-openapi.json +``` + +> **Note on the combined spec.** `stegocloud-openapi.json` has been rebuilt to match these per-service docs: real on-service paths with per-path `servers`, complete request/response schemas (including the token-reservation contract), accurate status codes, and error responses. The token-reservation paths are included as the intended contract and carry the same KNOWN ISSUE note. + +--- + +- [Back to index](./README.md) +- [Combined OpenAPI spec](../../stegocloud-openapi.json) +- [Combined API docs (HTML)](../../api-docs.html) +- [auth-server](./auth-server.md) · [subscription-service](./subscription-service.md) · [ai-service](./ai-service.md) · [watermark-service](./watermark-service.md) · [infrastructure](./infrastructure.md) diff --git a/docs/api/ai-service.md b/docs/api/ai-service.md new file mode 100644 index 0000000..b927863 --- /dev/null +++ b/docs/api/ai-service.md @@ -0,0 +1,106 @@ +# AI Service API + +The AI Service (port **8084**, base path `/api`) provides image classification using a MobileNetV2 ONNX model with CategoryMapper, reducing ~1000 ImageNet labels to 13 broad categories. It is an **internal-only** service — not proxied by the GUI nginx; in normal operation it is reached only by watermark-service via `AI_SERVICE_URL` during embed operations. + +- **Auth:** Bearer JWT required on every request (exceptions: Swagger/OpenAPI docs). JwtFilter validates the token via Feign `AuthClient` calling `POST /auth/validate?token=...` on auth-server. No role gate — any valid JWT passes. +- **Token cost:** `AI_CLASSIFICATION` = **2 tokens** (PRO plan only; see [subscription-service](./subscription-service.md)). +- **Swagger UI:** `http://localhost:8084/swagger-ui/index.html` +- **Combined OpenAPI spec:** `../../stegocloud-openapi.json` / `../../api-docs.html` + +## Endpoints + +| Method | Path | Auth | Notes | +|--------|--------------------|------------|------------------------------------------| +| POST | `/api/classify` | Bearer JWT | Classify image; costs 2 tokens (PRO plan) | + +--- + +## POST /api/classify + +Classifies the content of a provided image file using the ONNX MobileNetV2 model (13 broad categories via CategoryMapper). This endpoint is called by watermark-service's embed flow when the caller's plan and token balance permit AI_CLASSIFICATION. + +**Auth:** Bearer JWT (no role gate). **Token operation:** AI_CLASSIFICATION (cost: 2). **PRO plan required.** + +### Request + +`Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|---------------|----------|--------------------------------------------| +| file | `MultipartFile` | yes | The image file. Accepted image formats depend on what ONNX runtime can decode. | + +**Multipart limits:** +- `max-file-size`: **20MB** +- `max-request-size`: **25MB** + +Larger files are rejected at the servlet container level before reaching the controller. + +### Responses + +| Code | Content-Type | Body | +|------|--------------------|-------------------------------------------| +| 200 | `application/json` | `ClassificationResult` (see below) | + +**200 OK — ClassificationResult** + +```json +{ + "label": "golden retriever", + "category": "dog", + "confidence": 0.9321, + "categoryConfidence": 0.9700, + "top3": [ + { "label": "golden retriever", "confidence": 0.9321 }, + { "label": "Labrador retriever", "confidence": 0.0310 }, + { "label": "Rhodesian ridgeback", "confidence": 0.0085 } + ] +} +``` + +| Field | Type | Description | +|----------------------|-----------------|-------------------------------------------------------------| +| `label` | string | The most specific ImageNet class label (e.g. "golden retriever") | +| `category` | string | The broad category mapped from the label (e.g. "dog") | +| `confidence` | double | Probability of the top prediction in range **[0, 1]** | +| `categoryConfidence` | double | Aggregated confidence for the broad category in range **[0, 1]** | +| `top3` | array of object | The three highest-scoring predictions | + +Each `top3` entry: + +| Field | Type | Description | +|-------------|--------|------------------------------------------------| +| `label` | string | ImageNet class label | +| `confidence` | double | Probability for that label in range **[0, 1]** | + +### Errors + +- **400 Bad Request** — `IllegalArgumentException` (e.g. validation failure in the service layer). Body: `{ "error": "" }`. +- **401 Unauthorized** — missing or invalid Bearer JWT. Returned by `JwtFilter` before the endpoint is reached. +- **500 Internal Server Error** — unreadable/oversized image, ONNX runtime errors (`OrtException`), I/O errors (`IOException`), or any other unhandled exception. Body: `{ "error": "Internal server error" }`. + +### Example + +Classify an image using a bearer token: + +```bash +curl -X POST http://localhost:8084/api/classify \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@photo.jpg" +``` + +> **Note:** In normal operation this endpoint is invoked by watermark-service, not directly by browsers. The GUI nginx does not proxy `/api/classify`. To test directly during development, use port 8084 and a JWT obtained from `POST /auth/login` (see [auth-server](./auth-server.md)). + +--- + +### Implementation details + +- **Controller:** `ClassificationController.java` (`ai-service/src/main/java/pl/zzpj/ai_service/ClassificationController.java`) — `@PostMapping(value = "/classify", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)`. +- **Response model:** `ClassificationResult.java` — Java record `{ label, category, confidence, categoryConfidence, top3: List }`. `TopPrediction` is a nested record `{ label, confidence }`. +- **Error handler:** `GlobalExceptionHandler.java` — `IllegalArgumentException` → 400 `{ "error": "..." }`; all other exceptions → 500 `{ "error": "Internal server error" }` with a server-side error log. +- **Security:** `SecurityConfig.java` — stateless session, JwtFilter on every request except `/v3/api-docs/**`, `/swagger-ui/**`, `/swagger-ui.html`, `/error`. No role gate. +- **Model:** MobileNetV2 ONNX runtime; `CategoryMapper` reduces ~1000 ImageNet labels to 13 broad categories. +- **Multipart limits:** configured in `application.yaml` (max-file-size 20MB, max-request-size 25MB). + +--- + +[Back to index](./README.md) • [Combined OpenAPI spec](../../stegocloud-openapi.json) • [HTML docs](../../api-docs.html) • [auth-server](./auth-server.md) • [subscription-service](./subscription-service.md) • [watermark-service](./watermark-service.md) • [infrastructure](./infrastructure.md) diff --git a/docs/api/auth-server.md b/docs/api/auth-server.md new file mode 100644 index 0000000..2a36f19 --- /dev/null +++ b/docs/api/auth-server.md @@ -0,0 +1,281 @@ +# Auth Server API + +The auth-server is the central identity provider for the StegoCloud platform. It handles user registration, authentication, and JWT token validation. Every other microservice relies on this service to verify bearer tokens. + +- **Host port:** `http://localhost:8081` +- **Base path:** `/auth` +- **Auth:** All three endpoints are **public** (no authentication required). The service does not expose any actuator endpoints. +- **Swagger UI:** `http://localhost:8081/swagger-ui/index.html` + +Source files referenced: `controller/AuthController.java`, `dto/*.java`, `service/JwtService.java`, `security/SecurityConfig.java`, `exception/GlobalExceptionHandler.java`. + +## Endpoints summary + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| POST | `/auth/register` | Public | Create a new user account | +| POST | `/auth/login` | Public | Authenticate and receive a JWT | +| POST | `/auth/validate` | Public | Verify a JWT token's validity | + +--- + +## Authentication model + +The auth-server issues JSON Web Tokens (JWT) signed with HMAC-SHA (via the jjwt library). Every token contains the following claims: + +| Claim | Type | Description | +|-------|------|-------------| +| `sub` | string | Username | +| `userId` | number (int64) | User's internal database ID | +| `role` | string | `"USER"` or `"ADMIN"` | +| `iat` | number | Issued-at timestamp (epoch seconds) | +| `exp` | number | Expiration timestamp (epoch seconds); 24 hours from issue | + +The signing secret is configured via `app.jwt.secret` (Spring property), supplied at runtime through the `JWT_SECRET_KEY` environment variable. Passwords are hashed with BCrypt cost **12** (`SecurityConfig.java:37`). + +### How other services use the JWT + +All protected services require the JWT as a **Bearer token** in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Services **do not verify the signature locally**. Instead they forward the token to auth-server's `/auth/validate` endpoint, which returns a bare boolean `true` or `false`. This is implemented via: + +- **subscription-service & ai-service** — Feign `AuthClient` calling `POST /auth/validate?token=...` +- **watermark-service (Python)** — `httpx` POST to the same URL in `app/auth.py` + +Role checks: only watermark-service enforces ADMIN (owner-or-admin logic). subscription-service and ai-service require only a valid token (authentication, no role gate). + +--- + +## POST /auth/register + +Creates a new user account. Registered users always receive the `USER` role. + +**Auth:** Public +**Content-Type:** `application/json` + +### Request body — `RegisterRequest` + +| Field | Type | Required | Notes / Validation | +|-------|------|----------|-------------------| +| `username` | string | yes | `@NotBlank`, `@Size(min=3, max=50)` | +| `email` | string | yes | `@NotBlank`, `@Email` | +| `password` | string | yes | `@NotBlank` | + +Any unknown JSON property in the request body is rejected (`@JsonAnySetter` → `UnknownRegistrationPropertyException`). The body is parsed strictly — a single extra field causes a 400 error. + +### Responses + +| Code | Content-Type | Body | +|------|-------------|------| +| **201 Created** | `application/json` | `RegisterResponse` (see below) | +| **400 Bad Request** | `application/json` | Field-validation error map | +| **400 Bad Request** | `text/plain` | Malformed JSON or unknown property | +| **409 Conflict** | `application/json` | Duplicate-field error map | + +**201 Created** — `RegisterResponse`: + +```json +{ + "id": 42, + "username": "jdoe", + "email": "jdoe@example.com", + "role": "USER" +} +``` + +Fields: `id` (int64), `username` (string), `email` (string), `role` (`"USER"` or `"ADMIN"`). New users always get `"USER"`. + +### Errors + +- **400** — validation failure: JSON map keyed by field name with the validation message. + ```json + { "username": "size must be between 3 and 50" } + ``` +- **400** — malformed JSON (parse failure): plain text `"Invalid JSON format or unknown properties in request."` +- **400** — unknown property: plain text message from `UnknownRegistrationPropertyException`, e.g. `"Unknown property: extraField"` +- **409** — duplicate email or username: JSON map with the conflicting field and a descriptive message. + ```json + { "email": "Email already in use" } + ``` + +### Example + +```bash +curl -X POST http://localhost:8081/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "jdoe", + "email": "jdoe@example.com", + "password": "secret123" + }' +``` + +

+GUI same-origin alternative +When behind the nginx reverse proxy (port 5173), the same-origin path is `/auth/register`: + +```bash +curl -X POST http://localhost:5173/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "jdoe", + "email": "jdoe@example.com", + "password": "secret123" + }' +``` +
+ +--- + +## POST /auth/login + +Authenticates a user by email and password, returning a JWT token on success. + +**Auth:** Public +**Content-Type:** `application/json` + +### Request body — `LoginRequest` + +| Field | Type | Required | Notes / Validation | +|-------|------|----------|-------------------| +| `email` | string | yes | `@NotBlank` | +| `password` | string | yes | `@NotBlank` | + +### Responses + +| Code | Content-Type | Body | +|------|-------------|------| +| **200 OK** | `application/json` | `LoginResponse` with JWT token | +| **400 Bad Request** | `application/json` | Field-validation error map | +| **401 Unauthorized** | `text/plain` | Error description | + +**200 OK** — `LoginResponse`: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqZG9lIiwidXNlcklkIjo0Miwicm9sZSI6IlVTRVIiLCJpYXQiOjE3MTg3NzQ0MDAsImV4cCI6MTcxODg2MDgwMH0.example" +} +``` + +### Errors + +- **400** — validation failure: same shape as register (field → message map). +- **401** — wrong password: plain text `"Invalid password"`. +- **401** — email not found: plain text `"User not found"`. +- **401** — other credential errors: plain text `"Invalid email or password."` (from the `@ExceptionHandler` catching `BadCredentialsException` and `EmailNotFoundException`). + +Use the token in subsequent protected calls: + +``` +Authorization: Bearer +``` + +### Example + +```bash +# Capture the token into a shell variable +TOKEN=$(curl -s -X POST http://localhost:8081/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "jdoe@example.com", + "password": "secret123" + }' | jq -r '.token') + +echo "$TOKEN" +``` + +
+GUI same-origin alternative + +```bash +TOKEN=$(curl -s -X POST http://localhost:5173/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "jdoe@example.com", + "password": "secret123" + }' | jq -r '.token') +``` +
+ +--- + +## POST /auth/validate?token=\ + +Validates a JWT token's signature and expiration. Used by other services to verify bearer tokens without sharing the signing secret. + +**Auth:** Public +**Content-Type:** `application/x-www-form-urlencoded` (query parameter) + +### Request parameters + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `token` | string | yes | The JWT string to validate | + +### Responses + +| Code | Content-Type | Body | +|------|-------------|------| +| **200 OK** | `application/json` | `true` or `false` | + +**200 OK** — the endpoint returns a bare boolean. It never throws on a bad token; parsing exceptions are caught and converted to `false`. + +Valid token: +```json +true +``` + +Expired or tampered token: +```json +false +``` + +### Example + +```bash +# Validate the token captured from login +curl -s -X POST "http://localhost:8081/auth/validate?token=$TOKEN" +# → true + +# A garbage token +curl -s -X POST "http://localhost:8081/auth/validate?token=invalid.jwt.string" +# → false +``` + +
+GUI same-origin alternative + +```bash +curl -s -X POST "http://localhost:5173/auth/validate?token=$TOKEN" +``` +
+ +--- + +## Demo accounts + +These accounts are seeded by Flyway and available for testing: + +| Login | Password | Role | Plan | +|-------|----------|------|------| +| admin@gmail.com | admin | ADMIN | PRO | +| free@gmail.com | free | USER | FREE | +| standard@gmail.com | standard | USER | STANDARD | +| pro@gmail.com | pro | USER | PRO | +| lowbalance@gmail.com | lowbalance | USER | FREE (low balance) | + +--- + +## Related documentation + +- [API Index](./README.md) +- [Combined OpenAPI spec](../../stegocloud-openapi.json) +- [Combined API docs](../../api-docs.html) +- [Subscription Service](./subscription-service.md) — consumes auth tokens +- [AI Service](./ai-service.md) — consumes auth tokens +- [Watermark Service](./watermark-service.md) — consumes auth tokens, enforces ADMIN role +- [Infrastructure](./infrastructure.md) — Eureka, Config Server diff --git a/docs/api/infrastructure.md b/docs/api/infrastructure.md new file mode 100644 index 0000000..30283eb --- /dev/null +++ b/docs/api/infrastructure.md @@ -0,0 +1,199 @@ +# Infrastructure API + +This document covers the StegoCloud infrastructure services — Eureka service discovery (port 8761), Spring Cloud Config Server (port 8888), actuator/health endpoints per service, and the service-registration wiring visible to operators. All infrastructure endpoints are **internal** (not proxied through the GUI nginx). + +- **Eureka dashboard**: `http://localhost:8761/` (HTML, HTTP Basic Auth) +- **Eureka registry REST**: `http://localhost:8761/eureka/apps` +- **Config Server**: `http://localhost:8888//` +- **Combined OpenAPI**: `../../stegocloud-openapi.json` + +--- + +## Endpoints summary + +| Service | Method | Path | Auth | Notes | +|---|---|---|---|---| +| eureka-server | GET | `/` | HTTP Basic | Web dashboard, HTML | +| eureka-server | GET | `/eureka/apps` | HTTP Basic | Registry dump, JSON with `Accept: application/json` | +| config-server | GET | `/{application}/{profile}` | HTTP Basic | Fetch configuration | +| config-server | GET | `/{application}/{profile}/{label}` | HTTP Basic | Fetch config at specific git label | +| subscription-service | GET | `/actuator/health` | Public | Spring Boot Actuator health | +| watermark-service | GET | `/health` | Public | FastAPI health | + +--- + +## Eureka Server (port 8761) + +The Eureka server provides service discovery for the Java services (auth-server, subscription-service, ai-service) and the Python watermark-service. It runs on port 8761 with HTTP Basic Authentication on **all** routes; the `/eureka/**` path is CSRF-exempted to allow programmatic registration (`eureka-server/.../config/SecurityConfig.java:16`). + +### Auth + +**HTTP Basic Authentication** on every route. Credentials set via compose environment variables: + +- `EUREKA_USER` → `SPRING_SECURITY_USER_NAME` +- `EUREKA_PASSWORD` → `SPRING_SECURITY_USER_PASSWORD` + +(`docker-compose.yml:52-53`) + +### GET / (Web Dashboard) + +- **Summary**: Eureka web dashboard — HTML page listing registered instances, status and health links. +- **Auth**: HTTP Basic (any valid EUREKA_USER/EUREKA_PASSWORD). +- **Response**: HTML. + +### GET /eureka/apps (Registry REST API) + +- **Summary**: Returns the full registry as XML (default) or JSON (with `Accept: application/json`). +- **Auth**: HTTP Basic. +- **Request headers**: `Accept: application/json` for JSON output. +- **Response 200 OK**: XML or JSON registry dump with all registered application instances. + +#### Example + +```bash +curl -u "$EUREKA_USER:$EUREKA_PASSWORD" \ + -H "Accept: application/json" \ + http://localhost:8761/eureka/apps +``` + +### Service registration + +Services register with Eureka via the `EUREKA_URL` environment variable, which embeds credentials in the URL: + +``` +http://:@eureka-server:8761/eureka/ +``` + +Logical application names registered by each service (`docker-compose.yml`): + +| Service | Eureka name | Env variable | +|---|---|---| +| auth-server | `AUTH-SERVER` | `EUREKA_URL` + `SPRING_APPLICATION_NAME` | +| subscription-service | `SUBSCRIPTION-SERVICE` | `EUREKA_URL` | +| ai-service | `AI-SERVICE` | `EUREKA_URL` | +| watermark-service | `WATERMARK-SERVICE` | `EUREKA_URL` | + +Eureka server itself does **not** self-register or fetch from the registry: `EUREKA_CLIENT_REGISTERWITHEUREKA=false`, `EUREKA_CLIENT_FETCHREGISTRY=false` (`docker-compose.yml:56-57`). + +--- + +## Config Server (port 8888) + +Spring Cloud Config Server backed by an external Git repository, with a native fallback for `ai-service`. Runs on port 8888. + +### Auth + +**HTTP Basic Authentication** on every request (`config-server/.../config/SecurityConfig.java:16-18`). Credentials from compose environment variables: + +- `CONFIG_SERVER_USER` (default `admin`) +- `CONFIG_SERVER_PASSWORD` (default `admin`) + +(`config-server/src/main/resources/application.yaml:9-11`) + +### Git backend + +- **URI**: `https://github.com/bkolacinski/pl-java2026-config.git` (`application.yaml:17`) +- **Default label**: `main` (`application.yaml:18`) +- **Native fallback**: `classpath:/config/` — holds only `ai-service.yaml` (`application.yaml:19-20`); `ai-service.yaml` configures `server.port: 8084`, Eureka client default zone, and AI model path (`config/ai-service.yaml`). + +### Endpoints + +### GET /{application}/{profile} + +- **Summary**: Fetch configuration for a given application and profile. Merges Git-backed config with the native fallback (if applicable). +- **Auth**: HTTP Basic. +- **Path params**: + - `application` (string, required) — e.g. `auth-server`, `subscription-service`, `ai-service`. + - `profile` (string, required) — e.g. `default`, `dev`, `prod`. +- **Response 200 OK**: JSON with `{ name, profiles, label, version, propertySources[] }`. + +### GET /{application}/{profile}/{label} + +- **Summary**: Fetch configuration pinned to a specific Git label (branch, tag, or commit SHA). +- **Auth**: HTTP Basic. +- **Path params**: + - `application` (string, required) + - `profile` (string, required) + - `label` (string, required) — Git branch, tag, or commit id. +- **Response 200 OK**: Same shape as above. + +#### Example + +```bash +curl -u "$CONFIG_SERVER_USER:$CONFIG_SERVER_PASSWORD" \ + http://localhost:8888/auth-server/default +``` + +```bash +curl -u "$CONFIG_SERVER_USER:$CONFIG_SERVER_PASSWORD" \ + http://localhost:8888/subscription-service/default/main +``` + +### Health check (compose) + +The compose file healthchecks the config server by curling `http://localhost:8888/auth-server/default` with HTTP Basic credentials (`docker-compose.yml:29-38`). This is **not** an Actuator endpoint — it uses the standard config query, which succeeds with a 200 if the server is healthy and can serve config. + +--- + +## Actuator / Health endpoints (per service) + +| Service | Endpoint | Auth | Framework | Notes | +|---|---|---|---|---| +| auth-server | — | — | — | No actuator dependency | +| ai-service | — | — | — | No actuator dependency | +| eureka-server | — | — | — | No actuator dependency | +| config-server | — | — | — | No actuator dependency | +| subscription-service | `GET /actuator/health` | Public | Spring Boot Actuator | Also `/actuator/health/**` permitted. Other actuator paths require auth. Exposure configured in external config repo; Spring defaults expose `health` (+ `info`). | +| watermark-service | `GET /health` | Public | FastAPI | Registered on app root, **not** under the `/api/watermark` prefix (`app/main.py:46`). Returns `{ "status": "UP" }` on 200. | + +### subscription-service + +```bash +# Public — no bearer token required +curl http://localhost:8085/actuator/health +``` + +Response: +```json +{ + "status": "UP" +} +``` + +### watermark-service + +```bash +# Public — no bearer token required +curl http://localhost:8082/health +``` + +Response: +```json +{ + "status": "UP" +} +``` + +--- + +## Service dependencies and startup order (docker-compose.yml) + +1. **postgres-db** — database for auth-server, subscription-service. +2. **config-server** — must be healthy before any Spring service can start (they import config via `SPRING_CONFIG_IMPORT`). Healthcheck curls `/auth-server/default`. +3. **eureka-server** — depends on config-server; starts after config-server is healthy. Does not wait for a health endpoint. +4. **auth-server** — depends on config-server (healthy), eureka-server (started), postgres-db (started). +5. **subscription-service** — same dependencies as auth-server. +6. **ai-service** — depends on config-server (healthy), eureka-server (started). +7. **watermark-service** — Python/FastAPI, depends on config-server, eureka-server, auth-server, ai-service, subscription-service (all started). +8. **gui** — nginx, depends on auth-server, watermark-service, subscription-service. + +--- + +## Cross-links + +- [API Reference Index](./README.md) +- [Combined OpenAPI spec (`stegocloud-openapi.json`)](../../stegocloud-openapi.json) +- [Auth Server API](./auth-server.md) +- [Subscription Service API](./subscription-service.md) +- [AI Service API](./ai-service.md) +- [Watermark Service API](./watermark-service.md) diff --git a/docs/api/subscription-service.md b/docs/api/subscription-service.md new file mode 100644 index 0000000..38ea385 --- /dev/null +++ b/docs/api/subscription-service.md @@ -0,0 +1,576 @@ +# Subscription-Service API + +The subscription-service manages the StegoCloud token economy — user plans, token balances, mock payments (upgrade/downgrade simulation), and token reservation for paid watermark operations. Runs on port **8085** with base paths `/api/subscriptions/`, `/api/payments/`, and `/api/tokens/`. All endpoints except public health require a Bearer JWT; the `JwtFilter` validates the token via auth-server and sets the principal to the JWT `userId`. + +Swagger UI: `http://localhost:8085/swagger-ui/index.html` + +─ + +## Token economy & plans + +### TokenOperation enum + +Defined in `domain/token/TokenOperation.java`. + +| Operation | Cost | +|----------:|:-----| +| CAPACITY_CHECK | 0 | +| DETECT | 1 | +| EXTRACT | 2 | +| VISUALIZE | 3 | +| EMBED_768 | 5 | +| EMBED_1024 | 8 | +| AI_CLASSIFICATION | 2 | + +### PlanCode enum + +`FREE`, `STANDARD`, `PRO`. + +| Plan | Monthly tokens | Allowed operations | +|:-----|:--------------|:-------------------| +| FREE | 50 | CAPACITY_CHECK, DETECT, EMBED_768 | +| STANDARD | 500 | CAPACITY_CHECK, DETECT, EMBED_768, EXTRACT, VISUALIZE, EMBED_1024 | +| PRO | 2500 | all, incl. AI_CLASSIFICATION | + +### Upgrade / transition rules + +- Allowed transitions: **FREE → STANDARD**, **FREE → PRO**, **STANDARD → PRO**. +- Downgrade or re-purchasing the currently active plan is **rejected** (`IllegalArgumentException` → HTTP 500 — no `@RestControllerAdvice`). +- A paid plan is valid for **one month** from purchase/upgrade. +- Upgrading starts a **new month** and **adds** the new plan's full monthly token pool to the current balance (tokens are additive). +- On expiry the plan reverts to **FREE** and the balance resets to 50. + +### Reservation lifecycle + +Statuses defined in `TokenReservationStatus`: `RESERVED`, `CONSUMED`, `RELEASED`. + +1. **RESERVED** — tokens are temporarily held (15-minute TTL per `TokenReservationPolicy`). +2. **CONSUMED** — operation completed; tokens deducted permanently. +3. **RELEASED** — operation failed or aborted; tokens returned to balance. + +### Payment session Status enum + +`PENDING`, `SUCCEEDED`, `FAILED`, `CANCELLED`. + +─ + +## Endpoints summary + +| Method | Path | Auth | Notes | +|:-------|:-----|:-----|:------| +| POST | `/api/payments/mock/sessions` | Bearer JWT | Create payment session (upgrade-only) | +| POST | `/api/payments/mock/sessions/{sessionId}/succeed` | Bearer JWT | Finalize as succeeded | +| POST | `/api/payments/mock/sessions/{sessionId}/fail` | Bearer JWT | Finalize as failed | +| POST | `/api/payments/mock/sessions/{sessionId}/cancel` | Bearer JWT | Finalize as cancelled | +| GET | `/api/subscriptions/status` | **PUBLIC** | Service health | +| GET | `/api/subscriptions/plans` | Bearer JWT | List available plans | +| GET | `/api/subscriptions/me` | Bearer JWT | Current subscription for caller | +| GET | `/api/subscriptions/me/tokens` | Bearer JWT | Token balance for caller | +| POST | `/api/tokens/reservations` | Bearer JWT | Reserve tokens (⚠️ see KNOWN ISSUE) | +| POST | `/api/tokens/reservations/{reservationId}/consume` | Bearer JWT | Consume (deduct) reserved tokens | +| POST | `/api/tokens/reservations/{reservationId}/release` | Bearer JWT | Release (return) reserved tokens | + +> The GUI nginx reverse proxy forwards `/api/subscriptions/`, `/api/payments/`, and `/api/tokens/` to subscription-service:8085. Note: `/api/subscriptions` (no trailing slash) falls through to the `/api/` catch‑all (watermark-service). + +─ + +## Mock payments + +All payment endpoints require a valid Bearer JWT. The caller identity is resolved from the JWT principal via `UserIdentityResolver`. + +### POST /api/payments/mock/sessions + +Create a payment session for upgrading the caller's plan. + +**Auth:** Bearer JWT + +**Tokens:** none (no token cost — payment is a mock operation) + +**Request** + +`Content-Type: application/json` + +| Field | Type | Required | Notes | +|:------|:-----|:---------|:------| +| `targetPlan` | string | yes | `"FREE"`, `"STANDARD"`, or `"PRO"`. Validated as an allowed upgrade from the caller's current plan. | + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `PaymentSessionResponse` | +| 401 | — | Missing/invalid bearer token (JwtFilter) | +| 500 | `application/json` | Default Spring error — domain violation (no advice) | + +**200 body (`PaymentSessionResponse`)** + +```json +{ + "id": "3b3c9e1a-5d7f-4a2b-9c8d-1e2f3a4b5c6d", + "userId": "7", + "targetPlan": "STANDARD", + "status": "PENDING" +} +``` + +**Errors** + +- **401** — bearer token missing or invalid (JwtFilter). +- **500** — `IllegalArgumentException` if the target plan is not an upgrade (downgrade or same plan) or `IllegalStateException` if there is a pending (unfinalized) session. Surface as Spring Boot's default error JSON `{timestamp, status, error, path}` because the service lacks a `@RestControllerAdvice`. + +**Example** + +```bash +curl -X POST http://localhost:8085/api/payments/mock/sessions \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"targetPlan": "STANDARD"}' +``` + +GUI same-origin: `POST /api/payments/mock/sessions` with the same body and auth header. + +─ + +### POST /api/payments/mock/sessions/{sessionId}/succeed + +Finalize a payment session as **succeeded**. Applies the plan upgrade: sets `activeUntil = now + 1 month` and adds the plan's monthly tokens to the caller's balance. + +**Auth:** Bearer JWT + +**Path params** + +| Field | Type | Required | Notes | +|:------|:-----|:---------|:------| +| `sessionId` | UUID | yes | The payment session identifier. | + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `PaymentSessionResponse` with `status: "SUCCEEDED"` | +| 401 | — | Missing/invalid bearer token | +| 500 | `application/json` | Domain / ownership error | + +**200 body** + +```json +{ + "id": "3b3c9e1a-5d7f-4a2b-9c8d-1e2f3a4b5c6d", + "userId": "7", + "targetPlan": "STANDARD", + "status": "SUCCEEDED" +} +``` + +**Properties** +- **Idempotent**: repeating with the same session returns the same response unchanged. +- **Owner-scoped**: only the user who created the session may finalize it. + +**Errors** +- **500** — `"Session not found"` (unknown sessionId), `"Payment session belongs to another user"`, `"Session already completed with different outcome"`. All surface as default Spring error JSON. + +**Example** + +```bash +curl -X POST "http://localhost:8085/api/payments/mock/sessions/3b3c9e1a-5d7f-4a2b-9c8d-1e2f3a4b5c6d/succeed" \ + -H "Authorization: Bearer $TOKEN" +``` + +─ + +### POST /api/payments/mock/sessions/{sessionId}/fail + +Finalize a payment session as **failed**. No subscription change occurs. + +**Auth:** Bearer JWT + +**Path params** + +Same as `succeed`. + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `PaymentSessionResponse` with `status: "FAILED"` | + +**200 body** + +```json +{ + "id": "3b3c9e1a-5d7f-4a2b-9c8d-1e2f3a4b5c6d", + "userId": "7", + "targetPlan": "STANDARD", + "status": "FAILED" +} +``` + +Idempotent and owner-scoped. + +─ + +### POST /api/payments/mock/sessions/{sessionId}/cancel + +Finalize a payment session as **cancelled**. No subscription change occurs. + +**Auth:** Bearer JWT + +**Path params** + +Same as `succeed`. + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `PaymentSessionResponse` with `status: "CANCELLED"` | + +**200 body** + +```json +{ + "id": "3b3c9e1a-5d7f-4a2b-9c8d-1e2f3a4b5c6d", + "userId": "7", + "targetPlan": "STANDARD", + "status": "CANCELLED" +} +``` + +Idempotent and owner-scoped. + +─ + +## Subscription query + +Endpoints for querying plans and the caller's subscription state. + +### GET /api/subscriptions/status + +**PUBLIC** — no JWT required. Returns the service health status. + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `ServiceStatus` | + +**200 body (`ServiceStatus`)** + +```json +{ + "service": "subscription-service", + "status": "UP" +} +``` + +The service also exposes a standard Spring Boot Actuator health endpoint at `/actuator/health` (public). + +**Example** + +```bash +curl http://localhost:8085/api/subscriptions/status +``` + +GUI same-origin: `GET /api/subscriptions/status`. + +─ + +### GET /api/subscriptions/plans + +List all available subscription plans with their monthly token allowance and permitted operations. + +**Auth:** Bearer JWT + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | Array of `PlanView` | +| 401 | — | Missing/invalid bearer token | + +**200 body (`PlanView[]`)** + +```json +[ + { + "code": "FREE", + "monthlyTokens": 50, + "allowedOperations": ["CAPACITY_CHECK", "DETECT", "EMBED_768"] + }, + { + "code": "STANDARD", + "monthlyTokens": 500, + "allowedOperations": ["CAPACITY_CHECK", "DETECT", "EMBED_768", "EXTRACT", "VISUALIZE", "EMBED_1024"] + }, + { + "code": "PRO", + "monthlyTokens": 2500, + "allowedOperations": ["CAPACITY_CHECK", "DETECT", "EMBED_768", "EXTRACT", "VISUALIZE", "EMBED_1024", "AI_CLASSIFICATION"] + } +] +``` + +**Example** + +```bash +curl http://localhost:8085/api/subscriptions/plans \ + -H "Authorization: Bearer $TOKEN" +``` + +GUI same-origin: `GET /api/subscriptions/plans`. + +─ + +### GET /api/subscriptions/me + +Return the caller's current active subscription. A user with no subscription is auto-initialized to FREE. + +**Auth:** Bearer JWT + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `CurrentSubscriptionView` | +| 401 | — | Missing/invalid bearer token | + +**200 body (`CurrentSubscriptionView`)** + +```json +{ + "userId": "7", + "planCode": "STANDARD", + "activeFrom": "2026-06-18T10:00:00Z", + "activeUntil": "2026-07-18T10:00:00Z" +} +``` + +Fields: `userId` (string), `planCode` (PlanCode enum), `activeFrom` (ISO-8601 date-time), `activeUntil` (ISO-8601 date-time). + +**Example** + +```bash +curl http://localhost:8085/api/subscriptions/me \ + -H "Authorization: Bearer $TOKEN" +``` + +GUI same-origin: `GET /api/subscriptions/me`. + +─ + +### GET /api/subscriptions/me/tokens + +Return the caller's current token balance — both available (spendable) and reserved (held by active reservations). + +**Auth:** Bearer JWT + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `TokenBalanceView` | +| 401 | — | Missing/invalid bearer token | + +**200 body (`TokenBalanceView`)** + +```json +{ + "userId": "7", + "availableTokens": 450, + "reservedTokens": 8 +} +``` + +Fields: `userId` (string), `availableTokens` (int), `reservedTokens` (int). + +**Example** + +```bash +curl http://localhost:8085/api/subscriptions/me/tokens \ + -H "Authorization: Bearer $TOKEN" +``` + +GUI same-origin: `GET /api/subscriptions/me/tokens`. + +─ + +## Token reservation + +> **⚠️ KNOWN ISSUE — endpoints not currently wired.** +> `controller/TokenReservationController.java` declares the handler methods and an `@Tag`, but the class is **missing** `@RestController` and a class-level `@RequestMapping`. As written it is not registered as a Spring MVC handler, so the paths below return **404**. This breaks every paid watermark operation, which calls these endpoints via `watermark-service-py/app/subscription_client.py`. The GUI nginx already proxies `/api/tokens/` to this service and `subscription_client.py` posts to `/api/tokens/reservations`, so the intended base path is unambiguous. +> **Fix:** add `@RestController` and `@RequestMapping("/api/tokens/reservations")` to the class. +> *(Source: `subscription-service/src/main/java/pl/zzpj/subscription_service/controller/TokenReservationController.java:27-28`)* + +The endpoints below document the **intended contract** once the fix is applied. All require a valid Bearer JWT. + +### POST /api/tokens/reservations + +Reserve tokens for an operation. The service evaluates the caller's plan and balance and either reserves the tokens (201) or returns a structured rejection. + +**Auth:** Bearer JWT + +**Tokens:** none (reservation itself has no token cost) + +**Request** + +`Content-Type: application/json` + +| Field | Type | Required | Notes | +|:------|:-----|:---------|:------| +| `operation` | string | yes | A `TokenOperation` value: `CAPACITY_CHECK`, `DETECT`, `EXTRACT`, `VISUALIZE`, `EMBED_768`, `EMBED_1024`, `AI_CLASSIFICATION` | +| `externalOperationId` | string | no | Optional caller-assigned id for correlation | + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 201 | `application/json` | `TokenReservationResponse` with `status: "RESERVED"` | +| 403 | `application/json` | `TokenReservationErrorResponse` — operation not allowed | +| 409 | `application/json` | `TokenReservationErrorResponse` — insufficient tokens, plan not found, or subscription expired | +| 401 | — | Missing/invalid bearer token | + +**201 body (`TokenReservationResponse`)** + +```json +{ + "reservationId": "d4e5f6a7-b8c9-4d0e-f123-4567890abcde", + "userId": "7", + "operation": "EMBED_768", + "tokens": 5, + "status": "RESERVED", + "expiresAt": "2026-06-18T11:00:00Z" +} +``` + +**Error body (`TokenReservationErrorResponse`)** + +```json +{ + "code": "INSUFFICIENT_TOKENS", + "message": "Not enough available tokens to reserve" +} +``` + +**Error codes** + +| HTTP | `code` | Trigger | +|:----|:-------|:--------| +| 409 | `INSUFFICIENT_TOKENS` | Available balance is too low | +| 403 | `OPERATION_NOT_ALLOWED` | User's plan does not permit this operation | +| 409 | `PLAN_NOT_FOUND` | Plan configuration missing from catalog | +| 409 | `SUBSCRIPTION_EXPIRED` | Subscription has expired (reverts to FREE) | + +**Example** + +```bash +curl -X POST http://localhost:8085/api/tokens/reservations \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"operation": "EMBED_768"}' +``` + +─ + +### POST /api/tokens/reservations/{reservationId}/consume + +Finalize a reservation, permanently deducting the reserved tokens from the caller's balance. Ownership-guarded — only the user who created the reservation may consume it. + +**Auth:** Bearer JWT + +**Path params** + +| Field | Type | Required | Notes | +|:------|:-----|:---------|:------| +| `reservationId` | UUID | yes | The reservation identifier. | + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `TokenReservationResponse` with `status: "CONSUMED"` | +| 401 | — | Missing/invalid bearer token | + +**200 body** + +```json +{ + "reservationId": "d4e5f6a7-b8c9-4d0e-f123-4567890abcde", + "userId": "7", + "operation": "EMBED_768", + "tokens": 5, + "status": "CONSUMED", + "expiresAt": "2026-06-18T11:00:00Z" +} +``` + +**Example** + +```bash +curl -X POST "http://localhost:8085/api/tokens/reservations/d4e5f6a7-b8c9-4d0e-f123-4567890abcde/consume" \ + -H "Authorization: Bearer $TOKEN" +``` + +─ + +### POST /api/tokens/reservations/{reservationId}/release + +Cancel a reservation, returning the reserved tokens to the caller's balance. Ownership-guarded. + +**Auth:** Bearer JWT + +**Path params** + +| Field | Type | Required | Notes | +|:------|:-----|:---------|:------| +| `reservationId` | UUID | yes | The reservation identifier. | + +**Responses** + +| Code | Content-Type | Body | +|:----|:-------------|:-----| +| 200 | `application/json` | `TokenReservationResponse` with `status: "RELEASED"` | +| 401 | — | Missing/invalid bearer token | + +**200 body** + +```json +{ + "reservationId": "d4e5f6a7-b8c9-4d0e-f123-4567890abcde", + "userId": "7", + "operation": "EMBED_768", + "tokens": 5, + "status": "RELEASED", + "expiresAt": "2026-06-18T11:00:00Z" +} +``` + +**Example (reserve → consume flow)** + +```bash +# 1. Reserve tokens +RESERVATION=$(curl -s -X POST http://localhost:8085/api/tokens/reservations \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"operation": "EMBED_768"}') +ID=$(echo "$RESERVATION" | python3 -c "import sys,json; print(json.load(sys.stdin)['reservationId'])") + +# 2. Consume (or release on error) +curl -X POST "http://localhost:8085/api/tokens/reservations/$ID/consume" \ + -H "Authorization: Bearer $TOKEN" + +# Alternative — release instead: +# curl -X POST "http://localhost:8085/api/tokens/reservations/$ID/release" \ +# -H "Authorization: Bearer $TOKEN" +``` + +─ + +## Related docs + +- [API overview](./README.md) +- [Combined OpenAPI spec](../../stegocloud-openapi.json) +- [Combined API doc HTML](../../api-docs.html) +- [Auth server API](./auth-server.md) +- [AI service API](./ai-service.md) +- [Watermark service API](./watermark-service.md) +- [Infrastructure](./infrastructure.md) diff --git a/docs/api/watermark-service.md b/docs/api/watermark-service.md new file mode 100644 index 0000000..fb708fa --- /dev/null +++ b/docs/api/watermark-service.md @@ -0,0 +1,328 @@ +# Watermark Service API + +The watermark service is a Python/FastAPI application (port 8082) that provides steganographic watermarking for PNG images. It can embed hidden text payloads into PNG pixel data, detect the presence of watermarks, extract the hidden text (owner-gated), visualize watermark distribution as a heatmap, and report an image's embedding capacity. All functional endpoints under `/api/watermark/*` require a Bearer JWT; `/health` is public. Swagger UI: `http://localhost:8082/docs` | OpenAPI: `http://localhost:8082/openapi.json`. + +Source files: `watermark-service-py/app/routes.py`, `app/auth.py`, `app/main.py`, `app/subscription_client.py`, `app/watermark.py`, `app/crypto.py`. + +## Endpoints summary + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| POST | `/api/watermark/embed` | Bearer JWT | Tokens EMBED_768 (5) or EMBED_1024 (8) + optional AI_CLASSIFICATION (2); returns binary PNG | +| POST | `/api/watermark/detect` | Bearer JWT | Token DETECT (1); returns JSON | +| POST | `/api/watermark/extract` | Bearer JWT | Token EXTRACT (2); OWNER-or-ADMIN role gate | +| POST | `/api/watermark/visualize` | Bearer JWT | Token VISUALIZE (3); returns binary PNG heatmap | +| POST | `/api/watermark/capacity` | Bearer JWT | FREE (CAPACITY_CHECK = 0, no token reservation); returns JSON | +| GET | `/health` | public | `{ "status": "UP" }` | + +## Auth & token flow + +**Authentication:** Every `/api/watermark/*` endpoint requires `Authorization: Bearer ` via the `require_principal` dependency (`app/auth.py:63-95`). The service does NOT verify the JWT signature locally — it calls auth-server `POST /auth/validate?token=` and requires the literal JSON response `true`. The principal is derived from the JWT body as `{sub}-{userId}` (e.g. `alice-7`), sanitised with `^[A-Za-z0-9._@-]{1,64}$` — fallback `"User"`. + +- **401** `{ "detail": { "error": "Invalid or expired token" } }` — missing, malformed, or invalid token. +- **503** `{ "detail": { "error": "Authentication service is currently unavailable" } }` — auth-server unreachable. + +**Token economy:** Paid operations (embed, detect, extract, visualize) call subscription-service to reserve tokens **before** doing work, then **consume** on success or **release** on error (`app/subscription_client.py`). Capacity check reserves nothing (CAPACITY_CHECK cost = 0). Reservation errors propagate to the caller: + +| Reservation error | Status | Body shape | +|------------------|--------|------------| +| Insufficient tokens | 409 | `{ "code": "INSUFFICIENT_TOKENS", "message": "..." }` | +| Operation not allowed | 403 | `{ "code": "OPERATION_NOT_ALLOWED", "message": "..." }` | +| Plan not found | 409 | `{ "code": "PLAN_NOT_FOUND", "message": "..." }` | +| Subscription expired | 409 | `{ "code": "SUBSCRIPTION_EXPIRED", "message": "..." }` | +| Invalid auth (passthrough) | 401 | `detail` string | +| Subscription service down | 503 | `{ "detail": "Subscription service is currently unavailable" }` | + +**KNOWN ISSUE** — `TokenReservationController` in subscription-service is missing `@RestController` and class-level `@RequestMapping`, so `/api/tokens/reservations/*` returns **404**. This breaks every paid watermark operation. The intended contract is documented in [`./subscription-service.md`](./subscription-service.md). Until the Java class is wired, all paid endpoints will fail with an upstream 404. + +**Image & text limits:** + +| Limit | Value | +|-------|-------| +| Max image size | 25 MB (`_MAX_IMAGE_BYTES`) | +| Max text payload | 4096 bytes (`_MAX_TEXT_BYTES`) | +| Accepted format | PNG only (embed checks `\\x89PNG` magic) | +| Min image (smallest tier) | 1024×1024 (`TIERS`) | + +**Tier selection** (`app/watermark.py:105-117`): images exceeding FHD pixel count (1920×1080 = 2,073,600 px) qualify for the 1024-bit tier (token: EMBED_1024, cost 8); images between 1024×1024 and FHD use the 768-bit tier (EMBED_768, cost 5). Images below 1024×1024 are rejected on the embed path. + +──────────────── + +## POST /api/watermark/embed + +Embeds a text watermark into a PNG image using the caller's identity as the owner. Returns the watermarked PNG bytes directly (not JSON). Optionally runs AI classification if the plan allows. + +**Auth:** Bearer JWT (required) + +**Tokens:** EMBED_768 (5) or EMBED_1024 (8) selected by image pixel count vs FHD threshold; plus AI_CLASSIFICATION (2) when the plan and token balance permit (403/409 from reservation skips classification silently, returning `"unknown"` headers). + +**Request:** `Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `image` | file | yes | PNG image file. Non-PNG → 400. > 25 MB → 413. Too small → 400 with min dimensions. | +| `text` | string | yes | Watermark payload. Blank → 400. > 4096 bytes → 413. Too long for image+owner → 400. | + +**Response — 200 OK:** `Content-Type: image/png` + +The body is the raw PNG bytes. Response headers: + +| Header | Example | Notes | +|--------|---------|-------| +| `X-Image-Category` | `"indoor_scene"` | AI category label; `"unknown"` when not run | +| `X-Image-Label` | `"library"` | AI fine-grained label | +| `X-Image-Confidence` | `"0.9321"` | AI confidence for the label | +| `X-Image-Category-Confidence` | `"0.9812"` | AI confidence for the category | +| `X-Max-Text-Bytes` | `"81"` | Max UTF-8 bytes the image+owner can carry at chosen tier | +| `X-Watermark-Length-Bits` | `"768"` | Chosen tier bit-length (768 or 1024) | + +**Errors:** + +| Status | Body | Trigger | +|--------|------|---------| +| 400 | `{ "detail": "text must not be blank" }` | Empty/whitespace-only text | +| 400 | `{ "detail": "Not a PNG image ..." }` | Non-PNG file (magic check) | +| 400 | `{ "detail": "Image too small (WxH). Minimum ..." }` | Below 1024×1024 | +| 400 | `{ "detail": "Text too long: max N bytes for this image and owner." }` | Text exceeds capacity | +| 413 | `{ "detail": "Text too large (max 4096 bytes)" }` | Text > 4096 UTF-8 bytes | +| 422 | `{ "detail": "..." }` | Embed verification failed at every tier (pathological image content) | +| 401/403/409/503 | see [Auth & token flow](#auth--token-flow) | Auth or reservation error | + +**Example:** + +```bash +# Save watermarked PNG to output.png; print response headers with -D (or -i) +curl -s -o output.png -D - \ + -H "Authorization: Bearer $TOKEN" \ + -F "image=@photo.png" \ + -F "text=Secret message" \ + http://localhost:8082/api/watermark/embed +``` + +The same-origin GUI path (via nginx) is `POST /api/watermark/embed` on port 5173 — the `/api/` catch-all routes to watermark-service:8082. + +──────────────── + +## POST /api/watermark/detect + +Checks whether a PNG image contains a detectable watermark and returns the owner identity if found. + +**Auth:** Bearer JWT (required) + +**Token:** DETECT (cost 1) + +**Request:** `Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `image` | file | yes | Image to inspect. > 25 MB → 413. | + +**Response — 200 OK:** `Content-Type: application/json` + +```json +{ + "watermarked": true, + "ownerIdentity": "alice-7", + "version": 1, + "lengthBits": 768 +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `watermarked` | boolean | `true` if a watermark was found | +| `ownerIdentity` | string\|null | The owner principal embedded with the watermark; `null` if not watermarked | +| `version` | int\|null | `1` when `watermarked` is true, else `null` | +| `lengthBits` | int\|null | Bit-length of the embedded watermark (768 or 1024); `null` if not watermarked | + +**Errors:** + +| Status | Body | Trigger | +|--------|------|---------| +| 413 | `{ "detail": "Image too large (max 26214400 bytes)" }` | Image > 25 MB | +| 401/403/409/503 | see [Auth & token flow](#auth--token-flow) | Auth or reservation error | + +**Example:** + +```bash +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + -F "image=@watermarked.png" \ + http://localhost:8082/api/watermark/detect | jq . +``` + +──────────────── + +## POST /api/watermark/extract + +Extracts the hidden text from a watermarked PNG image. The caller must be the watermark owner OR hold an admin token (`sub=admin, userId=1` or JWT `role=ADMIN`). + +**Auth:** Bearer JWT (required); OWNER or ADMIN + +**Token:** EXTRACT (cost 2) + +**Request:** `Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `image` | file | yes | PNG image with an embedded watermark. > 25 MB → 413. | + +**Response — 200 OK:** `Content-Type: application/json` + +```json +{ + "ownerIdentity": "alice-7", + "text": "Secret message" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `ownerIdentity` | string | The owner principal that was embedded in the watermark | +| `text` | string | The decrypted hidden payload | + +**Errors:** + +| Status | Body | Trigger | +|--------|------|---------| +| 400 | `{ "detail": "No watermark found in this image" }` | Image has no detectable watermark | +| 403 | `{ "detail": "Requester is not allowed to read this watermark" }` | Caller is not the owner and not admin | +| 413 | `{ "detail": "Image too large (max 26214400 bytes)" }` | Image > 25 MB | +| 401/403/409/503 | see [Auth & token flow](#auth--token-flow) | Auth or reservation error | + +**Example:** + +```bash +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + -F "image=@watermarked.png" \ + http://localhost:8082/api/watermark/extract | jq . +``` + +──────────────── + +## POST /api/watermark/visualize + +Generates a PNG heatmap showing the watermark distribution in the supplied image. Returns raw PNG bytes. + +**Auth:** Bearer JWT (required) + +**Token:** VISUALIZE (cost 3) + +**Request:** `Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `image` | file | yes | PNG or other image format the watermark library can read. > 25 MB → 413. | + +**Response — 200 OK:** `Content-Type: image/png` + +Body is the raw PNG heatmap bytes. + +**Errors:** + +| Status | Body | Trigger | +|--------|------|---------| +| 400 | `{ "detail": "..." }` | Image could not be decoded or processed | +| 413 | `{ "detail": "Image too large (max 26214400 bytes)" }` | Image > 25 MB | +| 401/403/409/503 | see [Auth & token flow](#auth--token-flow) | Auth or reservation error | + +**Example:** + +```bash +curl -s -o heatmap.png \ + -H "Authorization: Bearer $TOKEN" \ + -F "image=@watermarked.png" \ + http://localhost:8082/api/watermark/visualize +``` + +──────────────── + +## POST /api/watermark/capacity + +Calculates the maximum text embedding capacity for a given PNG image, based on its dimensions and the watermark tier it qualifies for. Does NOT reserve or consume tokens (CAPACITY_CHECK cost = 0, free). + +**Auth:** Bearer JWT (required) + +**Token:** FREE — no reservation performed, cost is 0. + +**Request:** `Content-Type: multipart/form-data` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `image` | file | yes | Image to analyse. > 25 MB → 413. | + +**Response — 200 OK:** `Content-Type: application/json` + +```json +{ + "maxTextBytes": 81, + "minImageWidth": 1024, + "minImageHeight": 1024, + "imageWidth": 1920, + "imageHeight": 1080, + "imageOk": true, + "lengthBits": 768 +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `maxTextBytes` | int | Maximum UTF-8 bytes that can be embedded with the caller's owner string | +| `minImageWidth` | int | Minimum required image width for the smallest tier (1024) | +| `minImageHeight` | int | Minimum required image height for the smallest tier (1024) | +| `imageWidth` | int | Actual image width (after EXIF orientation) | +| `imageHeight` | int | Actual image height (after EXIF orientation) | +| `imageOk` | bool | Whether the image meets the minimum dimension requirements | +| `lengthBits` | int | Chosen watermark bit-length (0 if image is below minimum tier; 768 or 1024 otherwise) | + +**Errors:** + +| Status | Body | Trigger | +|--------|------|---------| +| 413 | `{ "detail": "Image too large (max 26214400 bytes)" }` | Image > 25 MB | +| 401 | `{ "detail": { "error": "Invalid or expired token" } }` | Missing/invalid bearer token | +| 503 | `{ "detail": { "error": "Authentication service is currently unavailable" } }` | Auth-server unreachable | + +**Example:** + +```bash +curl -s \ + -H "Authorization: Bearer $TOKEN" \ + -F "image=@photo.png" \ + http://localhost:8082/api/watermark/capacity | jq . +``` + +──────────────── + +## GET /health + +Lightweight health check registered on the app root (not under `/api/watermark`). No auth required. + +**Auth:** Public + +**Response — 200 OK:** `Content-Type: application/json` + +```json +{ + "status": "UP" +} +``` + +**Example:** + +```bash +curl -s http://localhost:8082/health | jq . +``` + +──────────────── + +**Cross-references** + +- [API index](./README.md) +- [Combined OpenAPI spec](../../stegocloud-openapi.json) — note: this file has known gaps for watermark responses (binary PNG schemas empty, missing error codes) +- [Combined API docs](../../api-docs.html) +- [Auth server API](./auth-server.md) — token validation dependency +- [Subscription service API](./subscription-service.md) — token reservation dependency +- [AI service API](./ai-service.md) — optional AI classification dependency during embed +- [Infrastructure notes](./infrastructure.md) — Eureka, config-server, GUI nginx routing diff --git a/docs/project/README.md b/docs/project/README.md new file mode 100644 index 0000000..ae3c56a --- /dev/null +++ b/docs/project/README.md @@ -0,0 +1,18 @@ +# Project Documentation + +This folder contains the project report, diagrams and GUI screenshots used to document StegoCloud. + +## Contents + +| Path | Description | +|---|---| +| `project-report.pdf` | Final rendered project report | +| `project-report.tex` | LaTeX source for the report | +| `*.puml` | PlantUML source diagrams | +| `*.svg` | Rendered vector diagrams | +| `*.pdf` | Diagram files used by the LaTeX report | +| `screen_*.png` | Application screenshots | +| `render_puml.py` | Helper script for rendering PlantUML diagrams | +| `svg_to_pdf.py` | Helper script for converting SVG diagrams to PDF | + +LaTeX build artifacts such as `.aux`, `.out`, `.toc` and `.synctex.gz` are intentionally not tracked. diff --git a/docs/project/architecture.pdf b/docs/project/architecture.pdf new file mode 100644 index 0000000..e85f84f Binary files /dev/null and b/docs/project/architecture.pdf differ diff --git a/docs/project/architecture.puml b/docs/project/architecture.puml new file mode 100644 index 0000000..13ef56c --- /dev/null +++ b/docs/project/architecture.puml @@ -0,0 +1,60 @@ +@startuml +skinparam dpi 150 +skinparam componentStyle uml2 +skinparam database { + BackgroundColor LightCyan + BorderColor DarkCyan +} +skinparam component { + BackgroundColor LightYellow + BorderColor Orange +} +skinparam node { + BackgroundColor LightGray + BorderColor Black +} + +node "Środowisko Klienta" { + [Svelte GUI] as gui <> +} + +node "Brama i Wsparcie" { + [Nginx (Gateway)] as nginx <> + [Eureka Discovery] as eureka <> + [Config Server] as config_srv <> +} + +node "Mikroserwisy Backendowe" { + [Auth Server] as auth <> + [Subscription Service] as sub <> + [Watermark Service] as wm <> + [AI Service] as ai <> +} + +database "PostgreSQL" { + [auth_schema] as db_auth + [subscription_schema] as db_sub +} + +gui --> nginx : HTTP +nginx --> auth : /auth/* +nginx --> sub : /api/subscriptions/*\n/api/payments/*\n/api/tokens/* +nginx --> wm : /api/watermark/* + +auth -up-> eureka : Rejestruje/Odkrywa +sub -up-> eureka : Rejestruje/Odkrywa +wm -up-> eureka : Rejestruje/Odkrywa +ai -up-> eureka : Rejestruje/Odkrywa + +auth --> config_srv : Pobiera config +sub --> config_srv : Pobiera config +wm --> config_srv : Pobiera config +ai --> config_srv : Pobiera config + +auth --> db_auth : JDBC (schema auth) +sub --> db_sub : JDBC (schema subscription) + +wm --> sub : Rezerwacja/Konsumpcja tokenów +wm --> ai : Wywołanie klasyfikacji (REST) +wm ..> auth : Walidacja JWT (POST /auth/validate) +@enduml diff --git a/docs/project/architecture.svg b/docs/project/architecture.svg new file mode 100644 index 0000000..0abaa6b --- /dev/null +++ b/docs/project/architecture.svg @@ -0,0 +1 @@ +Środowisko KlientaBrama i WsparcieMikroserwisy BackendowePostgreSQL«Frontend»Svelte GUI«Nginx»Nginx (Gateway)«Netflix Eureka»Eureka Discovery«Spring Cloud Config»Config Server«Spring Boot»Auth Server«Spring Boot»Subscription Service«FastAPI (Python)»Watermark Service«Spring Boot + ONNX»AI Serviceauth_schemasubscription_schemaHTTP/auth/*/api/subscriptions/*/api/payments/*/api/tokens/*/api/watermark/*Rejestruje/OdkrywaRejestruje/OdkrywaRejestruje/OdkrywaRejestruje/OdkrywaPobiera configPobiera configPobiera configPobiera configJDBC (schema auth)JDBC (schema subscription)Rezerwacja/Konsumpcja tokenówWywołanie klasyfikacji (REST)Walidacja JWT (POST /auth/validate) \ No newline at end of file diff --git a/docs/project/classes.pdf b/docs/project/classes.pdf new file mode 100644 index 0000000..7fb05f9 Binary files /dev/null and b/docs/project/classes.pdf differ diff --git a/docs/project/classes.puml b/docs/project/classes.puml new file mode 100644 index 0000000..c8a254a --- /dev/null +++ b/docs/project/classes.puml @@ -0,0 +1,153 @@ +@startuml +skinparam dpi 150 +skinparam class { + BackgroundColor LightYellow + BorderColor Orange + ArrowColor Black +} +skinparam interface { + BackgroundColor LightCyan + BorderColor DarkCyan +} +skinparam enum { + BackgroundColor LightPink + BorderColor Red +} + +package "auth-server :: Domain Entities" { + class User <> { + - Long id + - String username + - String email + - String password + - UserRole role + } + + enum UserRole { + ADMIN + USER + } + + User --> UserRole +} + +package "subscription-service :: Domain Model" { + class SubscriptionPlan <> { + - PlanCode code + - int monthlyTokens + - Set allowedOperations + + boolean allows(TokenOperation op) + } + + enum PlanCode { + FREE + STANDARD + PRO + + boolean canUpgradeTo(PlanCode target) + } + + class ActiveSubscription <> { + - String userId + - PlanCode planCode + - Instant activeFrom + - Instant activeUntil + + boolean isExpiredAt(Instant instant) + } + + enum TokenOperation { + CAPACITY_CHECK + DETECT + EXTRACT + VISUALIZE + EMBED_768 + EMBED_1024 + AI_CLASSIFICATION + } + + class TokenBalance <> { + - String userId + - int availableTokens + - int reservedTokens + + boolean canReserve(int tokens) + + TokenBalance reserve(int tokens) + + TokenBalance consumeReserved(int tokens) + + TokenBalance releaseReserved(int tokens) + } + + class TokenReservation <> { + - UUID reservationId + - String userId + - TokenOperation operation + - int tokens + - Instant expiresAt + } + + class TokenReservationEntity <> { + - UUID id + - TokenReservationStatus status + - Instant createdAt + - Instant expiresAt + - Instant completedAt + + TokenReservation toDomain() + } + + enum TokenReservationStatus { + RESERVED + CONSUMED + RELEASED + } + + interface TokenDecision <> + class Accepted <> implements TokenDecision { + - TokenReservation reservation + } + class RejectedInsufficientTokens <> implements TokenDecision + class RejectedOperationNotAllowed <> implements TokenDecision + class RejectedSubscriptionExpired <> implements TokenDecision + class RejectedPlanNotFound <> implements TokenDecision + + class PaymentSession <> { + - UUID id + - String userId + - PlanCode targetPlan + - Status status + - Instant createdAt + - Instant updatedAt + } + + enum "PaymentSession.Status" as PaymentStatus { + PENDING + SUCCEEDED + FAILED + CANCELLED + } + + ActiveSubscription --> PlanCode + SubscriptionPlan --> PlanCode + SubscriptionPlan --> TokenOperation + TokenReservation --> TokenOperation + Accepted --> TokenReservation + TokenReservationEntity ..> TokenReservation : maps + TokenReservationEntity --> TokenReservationStatus + PaymentSession --> PlanCode + PaymentSession --> PaymentStatus +} + +package "watermark-service-py :: Modules" { + class "watermark.py" as watermark <> { + + embed_text(image_bytes, text, owner, app_key) : WatermarkResult + + detect_text(image_bytes, app_key) : DetectionResult + + visualize(image_bytes, app_key) : bytes + + capacity_report(image_bytes, owner) : CapacityReport + + select_length_bits(width, height) : int + } + + class "crypto.py" as crypto <> { + + seal(owner, text, app_key) : bytes + + unseal(blob, app_key) : DecodedEnvelope + + max_text_bytes(length_bits, owner) : int + } + + watermark ..> crypto : AES-GCM + Reed-Solomon +} +@enduml diff --git a/docs/project/classes.svg b/docs/project/classes.svg new file mode 100644 index 0000000..ac511ec --- /dev/null +++ b/docs/project/classes.svg @@ -0,0 +1 @@ +auth-server :: Domain Entitiessubscription-service :: Domain Modelwatermark-service-py :: Modules«entity»UserLong idString usernameString emailString passwordUserRole roleUserRoleADMINUSER«record»SubscriptionPlanPlanCode codeint monthlyTokensSet<TokenOperation> allowedOperationsboolean allows(TokenOperation op)PlanCodeFREESTANDARDPROboolean canUpgradeTo(PlanCode target)«record»ActiveSubscriptionString userIdPlanCode planCodeInstant activeFromInstant activeUntilboolean isExpiredAt(Instant instant)TokenOperationCAPACITY_CHECKDETECTEXTRACTVISUALIZEEMBED_768EMBED_1024AI_CLASSIFICATION«record»TokenBalanceString userIdint availableTokensint reservedTokensboolean canReserve(int tokens)TokenBalance reserve(int tokens)TokenBalance consumeReserved(int tokens)TokenBalance releaseReserved(int tokens)«record»TokenReservationUUID reservationIdString userIdTokenOperation operationint tokensInstant expiresAt«entity»TokenReservationEntityUUID idTokenReservationStatus statusInstant createdAtInstant expiresAtInstant completedAtTokenReservation toDomain()TokenReservationStatusRESERVEDCONSUMEDRELEASED«sealed»TokenDecision«record»AcceptedTokenReservation reservation«record»RejectedInsufficientTokens«record»RejectedOperationNotAllowed«record»RejectedSubscriptionExpired«record»RejectedPlanNotFound«record»PaymentSessionUUID idString userIdPlanCode targetPlanStatus statusInstant createdAtInstant updatedAtPaymentSession.StatusPENDINGSUCCEEDEDFAILEDCANCELLED«module»watermark.pyembed_text(image_bytes, text, owner, app_key) : WatermarkResultdetect_text(image_bytes, app_key) : DetectionResultvisualize(image_bytes, app_key) : bytescapacity_report(image_bytes, owner) : CapacityReportselect_length_bits(width, height) : int«module»crypto.pyseal(owner, text, app_key) : bytesunseal(blob, app_key) : DecodedEnvelopemax_text_bytes(length_bits, owner) : intmapsAES-GCM + Reed-Solomon \ No newline at end of file diff --git a/docs/project/components.pdf b/docs/project/components.pdf new file mode 100644 index 0000000..15c765c Binary files /dev/null and b/docs/project/components.pdf differ diff --git a/docs/project/components.puml b/docs/project/components.puml new file mode 100644 index 0000000..a485b7a --- /dev/null +++ b/docs/project/components.puml @@ -0,0 +1,75 @@ +@startuml +skinparam dpi 150 +skinparam componentStyle uml2 +skinparam component { + BackgroundColor LightYellow + BorderColor Orange +} +skinparam interface { + BackgroundColor LightCyan + BorderColor DarkCyan +} + +package "Warstwa Prezentacji" { + [Svelte SPA (GUI)] as gui +} + +package "Brama i Wsparcie" { + [Nginx Router (Gateway)] as nginx + interface "Eureka Service Registry" as registry_api + [Eureka Discovery Server] as discovery + discovery -up- registry_api +} + +package "Mikroserwis Autoryzacji (auth-server)" { + [Auth Controller] as auth_ctrl + [JWT Service] as jwt_svc + [Registration Service] as reg_svc + auth_ctrl --> jwt_svc + auth_ctrl --> reg_svc +} + +package "Mikroserwis Subskrypcji (subscription-service)" { + [Token Reservation Controller] as reserve_ctrl + [Subscription Query Controller] as query_ctrl + [Mock Payment Controller] as pay_ctrl + [Token Reservation Policy] as policy + [Payment Application Service] as pay_app_svc + + reserve_ctrl --> policy + pay_ctrl --> pay_app_svc +} + +package "Mikroserwis Watermarkingu (watermark-service-py)" { + [Watermark Router] as wm_router + [Watermark Engine] as wm_eng + [Crypto Manager] as crypto + [Subscription Client] as sub_client + + wm_router --> wm_eng + wm_router --> crypto + wm_router --> sub_client +} + +package "Mikroserwis Klasyfikacji AI (ai-service)" { + [AI Controller] as ai_ctrl + [ONNX Runtime Classifier] as ai_classifier + ai_ctrl --> ai_classifier +} + +gui --> nginx : HTTP REST / JSON +nginx --> auth_ctrl : /auth/* +nginx --> reserve_ctrl : /api/tokens/* +nginx --> query_ctrl : /api/subscriptions/* +nginx --> pay_ctrl : /api/payments/* +nginx --> wm_router : /api/watermark/* + +wm_router -down-> ai_ctrl : HTTP REST (Klasyfikacja) +sub_client -down-> reserve_ctrl : HTTP REST (Rezerwacja tokenów) + +auth_ctrl ..> registry_api : rejestracja +reserve_ctrl ..> registry_api : rejestracja +wm_router ..> registry_api : rejestracja +ai_ctrl ..> registry_api : rejestracja + +@enduml diff --git a/docs/project/components.svg b/docs/project/components.svg new file mode 100644 index 0000000..de43107 --- /dev/null +++ b/docs/project/components.svg @@ -0,0 +1 @@ +Warstwa PrezentacjiBrama i WsparcieMikroserwis Autoryzacji (auth-server)Mikroserwis Subskrypcji (subscription-service)Mikroserwis Watermarkingu (watermark-service-py)Mikroserwis Klasyfikacji AI (ai-service)Svelte SPA (GUI)Nginx Router (Gateway)Eureka Service RegistryEureka Discovery ServerAuth ControllerJWT ServiceRegistration ServiceToken Reservation ControllerSubscription Query ControllerMock Payment ControllerToken Reservation PolicyPayment Application ServiceWatermark RouterWatermark EngineCrypto ManagerSubscription ClientAI ControllerONNX Runtime ClassifierHTTP REST / JSON/auth/*/api/tokens/*/api/subscriptions/*/api/payments/*/api/watermark/*HTTP REST (Klasyfikacja)HTTP REST (Rezerwacja tokenów)rejestracjarejestracjarejestracjarejestracja \ No newline at end of file diff --git a/docs/project/project-report.pdf b/docs/project/project-report.pdf new file mode 100644 index 0000000..457db71 Binary files /dev/null and b/docs/project/project-report.pdf differ diff --git a/docs/project/project-report.tex b/docs/project/project-report.tex new file mode 100644 index 0000000..1be4d5b --- /dev/null +++ b/docs/project/project-report.tex @@ -0,0 +1,431 @@ +\documentclass[12pt,a4paper]{article} + +% Język polski i kodowanie +\usepackage[polish]{babel} +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} + +% Pakiety graficzne i tabel +\usepackage{graphicx} +\usepackage{float} +\usepackage{booktabs} +\usepackage{tabularx} +\usepackage{geometry} +\usepackage{fancyhdr} +\usepackage{titlesec} +\usepackage{caption} +\usepackage{subcaption} + +% Konfiguracja marginesów +\geometry{margin=2.5cm} + +% Interaktywny spis treści z usuniętymi obwódkami linków +\usepackage{hyperref} +\hypersetup{ + colorlinks=true, + linkcolor=black, + filecolor=black, + urlcolor=blue, + citecolor=black, + pdfborder={0 0 0} +} + +% Ustawienia nagłówka i stopki +\setlength{\headheight}{15pt} +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{StegoCloud -- Dokumentacja Projektu} +\fancyhead[R]{\thepage} +\fancyfoot[C]{Politechnika Łódzka -- ZZPJ 2026} +\renewcommand{\headrulewidth}{0.4pt} +\renewcommand{\footrulewidth}{0.4pt} + +% Ustawienia formatowania listingów +\usepackage{listings} +\usepackage{xcolor} +\lstset{ + basicstyle=\ttfamily\small, + backgroundcolor=\color{gray!10}, + frame=single, + breaklines=true, + captionpos=b +} + +\begin{document} + +% --- STRONA TYTUŁOWA --- +\begin{titlepage} + \centering + \vspace*{1cm} + {\large\bfseries Politechnika Łódzka} \\ + \vspace*{0.2cm} + {\large Wydział Fizyki Technicznej, Informatyki i Matematyki Stosowanej} \\ + \vspace*{0.2cm} + {\large Instytut Informatyki} \\ + \vspace*{2cm} + + \vspace*{1.5cm} + + {\Huge\bfseries StegoCloud} \\ + \vspace*{0.5cm} + {\Large\bfseries System mikroserwisowy do ukrywania i odczytywania szyfrowanych znaków wodnych w obrazach PNG} \\ + \vspace*{1cm} + {\large Dokumentacja techniczna i projektowa} \\ + \vspace*{0.5cm} + {\large Przedmiot: Zaawansowane Zagadnienia Programowania w Javie} \\ + \vspace*{2cm} + + \begin{flushleft} + \large + \textbf{Członkowie zespołu projektowego:} \\ + \vspace*{0.2cm} + \begin{tabular}{ll} + Bartosz Kołaciński & index: 251554 \\ + Mateusz Kosowski & index: 251558 \\ + Nikodem Nowak & index: 251598 \\ + Wiktor Pankanin & index: 251606 \\ + Jakub Rosiak & index: 251620 \\ + Jakub Rusek & index: 247774 \\ + \end{tabular} + \end{flushleft} + + \vfill + {\large Łódź, Czerwiec 2026} +\end{titlepage} + +\newpage + +% --- SPIS TREŚCI --- +{ + \hypersetup{linkcolor=black} + \tableofcontents +} +\newpage + +% --- 1. WPROWADZENIE --- +\section{Wprowadzenie} + +\subsection{Cel dokumentu} +Niniejszy dokument stanowi dokumentację techniczną i projektową systemu \textbf{StegoCloud}. Jego celem jest kompleksowe przedstawienie założeń projektu, jego architektury oraz sposobu działania -- od przeznaczenia i głównych funkcji, poprzez przypadki użycia, diagramy komponentów, klas i interakcji, aż po wykorzystany stos technologiczny wraz z uzasadnieniem wyboru. Dokument skierowany jest do prowadzącego przedmiot \textit{Zaawansowane Zagadnienia Programowania w Javie}, członków zespołu projektowego oraz osób, które w przyszłości chciałyby rozwijać lub utrzymywać system. + +\subsection{Cel projektu} +Głównym celem projektu \textbf{StegoCloud} jest zaprojektowanie oraz implementacja nowoczesnego, rozproszonego i bezpiecznego systemu do dystrybucji i weryfikacji obrazów cyfrowych za pomocą ukrytych (niewidocznych) znaków wodnych. System pozwala autorom oraz dystrybutorom treści multimedialnych (np. obrazów PNG) na weryfikację ich oryginalności oraz identyfikację ewentualnego źródła przecieku lub nieautoryzowanego użycia, przy jednoczesnym zabezpieczeniu samej treści znaku wodnego przed odczytem przez osoby nieuprawnione. + +\subsection{Zakres projektu} +System został zaprojektowany w nowoczesnej architekturze mikroserwisowej i obejmuje: +\begin{itemize} + \item \textbf{Usługi autoryzacji i rejestracji} zarządzające tożsamościami użytkowników i ról (Zwykły Użytkownik, Administrator). + \item \textbf{Moduł subskrypcyjny} realizujący logikę biznesową planów taryfowych (\texttt{FREE}, \texttt{STANDARD}, \texttt{PRO}) oraz dynamiczne zarządzanie saldem i rezerwacjami tokenów użytkowników. + \item \textbf{Serwis znakowania (watermarkingu)} oparty o zaawansowany silnik w języku Python osadzający zaszyfrowane komunikaty w dziedzinie częstotliwościowej (transformata DWT-DCT-SVD) obrazów PNG. + \item \textbf{Serwis sztucznej inteligencji (AI)} dokonujący klasyfikacji zawartości obrazów przy użyciu modelu \texttt{MobileNetV2} w środowisku uruchomieniowym ONNX. + \item \textbf{Interfejs graficzny użytkownika (GUI)} zrealizowany w technologii Svelte, pozwalający na łatwe korzystanie z funkcji platformy. +\end{itemize} + +\subsection{Grupa docelowa (Do kogo projekt jest skierowany)} +StegoCloud jest skierowany przede wszystkim do: +\begin{itemize} + \item \textbf{Twórców cyfrowych i fotografów} chcących trwale zabezpieczyć swoje prace przed kradzieżą i niekontrolowanym rozpowszechnianiem. + \item \textbf{Agencji reklamowych i marketingowych} dystrybuujących materiały graficzne do klientów zewnętrznych, które muszą pozostać poufne do momentu oficjalnej publikacji. + \item \textbf{Systemów zarządzania treścią (CMS) i platform e-commerce} w celu automatycznego stemplowania pobieranych obrazów tożsamością kupującego (śledzenie nielegalnej redystrybucji). +\end{itemize} + +\newpage + +% --- 2. OPIS SYSTEMU --- +\section{Opis systemu} + +\subsection{Przeznaczenie} +Platforma StegoCloud służy do bezpowrotnego, niewidocznego dla ludzkiego oka stemplowania cyfrowych plików PNG. W przeciwieństwie do widocznych znaków wodnych, które można łatwo wyciąć lub zamazać, steganografia w dziedzinie częstotliwościowej (transformata DWT-DCT-SVD) osadza informacje w widmie częstotliwościowym obrazu, modyfikując współczynniki transformaty zamiast bezpośrednich wartości pikseli. Znak wodny chroniony jest kryptograficznie (AES-GCM) oraz nadmiarowo (Reed-Solomon Error Correction Code), co zapobiega fałszowaniu i pozwala na poprawny odczyt nawet przy drobnych zniekształceniach. + +\subsection{Główne funkcje} +System oferuje następujące operacje taryfikowane odpowiednim kosztem tokenowym: +\begin{enumerate} + \item \textbf{Capacity Check (0 tokenów)}: Analizuje obraz PNG i informuje o maksymalnej liczbie bajtów, jakie można w nim ukryć w zależności od jego wymiarów (minimalny rozmiar to $1024 \times 1024$ pikseli). + \item \textbf{Embed (5 lub 8 tokenów)}: Osadza zaszyfrowany i zabezpieczony kodem korekcyjnym tekst w obrazie. Rozmiary poniżej Full HD ($1920 \times 1080$) zużywają 5 tokenów (tier \texttt{EMBED\_768}), większe 8 tokenów (tier \texttt{EMBED\_1024}). + \item \textbf{Detect (1 token)}: Sprawdza, czy w przesłanym obrazie znajduje się poprawny znak wodny i określa jego właściciela. + \item \textbf{Extract (2 tokeny)}: Wyodrębnia ukryty tekst. Zwykły użytkownik może odczytać tylko swoje znaki wodne, administrator ma uprawnienia do wszystkich. + \item \textbf{Visualize (3 tokeny)}: Generuje mapę ciepła (heatmap) pokazującą bezwzględne różnice wartości pikseli przed i po osadzeniu znaku wodnego. + \item \textbf{AI Classification (2 tokeny)}: Opcjonalna klasyfikacja zawartości obrazu przy pomocy modelu sieci neuronowej MobileNetV2 (dostępna tylko dla planu \texttt{PRO}). +\end{enumerate} + +\subsection{Architektura systemu} +Projekt zrealizowano w architekturze mikroserwisowej z wykorzystaniem stosu technologicznego Spring Cloud oraz kontenerów Docker. Diagram przedstawia strukturę powiązań pomiędzy komponentami systemu. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{architecture.pdf} + \caption{Architektura mikroserwisowa systemu StegoCloud} + \label{fig:architecture} +\end{figure} + +\subsection{Integracje z zewnętrznymi systemami} +System integruje się z następującymi mechanizmami wspierającymi: +\begin{itemize} + \item \textbf{Spring Cloud Config Server}: Centralne repozytorium konfiguracyjne przechowujące pliki konfiguracyjne mikroserwisów w dedykowanym repozytorium Git. + \textit{Zmiany konfiguracji mogą być dynamicznie propagowane.} + \item \textbf{Spring Cloud Netflix Eureka}: Serwer rejestracji i odkrywania usług (Service Discovery), umożliwiający dynamiczne kierowanie ruchu i skalowalność mikroserwisów. + \item \textbf{PostgreSQL}: Relacyjna baza danych współdzielona na poziomie instancji, ale logicznie wydzielona na osobne schematy: \texttt{auth\_schema} dla serwera autoryzacji oraz \texttt{subscription\_schema} dla serwisu subskrypcji. + \item \textbf{ONNX Runtime}: Zintegrowane bibliotecznie w serwisie AI środowisko wykonawcze do uruchamiania wytrenowanego modelu głębokiego uczenia MobileNetV2 bez konieczności instalowania pełnych bibliotek takich jak TensorFlow czy PyTorch. +\end{itemize} + +\newpage + +% --- 3. DIAGRAMY PRZYPADKÓW UŻYCIA --- +\section{Diagramy przypadków użycia} + +\subsection{Diagram ogólny} +Diagram przypadków użycia przedstawia interakcje aktorów (Użytkownik, Administrator oraz zewnętrzna Bramka Płatności) z poszczególnymi funkcjonalnościami systemu StegoCloud. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{use_cases.pdf} + \caption{Diagram przypadków użycia systemu StegoCloud} + \label{fig:use_cases} +\end{figure} + +\subsection{Opis wybranych przypadków użycia} + +Poniższe tabele szczegółowo opisują dwa kluczowe przypadki użycia systemu: osadzanie znaku wodnego z klasyfikacją AI oraz zakup/upgrade planu subskrypcji. + +\begin{table}[H] +\centering +\caption{Opis przypadku użycia: Osadzanie znaku wodnego z klasyfikacją AI} +\begin{tabularx}{\textwidth}{lX} +\toprule +\textbf{Nazwa przypadku} & Osadzenie znaku wodnego z klasyfikacją AI \\ +\midrule +\textbf{Aktorzy} & Użytkownik Zalogowany, \texttt{watermark-service-py}, \texttt{subscription-service}, \texttt{ai-service} \\ +\midrule +\textbf{Warunki wstępne} & +1. Użytkownik jest zalogowany i posiada ważny token JWT. \newline +2. Użytkownik posiada aktywny plan \texttt{PRO}. \newline +3. Saldo tokenów użytkownika wynosi co najmniej 7 tokenów (koszt embed: 5 lub 8 + koszt AI: 2). \newline +4. Przesłany plik jest poprawnym formatem PNG o wymiarach $\ge 1024 \times 1024$ px. \\ +\midrule +\textbf{Przebieg główny} & +1. Użytkownik wybiera plik PNG oraz wpisuje treść znaku wodnego w GUI. \newline +2. GUI wysyła żądanie do bramki Nginx, która przekazuje je do \texttt{watermark-service-py}. \newline +3. \texttt{watermark-service-py} pyta \texttt{subscription-service} o rezerwację tokenów na operację embedowania. \newline +4. \texttt{subscription-service} dokonuje rezerwacji tokenów o statusie \texttt{RESERVED}. \newline +5. \texttt{watermark-service-py} pyta \texttt{subscription-service} o rezerwację tokenów na klasyfikację AI. Rezerwacja zostaje zatwierdzona. \newline +6. \texttt{watermark-service-py} wysyła obraz do \texttt{ai-service}. \newline +7. \texttt{ai-service} uruchamia model ONNX, klasyfikuje obraz i zwraca etykiety (np. klasyfikacja: "pies", pewność: 34\%). \newline +8. \texttt{watermark-service-py} przesyła żądanie konsumpcji rezerwacji AI. \newline +9. \texttt{watermark-service-py} szyfruje payload AES-GCM, koduje Reed-Solomon i osadza go w DCT obrazu. \newline +10. Po poprawnym osadzeniu, serwis wysyła żądanie konsumpcji rezerwacji embedowania do \texttt{subscription-service}. \newline +11. Obraz PNG wraz z nagłówkami wyników klasyfikacji AI zostaje przesłany do GUI i udostępniony do pobrania. \\ +\midrule +\textbf{Przebieg alternatywny} & +\textbf{4a/5a. Brak tokenów lub niepoprawny plan}: \texttt{subscription-service} odmawia rezerwacji. Proces zostaje przerwany z błędem 403/409, użytkownik widzi komunikat w GUI. \newline +\textbf{9a. Błąd algorytmu steganograficznego}: \texttt{watermark-service-py} zwalnia rezerwacje tokenów w \texttt{subscription-service} (status \texttt{RELEASED}). Saldo użytkownika nie ulega zmianie. \\ +\bottomrule +\end{tabularx} +\end{table} + +\newpage + +\begin{table}[H] +\centering +\caption{Opis przypadku użycia: Zakup / Upgrade planu subskrypcji} +\begin{tabularx}{\textwidth}{lX} +\toprule +\textbf{Nazwa przypadku} & Zakup / Upgrade planu subskrypcji \\ +\midrule +\textbf{Aktorzy} & Użytkownik Zalogowany, \texttt{subscription-service}, Bramka Płatności (Mock) \\ +\midrule +\textbf{Warunki wstępne} & +1. Użytkownik jest zalogowany. \newline +2. Użytkownik posiada aktualnie niższy plan niż docelowy (dozwolone przejścia: \texttt{FREE $\rightarrow$ STANDARD}, \texttt{FREE $\rightarrow$ PRO}, \texttt{STANDARD $\rightarrow$ PRO}). \\ +\midrule +\textbf{Przebieg główny} & +1. Użytkownik w panelu subskrypcji wybiera przycisk "Kup" przy wybranym nowym planie. \newline +2. GUI wysyła żądanie utworzenia sesji płatniczej do \texttt{subscription-service}. \newline +3. \texttt{subscription-service} tworzy sesję płatniczą o statusie \texttt{PENDING} i zwraca jej identyfikator oraz adres bramki płatniczej. \newline +4. GUI przekierowuje użytkownika na stronę bramki płatności (Mock Payment Page). \newline +5. Użytkownik klika przycisk "Zatwierdź płatność". \newline +6. Bramka płatności wysyła żądanie sukcesu płatności do \texttt{subscription-service} przekazując \texttt{sessionId}. \newline +7. \texttt{subscription-service} weryfikuje sesję i jej właściciela, a następnie zmienia status sesji na \texttt{SUCCEEDED}. \newline +8. Serwis modyfikuje subskrypcję użytkownika w bazie (nowy plan, nowa data ważności na +1 miesiąc). \newline +9. Saldo tokenów użytkownika zostaje zasilone pełną pulą nowego planu. \newline +10. Użytkownik zostaje przekierowany z powrotem do aplikacji, gdzie GUI prezentuje zaktualizowany stan konta. \\ +\midrule +\textbf{Przebieg alternatywny} & +\textbf{1a. Downgrade lub zakup tego samego planu}: Backend blokuje operację (błąd 400), informując o niedozwolonym przejściu. \newline +\textbf{5a. Anulowanie płatności}: Użytkownik klika "Anuluj". Bramka wysyła status anulowania. Status sesji w bazie zmienia się na \texttt{CANCELLED}. Subskrypcja użytkownika pozostaje bez zmian. \\ +\bottomrule +\end{tabularx} +\end{table} + +\newpage + +% --- 4. DIAGRAM KOMPONENTÓW --- +\section{Diagram komponentów} + +\subsection{Diagram komponentów} +Diagram komponentów przedstawia dekompozycję systemu na poszczególne moduły aplikacyjne, interfejsy komunikacyjne oraz zależności między nimi. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{components.pdf} + \caption{Diagram komponentów systemu StegoCloud} + \label{fig:components} +\end{figure} + +\subsection{Opis komponentów} +\begin{itemize} + \item \textbf{Svelte SPA (GUI)}: Aplikacja kliencka uruchamiana w przeglądarce użytkownika. Odpowiada za prezentowanie formularzy, obsługę plików PNG, podgląd heatmap i zarządzanie profilami subskrypcji. + \item \textbf{Nginx Router (Gateway)}: Odpowiada za kierowanie ruchu zewnętrznego (routing) do odpowiednich mikroserwisów na podstawie prefiksów ścieżek URL, pełniąc funkcję API Gateway. + \item \textbf{Auth Controller \& JWT Service}: Części składowe \texttt{auth-server}. Odpowiadają za weryfikację haseł (BCrypt), generowanie tokenów JWT oraz rejestrację nowych użytkowników. + \item \textbf{Token Reservation \& Subscription Controller}: Komponenty \texttt{subscription-service}. Odpowiadają odpowiednio za przyjmowanie żądań rezerwacji tokenów oraz zarządzanie sesjami płatniczymi i planami. + \item \textbf{Watermark Router \& Engine}: Komponenty usługi \texttt{watermark-service-py}. Router wystawia punkty końcowe (REST API), a Engine wykonuje algorytm częstotliwościowy przy użyciu biblioteki \texttt{invisible-watermark}. + \item \textbf{Crypto Manager}: Komponent pomocniczy usługi watermarkingu. Szyfruje tekst znaku wodnego z użyciem AES-GCM przed osadzeniem, używając klucza wyprowadzonego z sekretu aplikacji. + \item \textbf{AI Controller \& ONNX Runtime Classifier}: Części składowe \texttt{ai-service}. Pobierają obraz i przekazują go do silnika ONNX Runtime uruchamiającego sieć MobileNetV2, która wykonuje klasyfikację. +\end{itemize} + +\newpage + +% --- 5. DIAGRAM KLAS --- +\section{Diagram klas} + +\subsection{Diagram klas} +Diagram klas prezentuje kluczowe encje bazodanowe, rekordy oraz interfejsy wchodzące w skład warstwy domenowej systemów \texttt{subscription-service}, \texttt{auth-server} oraz strukturę modułu w \texttt{watermark-service-py}. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{classes.pdf} + \caption{Diagram klas domenowych systemu StegoCloud} + \label{fig:classes} +\end{figure} + +\subsection{Opis kluczowych struktur danych} +\begin{itemize} + \item \textbf{User \& UserRole}: Reprezentują użytkownika w systemie autoryzacji wraz z rolą przypisaną na potrzeby kontroli dostępu na poziomie endpointów (RBAC). + \item \textbf{SubscriptionPlan}: Klasa definiująca właściwości planu taryfowego -- liczbę darmowych tokenów na miesiąc oraz zbiór dozwolonych operacji (\texttt{allowedOperations}). + \item \textbf{ActiveSubscription}: Rekord domenowy reprezentujący powiązanie użytkownika z konkretnym planem (\texttt{planCode}) wraz z okresem jego ważności (\texttt{activeFrom}, \texttt{activeUntil}). Posiada metodę \texttt{isExpiredAt(Instant)} określającą ważność subskrypcji. + \item \textbf{TokenBalance}: Rekord reprezentujący bilans tokenów zalogowanego użytkownika. Zawiera informację o tokenach dostępnych (\texttt{availableTokens}) oraz zablokowanych na poczet trwających operacji (\texttt{reservedTokens}). + \item \textbf{TokenReservation}: Reprezentuje rezerwację tokenów dokonaną przez system podczas rozpoczęcia operacji steganograficznej. Rekord domenowy zawiera unikalne ID (\texttt{UUID}), typ operacji oraz koszt tokenowy, natomiast status cyklu życia (\texttt{RESERVED}, \texttt{CONSUMED}, \texttt{RELEASED}) jest utrwalany w encji \texttt{TokenReservationEntity}. + \item \textbf{TokenDecision}: Zamknięty interfejs (\texttt{sealed interface}), który przy użyciu mechanizmów Javy 21 wymusza kompletną obsługę wszystkich możliwych decyzji biznesowych dotyczących przydziału tokenów (akceptacja lub konkretny powód odmowy). +\end{itemize} + +\newpage + +% --- 6. DIAGRAMY INTERAKCJI --- +\section{Diagramy interakcji} + +\subsection{Diagram interakcji: Osadzenie znaku wodnego z klasyfikacją AI} +Diagram przedstawia sekwencję komunikatów i wywołań API podczas osadzania tekstu w obrazie z opcjonalną weryfikacją zawartości przez sztuczną inteligencję. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{seq_embed_ai.pdf} + \caption{Diagram sekwencyjny: Proces osadzania znaku wodnego z klasyfikacją AI} + \label{fig:seq_embed_ai} +\end{figure} + +\subsection{Diagram interakcji: Zakup / Upgrade planu subskrypcji} +Diagram przedstawia przebieg rejestracji sesji płatniczej, jej opłacenia oraz ostatecznej aktualizacji danych abonamentowych w bazie danych. + +\begin{figure}[H] + \centering + \includegraphics[width=\textwidth]{seq_subscription_upgrade.pdf} + \caption{Diagram sekwencyjny: Proces zakupu / upgrade'u planu subskrypcji} + \label{fig:seq_sub_upgrade} +\end{figure} + +\newpage + +% --- 7. STACK TECHNOLOGICZNY --- +\section{Stack technologiczny} + +Wybrane komponenty systemu zostały zaimplementowane przy użyciu technologii dobranych pod kątem ich zalet wydajnościowych oraz spełnienia celów dydaktycznych i biznesowych projektu. + +\begin{table}[H] +\centering +\caption{Wykorzystane technologie i uzasadnienie wyboru} +\begin{tabularx}{\textwidth}{l|p{4cm}|X} +\toprule +\textbf{Komponent} & \textbf{Technologia} & \textbf{Uzasadnienie wyboru} \\ +\midrule +Backend Core & Java 21 / Spring Boot 3 & Zapewnia stabilne środowisko, wysokie bezpieczeństwo typów oraz wsparcie dla najnowszych funkcjonalności JDK (rekordy, dopasowywanie wzorców dla switcha, sealed classes), które upraszczają kod domeny subskrypcji. \\ +\midrule +Steganografia & Python 3.12 / FastAPI & Znakowanie obrazów wymaga niskopoziomowych operacji matematycznych (DWT-DCT-SVD) oraz wsparcia dla zaawansowanych bibliotek naukowych (NumPy, OpenCV, invisible-watermark). FastAPI zapewnia asynchroniczność oraz generuje automatyczną dokumentację OpenAPI. \\ +\midrule +AI/Klasyfikacja & ONNX Runtime (Java) & Umożliwia ładowanie i szybkie uruchamianie modeli sieci neuronowych (\texttt{MobileNetV2} wyeksportowanego z PyTorch) bezpośrednio w procesie Javy z optymalizacją sprzętową, bez potrzeby stawiania ciężkiego środowiska PyTorch. \\ +\midrule +Baza Danych & PostgreSQL 17 & Stabilna, relacyjna baza danych obsługująca transakcje ACID niezbędne w logice rozliczania tokenów i płatności. Zapewnia izolację za pomocą osobnych schematów bazodanowych. \\ +\midrule +Frontend GUI & Svelte 5 (SvelteKit) / nginx & Svelte generuje niezwykle lekki kod kliencki (brak narzutu Virtual DOM), co przekłada się na szybkie ładowanie interfejsu. Nginx służy jako serwer statyczny i Gateway proxy. \\ +\midrule +Narzędzia & Docker Compose / SonarQube & Konteneryzacja ułatwia lokalne uruchamianie całego rozproszonego środowiska. SonarQube umożliwia ciągłą analizę statyczną kodu w celu utrzymania długu technicznego na niskim poziomie. \\ +\bottomrule +\end{tabularx} +\end{table} + +\newpage + +% --- 8. DZIAŁANIE SYSTEMU --- +\section{Działanie systemu} + +W tej sekcji przedstawiono kluczowe zrzuty ekranu prezentujące działanie systemu StegoCloud, wykonane na uruchomionej instancji (użytkownik \texttt{pro} z planem \texttt{PRO}). + +Typowy scenariusz korzystania z platformy obejmuje cztery kroki: (1) zalogowanie się i przegląd planu subskrypcji oraz salda tokenów; (2) wgranie pliku PNG i wpisanie ukrywanego tekstu w zakładce \emph{Embed} (z opcjonalną klasyfikacją AI dostępną dla planu \texttt{PRO}), a następnie pobranie oznaczonego obrazu; (3) weryfikację oznaczenia za pomocą zakładek \emph{Detect} (sprawdzenie właściciela) oraz \emph{Extract} (odczyt ukrytego tekstu); (4) podgląd rozmieszczenia znaku wodnego na mapie ciepła w zakładce \emph{Visualize}. Poniższe zrzuty ekranu ilustrują kolejne etapy tego przepływu. + +\subsection{Zrzuty ekranu interfejsu użytkownika} + +\begin{figure}[H] + \centering + \begin{subfigure}[t]{0.45\textwidth} + \centering + \includegraphics[width=\textwidth]{screen_login.png} + \caption{Ekran logowania z kontami demonstracyjnymi} + \end{subfigure} + \hfill + \begin{subfigure}[t]{0.53\textwidth} + \centering + \includegraphics[width=\textwidth]{screen_subscription.png} + \caption{Panel planów subskrypcji wraz z saldem tokenów} + \end{subfigure} + \caption{Ekran logowania oraz panel wyboru planu subskrypcji (Free, Standard, Pro) i salda tokenów} + \label{fig:screen_login} +\end{figure} + +\begin{figure}[H] + \centering + \includegraphics[width=0.56\textwidth]{screen_embed.png} + \caption{Panel osadzania znaku wodnego: Capacity Check (rozmiar, tier \texttt{EMBED\_768}, limit tekstu, kontrola planu), klasyfikacja AI (\texttt{dog}, ufność 34\%) oraz wynikowy obraz PNG ze znakiem wodnym} + \label{fig:screen_embed} +\end{figure} + +\begin{figure}[H] + \centering + \begin{subfigure}[t]{0.49\textwidth} + \centering + \includegraphics[width=\textwidth]{screen_detect.png} + \caption{Detekcja: wykryty znak wodny i tożsamość właściciela (\texttt{pro-4})} + \end{subfigure} + \hfill + \begin{subfigure}[t]{0.49\textwidth} + \centering + \includegraphics[width=\textwidth]{screen_extract.png} + \caption{Ekstrakcja: odczytany ukryty tekst (\texttt{StegoCloud2026})} + \end{subfigure} + + \vspace{0.4cm} + \begin{subfigure}[t]{0.5\textwidth} + \centering + \includegraphics[width=\textwidth]{screen_visualize.png} + \caption{Wizualizacja: heatmapa różnic pikseli (pixel-diff)} + \end{subfigure} + \caption{Detekcja znaku wodnego, ekstrakcja ukrytego tekstu oraz wizualizacja heatmapy różnic} + \label{fig:screen_detect} +\end{figure} + +\newpage + +% --- 9. PODSUMOWANIE --- +\section{Podsumowanie} +Projekt \textbf{StegoCloud} z powodzeniem integruje zaawansowane mechanizmy platformy Java 21, architekturę mikroserwisową Spring Cloud oraz dedykowany silnik przetwarzania obrazów w języku Python. + +Dzięki zastosowaniu wzorca rezerwacji tokenów system gwarantuje odporność na błędy sieciowe i transakcyjne -- użytkownicy są obciążani tokenami wyłącznie za operacje, które zostały pomyślnie zakończone. Zastosowanie szyfrowania kopertowego AES-GCM oraz Reed-Solomon ECC w warstwie steganografii gwarantuje wysoki poziom poufności i odporności na zakłócenia. System spełnia wymagania stawiane nowoczesnym aplikacjom korporacyjnym, a jego modularna budowa pozwala na łatwe dodawanie kolejnych algorytmów znakowania oraz modeli klasyfikacji AI w przyszłości. + +\end{document} diff --git a/docs/project/render_puml.py b/docs/project/render_puml.py new file mode 100644 index 0000000..c7df33b --- /dev/null +++ b/docs/project/render_puml.py @@ -0,0 +1,59 @@ +import sys +import os +import zlib +import base64 +import urllib.request + +def plantuml_encode(plantuml_text): + # 1. UTF-8 encode + utf8_str = plantuml_text.encode('utf-8') + # 2. Compress using deflate (zlib without headers/footers) + # We use zlib.compressobj to get raw deflate + compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -15) + compressed = compressor.compress(utf8_str) + compressed += compressor.flush() + + # 3. Custom Base64 encoding + b64_encoded = base64.b64encode(compressed).decode('utf-8') + + # Translate standard base64 alphabet to PlantUML's alphabet + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + plantuml_alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" + trans = str.maketrans(alphabet, plantuml_alphabet) + + return b64_encoded.translate(trans).replace('=', '') + +def render_file(puml_filepath, png_filepath): + print(f"Reading {puml_filepath}...") + with open(puml_filepath, "r", encoding="utf-8") as f: + text = f.read() + + encoded = plantuml_encode(text) + url = f"http://www.plantuml.com/plantuml/png/{encoded}" + + print(f"Downloading from {url}...") + try: + req = urllib.request.Request( + url, + headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} + ) + with urllib.request.urlopen(req) as response: + with open(png_filepath, "wb") as f_out: + f_out.write(response.read()) + print(f"Saved rendered image to {png_filepath}") + return True + except Exception as e: + print(f"Error rendering diagram: {e}") + return False + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python render_puml.py ") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] + + success = render_file(input_file, output_file) + if not success: + sys.exit(1) diff --git a/docs/project/screen_detect.png b/docs/project/screen_detect.png new file mode 100644 index 0000000..8309974 Binary files /dev/null and b/docs/project/screen_detect.png differ diff --git a/docs/project/screen_embed.png b/docs/project/screen_embed.png new file mode 100644 index 0000000..2453462 Binary files /dev/null and b/docs/project/screen_embed.png differ diff --git a/docs/project/screen_extract.png b/docs/project/screen_extract.png new file mode 100644 index 0000000..b070a0c Binary files /dev/null and b/docs/project/screen_extract.png differ diff --git a/docs/project/screen_login.png b/docs/project/screen_login.png new file mode 100644 index 0000000..46fb891 Binary files /dev/null and b/docs/project/screen_login.png differ diff --git a/docs/project/screen_subscription.png b/docs/project/screen_subscription.png new file mode 100644 index 0000000..2b07afb Binary files /dev/null and b/docs/project/screen_subscription.png differ diff --git a/docs/project/screen_visualize.png b/docs/project/screen_visualize.png new file mode 100644 index 0000000..d14888a Binary files /dev/null and b/docs/project/screen_visualize.png differ diff --git a/docs/project/seq_embed_ai.pdf b/docs/project/seq_embed_ai.pdf new file mode 100644 index 0000000..fb94171 Binary files /dev/null and b/docs/project/seq_embed_ai.pdf differ diff --git a/docs/project/seq_embed_ai.puml b/docs/project/seq_embed_ai.puml new file mode 100644 index 0000000..5743612 --- /dev/null +++ b/docs/project/seq_embed_ai.puml @@ -0,0 +1,96 @@ +@startuml +skinparam dpi 150 +autonumber +skinparam BoxBackgroundColor LightGray +skinparam BoxBorderColor Gray +skinparam ParticipantBackgroundColor LightCyan +skinparam ParticipantBorderColor DarkCyan +skinparam ActorBackgroundColor LightYellow +skinparam ActorBorderColor Orange + +actor "Użytkownik (GUI)" as gui +participant "Nginx Router" as nginx +participant "Watermark Service\n(Python)" as wm +participant "Subscription Service\n(Java/Spring)" as sub +database "Baza Danych" as db +participant "AI Service\n(Java/ONNX)" as ai + +gui -> nginx : POST /api/watermark/embed\n(JWT, image.png, text) +activate nginx +nginx -> wm : /embed +activate wm + +wm -> wm : Sprawdzenie pojemności\ni formatu PNG +wm -> sub : POST /api/tokens/reservations\n(EMBED_768 lub EMBED_1024) +activate sub + +sub -> db : Sprawdzenie planu i salda +activate db +db --> sub : dane subskrypcji +deactivate db + +sub -> db : Rezerwacja tokenów (status RESERVED) +activate db +db --> sub : rezerwacja zapisana +deactivate db + +sub --> wm : 201 Created (reservationId) +deactivate sub + +alt Plan użytkownika pozwala na AI (PRO) oraz saldo jest wystarczające + wm -> sub : POST /api/tokens/reservations\n(AI_CLASSIFICATION) + activate sub + sub -> db : Rezerwacja 2 tokenów + activate db + db --> sub : zapisana + deactivate db + sub --> wm : 201 Created (aiReservationId) + deactivate sub + + wm -> ai : POST /api/classify (image.png) + activate ai + ai -> ai : Klasyfikacja obrazu\n(ONNX MobileNetV2) + ai --> wm : 200 OK (label, confidence) + deactivate ai + + wm -> sub : POST /api/tokens/reservations/{aiReservationId}/consume + activate sub + sub -> db : Pobranie 2 tokenów z salda\n(rezerwacja -> CONSUMED) + activate db + db --> sub : zaktualizowano + deactivate db + sub --> wm : 200 OK + deactivate sub +else Plan FREE/STANDARD lub brak tokenów + note over wm : Pomiń klasyfikację AI\n(Zwróć status UNKNOWN dla AI) +end + +wm -> wm : Szyfrowanie payloadu (AES-GCM)\n+ Kodowanie Reed-Solomon +wm -> wm : Osadzanie bitów w DCT obrazu + +alt Osadzanie powiodło się + wm -> sub : POST /api/tokens/reservations/{reservationId}/consume + activate sub + sub -> db : Pobranie tokenów (rezerwacja -> CONSUMED) + activate db + db --> sub : zaktualizowano + deactivate db + sub --> wm : 200 OK + deactivate sub + wm --> nginx : 200 OK (obraz PNG z watermarkiem + nagłówki AI) + nginx --> gui : Zwrócenie obrazu i prezentacja w GUI +else Wystąpił błąd algorytmu lub brak pojemności + wm -> sub : POST /api/tokens/reservations/{reservationId}/release + activate sub + sub -> db : Zwolnienie tokenów (rezerwacja -> RELEASED) + activate db + db --> sub : zaktualizowano + deactivate db + sub --> wm : 200 OK + deactivate sub + wm --> nginx : 400 Bad Request / 422 Unprocessable Entity + deactivate wm + nginx --> gui : Błąd w interfejsie + deactivate nginx +end +@enduml diff --git a/docs/project/seq_embed_ai.svg b/docs/project/seq_embed_ai.svg new file mode 100644 index 0000000..f77f30d --- /dev/null +++ b/docs/project/seq_embed_ai.svg @@ -0,0 +1 @@ +Nginx RouterWatermark ServiceSubscription ServiceSubscription ServiceSubscription ServiceSubscription ServiceSubscription ServiceBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychAI ServiceU.ytkownik .GUI.Nginx RouterWatermark ServiceSubscription ServiceBaza DanychAI ServiceUżytkownik (GUI)Użytkownik (GUI)Nginx RouterNginx RouterWatermark Service(Python)Watermark Service(Python)Subscription Service(Java/Spring)Subscription Service(Java/Spring)Baza DanychBaza DanychAI Service(Java/ONNX)AI Service(Java/ONNX)Nginx RouterWatermark ServiceSubscription ServiceSubscription ServiceSubscription ServiceSubscription ServiceSubscription ServiceBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychAI Service1POST /api/watermark/embed(JWT, image.png, text)2/embed3Sprawdzenie pojemnościi formatu PNG4POST /api/tokens/reservations(EMBED_768 lub EMBED_1024)5Sprawdzenie planu i salda6dane subskrypcji7Rezerwacja tokenów (status RESERVED)8rezerwacja zapisana9201 Created (reservationId)alt[Plan użytkownika pozwala na AI (PRO) oraz saldo jest wystarczające]10POST /api/tokens/reservations(AI_CLASSIFICATION)11Rezerwacja 2 tokenów12zapisana13201 Created (aiReservationId)14POST /api/classify (image.png)15Klasyfikacja obrazu(ONNX MobileNetV2)16200 OK (label, confidence)17POST /api/tokens/reservations/{aiReservationId}/consume18Pobranie 2 tokenów z salda(rezerwacja -> CONSUMED)19zaktualizowano20200 OK[Plan FREE/STANDARD lub brak tokenów]Pomiń klasyfikację AI(Zwróć status UNKNOWN dla AI)21Szyfrowanie payloadu (AES-GCM)+ Kodowanie Reed-Solomon22Osadzanie bitów w DCT obrazualt[Osadzanie powiodło się]23POST /api/tokens/reservations/{reservationId}/consume24Pobranie tokenów (rezerwacja -> CONSUMED)25zaktualizowano26200 OK27200 OK (obraz PNG z watermarkiem + nagłówki AI)28Zwrócenie obrazu i prezentacja w GUI[Wystąpił błąd algorytmu lub brak pojemności]29POST /api/tokens/reservations/{reservationId}/release30Zwolnienie tokenów (rezerwacja -> RELEASED)31zaktualizowano32200 OK33400 Bad Request / 422 Unprocessable Entity34Błąd w interfejsie \ No newline at end of file diff --git a/docs/project/seq_subscription_upgrade.pdf b/docs/project/seq_subscription_upgrade.pdf new file mode 100644 index 0000000..49f00ae Binary files /dev/null and b/docs/project/seq_subscription_upgrade.pdf differ diff --git a/docs/project/seq_subscription_upgrade.puml b/docs/project/seq_subscription_upgrade.puml new file mode 100644 index 0000000..3ef75d7 --- /dev/null +++ b/docs/project/seq_subscription_upgrade.puml @@ -0,0 +1,84 @@ +@startuml +skinparam dpi 150 +autonumber +skinparam BoxBackgroundColor LightGray +skinparam BoxBorderColor Gray +skinparam ParticipantBackgroundColor LightCyan +skinparam ParticipantBorderColor DarkCyan +skinparam ActorBackgroundColor LightYellow +skinparam ActorBorderColor Orange + +actor "Użytkownik (GUI)" as gui +participant "Nginx Router" as nginx +participant "Subscription Service\n(Java/Spring)" as sub +database "Baza Danych" as db +participant "System Płatności\n(Mock Payment)" as pm + +gui -> nginx : POST /api/payments/mock/sessions\n(JWT, targetPlan: STANDARD) +activate nginx +nginx -> sub : /api/payments/mock/sessions +activate sub + +sub -> sub : Walidacja reguł biznesowych\n(brak downgrade'u, aktywny plan) +sub -> db : Zapis nowej sesji płatniczej\n(status: PENDING, targetPlan) +activate db +db --> sub : sesja zapisana +deactivate db + +sub --> nginx : 201 Created (sessionId) +deactivate sub +nginx --> gui : Zwrócenie sessionId i URL bramki +deactivate nginx + +gui -> pm : Przekierowanie na stronę płatności\n(URL z sessionId) +activate pm +gui -> pm : Użytkownik klika "Zatwierdź płatność" +pm -> nginx : POST /api/payments/mock/sessions/{sessionId}/succeed\n(JWT bramki/użytkownika) +activate nginx +nginx -> sub : /api/payments/mock/sessions/{id}/succeed +activate sub + +sub -> db : Pobranie sesji płatniczej po ID +activate db +db --> sub : dane sesji (status PENDING) +deactivate db + +sub -> sub : Weryfikacja właściciela sesji\n(Zabezpieczenie przed przejęciem) + +sub -> db : Aktualizacja statusu sesji na SUCCEEDED +activate db +db --> sub : zaktualizowano +deactivate db + +sub -> db : Aktualizacja planu subskrypcji użytkownika\n(PlanCode, ważność + 1 miesiąc) +activate db +db --> sub : zapisano subskrypcję +deactivate db + +sub -> db : Reset/Zasilenie salda tokenów\n(+500 tokenów dla STANDARD) +activate db +db --> sub : zaktualizowano saldo +deactivate db + +sub --> nginx : 200 OK (Payment successful) +deactivate sub +nginx --> pm : 200 OK +deactivate nginx + +pm --> gui : Przekierowanie zwrotne do aplikacji (Success page) +deactivate pm + +gui -> nginx : GET /api/subscriptions/me\nGET /api/subscriptions/me/tokens +activate nginx +nginx -> sub : Obsługa zapytań o stan konta +activate sub +sub -> db : Odczyt aktualnej subskrypcji i salda +activate db +db --> sub : dane subskrypcji i salda +deactivate db +sub --> nginx : 200 OK (Zaktualizowany plan i saldo) +deactivate sub +nginx --> gui : Wyświetlenie nowego planu (STANDARD) i 500 tokenów +deactivate nginx + +@enduml diff --git a/docs/project/seq_subscription_upgrade.svg b/docs/project/seq_subscription_upgrade.svg new file mode 100644 index 0000000..8c4ea1e --- /dev/null +++ b/docs/project/seq_subscription_upgrade.svg @@ -0,0 +1 @@ +Nginx RouterNginx RouterNginx RouterSubscription ServiceSubscription ServiceSubscription ServiceBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychSystem P.atno.ciU.ytkownik .GUI.Nginx RouterSubscription ServiceBaza DanychSystem P.atno.ciUżytkownik (GUI)Użytkownik (GUI)Nginx RouterNginx RouterSubscription Service(Java/Spring)Subscription Service(Java/Spring)Baza DanychBaza DanychSystem Płatności(Mock Payment)System Płatności(Mock Payment)Nginx RouterNginx RouterNginx RouterSubscription ServiceSubscription ServiceSubscription ServiceBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychBaza DanychSystem P.atno.ci1POST /api/payments/mock/sessions(JWT, targetPlan: STANDARD)2/api/payments/mock/sessions3Walidacja reguł biznesowych(brak downgrade'u, aktywny plan)4Zapis nowej sesji płatniczej(status: PENDING, targetPlan)5sesja zapisana6201 Created (sessionId)7Zwrócenie sessionId i URL bramki8Przekierowanie na stronę płatności(URL z sessionId)9Użytkownik klika "Zatwierdź płatność"10POST /api/payments/mock/sessions/{sessionId}/succeed(JWT bramki/użytkownika)11/api/payments/mock/sessions/{id}/succeed12Pobranie sesji płatniczej po ID13dane sesji (status PENDING)14Weryfikacja właściciela sesji(Zabezpieczenie przed przejęciem)15Aktualizacja statusu sesji na SUCCEEDED16zaktualizowano17Aktualizacja planu subskrypcji użytkownika(PlanCode, ważność + 1 miesiąc)18zapisano subskrypcję19Reset/Zasilenie salda tokenów(+500 tokenów dla STANDARD)20zaktualizowano saldo21200 OK (Payment successful)22200 OK23Przekierowanie zwrotne do aplikacji (Success page)24GET /api/subscriptions/meGET /api/subscriptions/me/tokens25Obsługa zapytań o stan konta26Odczyt aktualnej subskrypcji i salda27dane subskrypcji i salda28200 OK (Zaktualizowany plan i saldo)29Wyświetlenie nowego planu (STANDARD) i 500 tokenów \ No newline at end of file diff --git a/docs/project/svg_to_pdf.py b/docs/project/svg_to_pdf.py new file mode 100644 index 0000000..78f29a7 --- /dev/null +++ b/docs/project/svg_to_pdf.py @@ -0,0 +1,52 @@ +"""Convert PlantUML SVG diagrams to vector PDF with proper Polish glyphs. + +svglib falls back to reportlab's built-in Helvetica (no Polish letters) for the +generic "sans-serif" family used by PlantUML, which renders diacritics as boxes. +We register the full-Unicode DejaVuSans family and remap the SVG to use it. +""" +import sys +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from svglib.svglib import svg2rlg +from reportlab.graphics import renderPDF + +FONT_DIR = "C:/Windows/Fonts/" +FAMILY = "DejaVuSans" +VARIANTS = { + "DejaVuSans": "DejaVuSans.ttf", + "DejaVuSans-Bold": "DejaVuSans-Bold.ttf", + "DejaVuSans-Oblique": "DejaVuSans-Oblique.ttf", + "DejaVuSans-BoldOblique": "DejaVuSans-BoldOblique.ttf", +} + +for name, fname in VARIANTS.items(): + pdfmetrics.registerFont(TTFont(name, FONT_DIR + fname)) +pdfmetrics.registerFontFamily( + FAMILY, + normal="DejaVuSans", + bold="DejaVuSans-Bold", + italic="DejaVuSans-Oblique", + boldItalic="DejaVuSans-BoldOblique", +) + + +def convert(name): + with open(name + ".svg", "r", encoding="utf-8") as f: + svg = f.read() + # Force the Unicode font everywhere PlantUML used a generic sans-serif. + svg = svg.replace('font-family="sans-serif"', f'font-family="{FAMILY}"') + tmp = name + ".uni.svg" + with open(tmp, "w", encoding="utf-8") as f: + f.write(svg) + drawing = svg2rlg(tmp) + renderPDF.drawToFile(drawing, name + ".pdf") + print(f"ok {name}.pdf {round(drawing.width)}x{round(drawing.height)}") + + +if __name__ == "__main__": + targets = sys.argv[1:] or [ + "architecture", "components", "use_cases", + "classes", "seq_embed_ai", "seq_subscription_upgrade", + ] + for t in targets: + convert(t) diff --git a/docs/project/use_cases.pdf b/docs/project/use_cases.pdf new file mode 100644 index 0000000..28dfd77 Binary files /dev/null and b/docs/project/use_cases.pdf differ diff --git a/docs/project/use_cases.puml b/docs/project/use_cases.puml new file mode 100644 index 0000000..5d7eb7a --- /dev/null +++ b/docs/project/use_cases.puml @@ -0,0 +1,45 @@ +@startuml +skinparam dpi 150 +left to right direction +skinparam packageStyle rectangle +skinparam actor { + BackgroundColor LightYellow + BorderColor Orange +} +skinparam usecase { + BackgroundColor LightCyan + BorderColor DarkCyan +} + +actor "Użytkownik" as user +actor "Administrator" as admin +actor "Bramka Płatności" as payment + +rectangle "System StegoCloud" { + usecase "Sprawdzanie pojemności\n(Capacity Check)" as UC_capacity + usecase "Osadzanie znaku wodnego\n(Embed)" as UC_embed + usecase "Wykrywanie znaku wodnego\n(Detect)" as UC_detect + usecase "Wyodrębnianie tekstu\n(Extract)" as UC_extract + usecase "Wizualizacja różnic\n(Visualize)" as UC_visualize + usecase "Klasyfikacja obrazu AI\n(MobileNetV2)" as UC_ai + + usecase "Podgląd salda i subskrypcji\n(Get Subscription/Tokens)" as UC_status + usecase "Zakup / Upgrade planu\n(Upgrade Subscription)" as UC_upgrade + usecase "Procesowanie płatności\n(Process Payment)" as UC_payment_proc + + UC_embed .up.> UC_ai : <> + UC_upgrade .right.> UC_payment_proc : <> +} + +user --> UC_capacity +user --> UC_embed +user --> UC_detect +user --> UC_extract : własne obrazy +user --> UC_visualize +user --> UC_status +user --> UC_upgrade + +admin -up-> UC_extract : wszystkie obrazy + +payment --> UC_payment_proc +@enduml diff --git a/docs/project/use_cases.svg b/docs/project/use_cases.svg new file mode 100644 index 0000000..14e0d18 --- /dev/null +++ b/docs/project/use_cases.svg @@ -0,0 +1 @@ +System StegoCloudSprawdzanie pojemności(Capacity Check)Osadzanie znaku wodnego(Embed)Wykrywanie znaku wodnego(Detect)Wyodrębnianie tekstu(Extract)Wizualizacja różnic(Visualize)Klasyfikacja obrazu AI(MobileNetV2)Podgląd salda i subskrypcji(Get Subscription/Tokens)Zakup / Upgrade planu(Upgrade Subscription)Procesowanie płatności(Process Payment)UżytkownikAdministratorBramka Płatności«extend»«include»własne obrazywszystkie obrazy \ No newline at end of file diff --git a/eureka-server/.gitattributes b/eureka-server/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/eureka-server/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/eureka-server/.gitignore b/eureka-server/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/eureka-server/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/eureka-server/Dockerfile b/eureka-server/Dockerfile new file mode 100644 index 0000000..0147b82 --- /dev/null +++ b/eureka-server/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /workspace + +COPY . . +RUN chmod +x gradlew && ./gradlew :eureka-server:bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=builder /workspace/eureka-server/build/libs/*.jar app.jar +EXPOSE 8761 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] + diff --git a/eureka-server/build.gradle.kts b/eureka-server/build.gradle.kts new file mode 100644 index 0000000..811e05c --- /dev/null +++ b/eureka-server/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + java + jacoco + id("org.springframework.boot") version "3.5.11" + id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "7.2.3.7755" +} + +group = "pl.zzpj" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-server") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } +} + +tasks.named("sonar") { + dependsOn(tasks.jacocoTestReport) +} diff --git a/eureka-server/src/main/java/pl/zzpj/eureka_server/Application.java b/eureka-server/src/main/java/pl/zzpj/eureka_server/Application.java new file mode 100644 index 0000000..92dabcf --- /dev/null +++ b/eureka-server/src/main/java/pl/zzpj/eureka_server/Application.java @@ -0,0 +1,14 @@ +package pl.zzpj.eureka_server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +@SpringBootApplication +@EnableEurekaServer +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/eureka-server/src/main/java/pl/zzpj/eureka_server/config/SecurityConfig.java b/eureka-server/src/main/java/pl/zzpj/eureka_server/config/SecurityConfig.java new file mode 100644 index 0000000..0333ca6 --- /dev/null +++ b/eureka-server/src/main/java/pl/zzpj/eureka_server/config/SecurityConfig.java @@ -0,0 +1,22 @@ +package pl.zzpj.eureka_server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.ignoringRequestMatchers("/eureka/**")) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()); + + return http.build(); + } +} diff --git a/eureka-server/src/main/resources/application.yaml b/eureka-server/src/main/resources/application.yaml new file mode 100644 index 0000000..1a3a2ef --- /dev/null +++ b/eureka-server/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + application: + name: eureka-server + config: + import: "optional:configserver:http://localhost:8888" \ No newline at end of file diff --git a/eureka-server/src/test/java/pl/zzpj/eureka_server/ApplicationTests.java b/eureka-server/src/test/java/pl/zzpj/eureka_server/ApplicationTests.java new file mode 100644 index 0000000..4ed3ae1 --- /dev/null +++ b/eureka-server/src/test/java/pl/zzpj/eureka_server/ApplicationTests.java @@ -0,0 +1,11 @@ +package pl.zzpj.eureka_server; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = {"spring.cloud.config.enabled=false"}) +class ApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/eureka-server/src/test/java/pl/zzpj/eureka_server/EurekaServerIntegrationTest.java b/eureka-server/src/test/java/pl/zzpj/eureka_server/EurekaServerIntegrationTest.java new file mode 100644 index 0000000..63cf446 --- /dev/null +++ b/eureka-server/src/test/java/pl/zzpj/eureka_server/EurekaServerIntegrationTest.java @@ -0,0 +1,41 @@ +package pl.zzpj.eureka_server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "eureka.client.register-with-eureka=false", + "eureka.client.fetch-registry=false", + "spring.security.user.name=admin", + "spring.security.user.password=admin", + }) +class EurekaServerIntegrationTest { + + @Autowired private TestRestTemplate restTemplate; + + @Test + void shouldReturnUnauthorizedWithoutCredentials() { + ResponseEntity response = restTemplate.getForEntity("/", String.class); + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void shouldReturnDashboardWithCredentials() { + ResponseEntity response = + restTemplate.withBasicAuth("admin", "admin").getForEntity("/", String.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("Eureka")); + } +} diff --git a/eureka-server/src/test/java/pl/zzpj/eureka_server/architecture/ArchitectureTest.java b/eureka-server/src/test/java/pl/zzpj/eureka_server/architecture/ArchitectureTest.java new file mode 100644 index 0000000..4446c83 --- /dev/null +++ b/eureka-server/src/test/java/pl/zzpj/eureka_server/architecture/ArchitectureTest.java @@ -0,0 +1,73 @@ +package pl.zzpj.eureka_server.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +/** + * ArchUnit architecture rules scoped to the eureka-server base package. + * eureka-server is an infrastructure module with no controller/service/repository + * layers, so most rules match nothing; allowEmptyShould(true) keeps them green. + */ +class ArchitectureTest { + + private static final String BASE_PACKAGE = "pl.zzpj.eureka_server"; + + private static JavaClasses classesUnderTest; + + @BeforeAll + static void importClasses() { + classesUnderTest = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE_PACKAGE); + } + + @Test + void restControllersShouldBeNamedController() { + ArchRule rule = classes() + .that() + .areAnnotatedWith(RestController.class) + .should() + .haveSimpleNameEndingWith("Controller") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void controllerLayerShouldNotBeAccessedByLowerLayers() { + ArchRule rule = noClasses() + .that() + .resideInAnyPackage( + "..service..", + "..repository..", + "..entity.." + ) + .should() + .accessClassesThat() + .resideInAPackage("..controller..") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } + + @Test + void noClassShouldUseStandardStreams() { + ArchRule rule = noClasses() + .should() + .accessField(System.class, "out") + .orShould() + .accessField(System.class, "err") + .because("logging must go through Slf4j, not System.out/System.err") + .allowEmptyShould(true); + + rule.check(classesUnderTest); + } +} diff --git a/gradle/versions.gradle.kts b/gradle/versions.gradle.kts new file mode 100644 index 0000000..d9b43ce --- /dev/null +++ b/gradle/versions.gradle.kts @@ -0,0 +1,8 @@ +val springdocVersion = "2.8.5" + +extra["springCloudVersion"] = "2025.0.1" +extra["springdocVersion"] = springdocVersion + +allprojects { + extra["springdocVersion"] = springdocVersion +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aaaabb3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gui/.dockerignore b/gui/.dockerignore new file mode 100644 index 0000000..a80ae19 --- /dev/null +++ b/gui/.dockerignore @@ -0,0 +1,8 @@ +node_modules +build +.svelte-kit +.git +.gitignore +README.md +Dockerfile +.dockerignore diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..fd496dd --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1,27 @@ +node_modules +/.vscode + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Lockfile is intentionally not committed (project convention) +package-lock.json diff --git a/gui/.npmrc b/gui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/gui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/gui/Dockerfile b/gui/Dockerfile new file mode 100644 index 0000000..0be907e --- /dev/null +++ b/gui/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: build the static SPA +FROM node:22-alpine AS builder +WORKDIR /app + +# Lockfile is not committed (project convention); the glob keeps the COPY valid +# whether or not one is present locally. +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . +RUN npm run build + +# Stage 2: serve the static build with nginx, proxying API/auth calls to the backend +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/build /usr/share/nginx/html + +EXPOSE 80 diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..adbf838 --- /dev/null +++ b/gui/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.15.3 create --template minimal --types jsdoc --install npm gui +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/gui/jsconfig.json b/gui/jsconfig.json new file mode 100644 index 0000000..0b2d886 --- /dev/null +++ b/gui/jsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/gui/nginx.conf b/gui/nginx.conf new file mode 100644 index 0000000..1aa9c38 --- /dev/null +++ b/gui/nginx.conf @@ -0,0 +1,56 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Allow reasonably large image uploads through to the watermark service. + client_max_body_size 20m; + + # Reverse proxy so the browser uses same-origin relative paths. + location /api/subscriptions/ { + proxy_pass http://subscription-service:8085; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/payments/ { + proxy_pass http://subscription-service:8085; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/tokens/ { + proxy_pass http://subscription-service:8085; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://watermark-service:8082; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /auth/ { + proxy_pass http://auth-server:8081; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA fallback: every other route is served by the client-side router. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/gui/package.json b/gui/package.json new file mode 100644 index 0000000..bcb1bcb --- /dev/null +++ b/gui/package.json @@ -0,0 +1,23 @@ +{ + "name": "gui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } +} diff --git a/gui/src/app.d.ts b/gui/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/gui/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/gui/src/app.html b/gui/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/gui/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/gui/src/lib/assets/favicon.svg b/gui/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/gui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/gui/src/lib/index.js b/gui/src/lib/index.js new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/gui/src/lib/index.js @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/gui/src/lib/watermark-console/auth.js b/gui/src/lib/watermark-console/auth.js new file mode 100644 index 0000000..f723d3e --- /dev/null +++ b/gui/src/lib/watermark-console/auth.js @@ -0,0 +1,14 @@ +export function readRole(jwt) { + try { + const parts = jwt.split("."); + if (parts.length < 2) return "USER"; + const segment = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4); + const payload = JSON.parse(atob(padded)); + if (payload.role) return String(payload.role).toUpperCase(); + if (payload.sub === "admin" && payload.userId === 1) return "ADMIN"; + } catch { + return "USER"; + } + return "USER"; +} diff --git a/gui/src/lib/watermark-console/components/AccountPanel.svelte b/gui/src/lib/watermark-console/components/AccountPanel.svelte new file mode 100644 index 0000000..d851655 --- /dev/null +++ b/gui/src/lib/watermark-console/components/AccountPanel.svelte @@ -0,0 +1,56 @@ + + + diff --git a/gui/src/lib/watermark-console/components/DetectPanel.svelte b/gui/src/lib/watermark-console/components/DetectPanel.svelte new file mode 100644 index 0000000..fc70826 --- /dev/null +++ b/gui/src/lib/watermark-console/components/DetectPanel.svelte @@ -0,0 +1,52 @@ + + +
+
+

Detect watermark

+ DETECT: {operationCost("DETECT")} token +
+ + + + {#if error}
{error}
{/if} +
+ +{#if result} +
+ {#if result.watermarked} +
Watermark detected
+
+
+ Owner{result.ownerIdentity} +
+
+ Payload tier{result.lengthBits ?? "unknown"} +
+
+ {:else} +
No watermark detected
+ {/if} +
+{/if} diff --git a/gui/src/lib/watermark-console/components/ExtractPanel.svelte b/gui/src/lib/watermark-console/components/ExtractPanel.svelte new file mode 100644 index 0000000..0cb86bc --- /dev/null +++ b/gui/src/lib/watermark-console/components/ExtractPanel.svelte @@ -0,0 +1,52 @@ + + +
+
+

Extract hidden text

+ EXTRACT: {operationCost("EXTRACT")} tokens +
+ + + + {#if notice}
{notice}
{/if} + {#if error}
{error}
{/if} +
+ +{#if result} +
+

Hidden text

+
+
+ Owner{result.ownerIdentity} +
+
+
{result.text}
+
+{/if} diff --git a/gui/src/lib/watermark-console/components/OperationGate.svelte b/gui/src/lib/watermark-console/components/OperationGate.svelte new file mode 100644 index 0000000..0236775 --- /dev/null +++ b/gui/src/lib/watermark-console/components/OperationGate.svelte @@ -0,0 +1,13 @@ + + +{#if status} +
{status}
+{:else} +
+ {operationName(operation)} is available for your plan. +
+{/if} diff --git a/gui/src/lib/watermark-console/components/PricingPanel.svelte b/gui/src/lib/watermark-console/components/PricingPanel.svelte new file mode 100644 index 0000000..2ce1a8f --- /dev/null +++ b/gui/src/lib/watermark-console/components/PricingPanel.svelte @@ -0,0 +1,116 @@ + + +
+

Subscription plans

+
+ {#each plans as plan} +
+

{plan.code}

+

+ {plan.monthlyTokens} tokens / month +

+
    + {#each plan.allowedOperations as op} +
  • {operationName(op)}
  • + {/each} +
+ {#if subscription?.planCode === plan.code} + + {:else if canUpgradeTo(plan.code)} + + {:else} + + {/if} +
+ {/each} +
+ + {#if paymentSession} +
+
+

Mock Payment Session

+

+ Target Plan: {paymentSession.targetPlan} +

+

+ Status: {paymentSession.status} +

+ + {#if paymentSession.status === "PENDING"} +
+ + + +
+ {:else} +
+ Session finished. You can now close this or start a new + upgrade. + +
+ {/if} +
+
+ {/if} + + {#if paymentError} +
{paymentError}
+ {/if} +
diff --git a/gui/src/lib/watermark-console/components/Tabs.svelte b/gui/src/lib/watermark-console/components/Tabs.svelte new file mode 100644 index 0000000..b54bc19 --- /dev/null +++ b/gui/src/lib/watermark-console/components/Tabs.svelte @@ -0,0 +1,20 @@ + + + diff --git a/gui/src/lib/watermark-console/components/VisualizePanel.svelte b/gui/src/lib/watermark-console/components/VisualizePanel.svelte new file mode 100644 index 0000000..53db6a9 --- /dev/null +++ b/gui/src/lib/watermark-console/components/VisualizePanel.svelte @@ -0,0 +1,48 @@ + + +
+
+

Visualize watermark footprint

+ VISUALIZE: {operationCost("VISUALIZE")} tokens +
+ + + + {#if error}
{error}
{/if} +
+ +{#if imageUrl} +
+

Visualization

+
+ Watermark visualization +
+ + + +
+{/if} diff --git a/gui/src/lib/watermark-console/config.js b/gui/src/lib/watermark-console/config.js new file mode 100644 index 0000000..5164600 --- /dev/null +++ b/gui/src/lib/watermark-console/config.js @@ -0,0 +1,32 @@ +export const tabs = [ + { id: "embed", label: "Embed" }, + { id: "detect", label: "Detect" }, + { id: "extract", label: "Extract" }, + { id: "visualize", label: "Visualize" }, + { id: "pricing", label: "Pricing" }, +]; + +export const planOrder = ["FREE", "STANDARD", "PRO"]; + +export const maxImageSizeBytes = 20_000_000; +export const maxImageSizeLabel = "20 MB"; + +export const operationCosts = { + CAPACITY_CHECK: 0, + DETECT: 1, + EXTRACT: 2, + VISUALIZE: 3, + EMBED_768: 5, + EMBED_1024: 8, + AI_CLASSIFICATION: 2, +}; + +export const operationLabels = { + CAPACITY_CHECK: "Capacity check", + DETECT: "Detect", + EXTRACT: "Extract", + VISUALIZE: "Visualize", + EMBED_768: "Embed basic", + EMBED_1024: "Embed large", + AI_CLASSIFICATION: "AI classification", +}; diff --git a/gui/src/lib/watermark-console/domain.js b/gui/src/lib/watermark-console/domain.js new file mode 100644 index 0000000..7a504ae --- /dev/null +++ b/gui/src/lib/watermark-console/domain.js @@ -0,0 +1,31 @@ +import { operationCosts, operationLabels, planOrder } from "./config"; + +const textEncoder = new TextEncoder(); + +export function canUpgrade(currentPlanCode, targetPlan) { + return planOrder.indexOf(targetPlan) > planOrder.indexOf(currentPlanCode); +} + +export function formatPlanExpiry(activeUntil) { + if (!activeUntil) return "No expiration"; + return new Intl.DateTimeFormat("pl-PL", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(activeUntil)); +} + +export function operationCost(operation) { + return operationCosts[operation] ?? 0; +} + +export function operationName(operation) { + return operationLabels[operation] ?? operation; +} + +export function utf8ByteLength(text) { + return textEncoder.encode(text).length; +} + +export function fileSignature(file) { + return `${file.name}|${file.size}|${file.lastModified}`; +} diff --git a/gui/src/lib/watermark-console/http.js b/gui/src/lib/watermark-console/http.js new file mode 100644 index 0000000..697700b --- /dev/null +++ b/gui/src/lib/watermark-console/http.js @@ -0,0 +1,64 @@ +import { maxImageSizeLabel } from "./config"; + +export function createApiClient(getToken, onUnauthorized) { + async function request(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + ...(options.headers ?? {}), + Authorization: `Bearer ${getToken()}`, + }, + }); + if (response.status === 401) { + onUnauthorized(); + throw new Error("Sesja wygasła."); + } + return response; + } + + async function get(url) { + const response = await request(url); + if (!response.ok) throw new Error(await readError(response)); + return response.json(); + } + + async function post(url, body) { + const response = await request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(await readError(response)); + return response.json(); + } + + function postImage(url, image, text, options = {}) { + const formData = new FormData(); + formData.append("image", image); + if (text !== undefined) formData.append("text", text); + return request(url, { + method: "POST", + body: formData, + signal: options.signal, + }); + } + + return { get, post, postImage }; +} + +export async function readError(response) { + if (response.status === 413) { + return `Wybrany obraz jest za duży. Maksymalny rozmiar pliku to ${maxImageSizeLabel}.`; + } + try { + const data = await response.json(); + if (typeof data.detail === "string") return data.detail; + if (data.detail?.message) return data.detail.message; + if (data.message) return data.message; + if (data.error) return data.error; + if (data.code) return data.code; + } catch { + // Fall through to the generic message. + } + return "Wystąpił błąd podczas przetwarzania."; +} diff --git a/gui/src/routes/+layout.js b/gui/src/routes/+layout.js new file mode 100644 index 0000000..480095b --- /dev/null +++ b/gui/src/routes/+layout.js @@ -0,0 +1,4 @@ +// Pure SPA: no server-side rendering or prerendering. The app relies on +// localStorage (JWT) and runs entirely in the browser. +export const ssr = false; +export const prerender = false; diff --git a/gui/src/routes/+layout.svelte b/gui/src/routes/+layout.svelte new file mode 100644 index 0000000..5c4f0f7 --- /dev/null +++ b/gui/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children()} diff --git a/gui/src/routes/+page.svelte b/gui/src/routes/+page.svelte new file mode 100644 index 0000000..5ba19c1 --- /dev/null +++ b/gui/src/routes/+page.svelte @@ -0,0 +1,668 @@ + + +
+
+
+

Watermark Console

+

PNG watermarking with subscription-aware token limits.

+
+ +
+ + + + + + {#if activeTab === "embed"} +
+
+

Embed watermark

+ {embedOperation() + ? `${operationName(embedOperation())}: ${operationCost(embedOperation())} tokens` + : "Capacity check is free"} +
+ + + + {#if capacityChecking} +
Checking image capacity...
+ {:else if capacityError} +
{capacityError}
+ {:else if capacity && !capacity.imageOk} +
+ Image is too small: {capacity.imageWidth}x{capacity.imageHeight}. + Minimum is {capacity.minImageWidth}x{capacity.minImageHeight}. +
+ {:else if capacity} + {@const embedStatus = embedActionStatus()} +
+
+ Size{capacity.imageWidth}x{capacity.imageHeight} +
+
+ Tier{embedOperation()} +
+
+ Text limit{capacity.maxTextBytes} B +
+
+ Plan check{embedStatus || "Allowed"} +
+
+ {#if embedStatus} +
+ This image requires {operationName(embedOperation())}, + which is not available for the current plan or token + balance. The embed action is disabled for this image. +
+ {/if} + {#if embedAiStatus()} +
{embedAiStatus()}
+ {/if} + {/if} + + + + + + {#if embedError} +
{embedError}
+ {/if} +
+ + {#if resultImageUrl} +
+

Watermarked image

+ {#if classification} +
+ AI classification: {classification.category} + {#if classification.categoryConfidence !== null} + ({classification.categoryConfidence}%) + {/if} + {#if classification.label} + - {classification.label} + {/if} +
+ {:else} +
+ AI classification was skipped or unavailable. +
+ {/if} +
+ Watermarked output +
+ + + +
+ {/if} + {:else if activeTab === "detect"} + (detectFiles = files)} + onSubmit={detectWatermark} + /> + {:else if activeTab === "extract"} + (extractFiles = files)} + onSubmit={extractWatermark} + /> + {:else if activeTab === "visualize"} + (visualizeFiles = files)} + onSubmit={visualizeWatermark} + /> + {:else if activeTab === "pricing"} + (paymentSession = null)} + /> + {/if} +
diff --git a/gui/src/routes/login/+page.svelte b/gui/src/routes/login/+page.svelte new file mode 100644 index 0000000..f9d1cd5 --- /dev/null +++ b/gui/src/routes/login/+page.svelte @@ -0,0 +1,267 @@ + + +
+ +
+ + diff --git a/gui/src/routes/register/+page.svelte b/gui/src/routes/register/+page.svelte new file mode 100644 index 0000000..3769fa5 --- /dev/null +++ b/gui/src/routes/register/+page.svelte @@ -0,0 +1,190 @@ + + +
+
+
+

Create Account

+

New accounts start as regular users on the FREE plan.

+
+ +
+ + + + + + + +
+ + {#if errorMessage} +
{errorMessage}
+ {/if} + + +
+
+ + diff --git a/gui/src/routes/watermark-console.css b/gui/src/routes/watermark-console.css new file mode 100644 index 0000000..0ea0b31 --- /dev/null +++ b/gui/src/routes/watermark-console.css @@ -0,0 +1,444 @@ +body { + margin: 0; + background: #f3f5f7; + color: #1f2933; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +} + +.container { + max-width: 980px; + margin: 0 auto; + padding: 32px 20px 48px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; +} + +h1, +h2 { + margin: 0; + color: #172033; +} + +h1 { + font-size: 1.8rem; +} + +h2 { + font-size: 1.25rem; +} + +p { + margin: 6px 0 0; + color: #667085; +} + +.account-panel { + display: grid; + grid-template-columns: repeat(5, minmax(110px, 1fr)) auto; + gap: 10px; + align-items: stretch; + margin-bottom: 18px; +} + +.metric, +.panel, +.tab { + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; +} + +.metric { + padding: 12px 14px; +} + +.metric span, +.details span, +.capacity-grid span { + display: block; + color: #667085; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +.metric strong { + display: block; + margin-top: 4px; + font-size: 1.2rem; +} + +.metric strong.metric-date { + font-size: 0.95rem; +} + +.tabs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 18px; +} + +.tab { + padding: 12px 10px; + color: #344054; + font-weight: 700; + cursor: pointer; +} + +.tab.active { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} + +.tab:disabled { + background: #eef2f6; + color: #98a2b3; + cursor: not-allowed; +} + +.tab.active:disabled { + background: #98a2b3; + border-color: #98a2b3; + color: #fff; +} + +.panel { + padding: 22px; + margin-bottom: 18px; +} + +.panel-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} + +.cost { + color: #155e75; + background: #ecfeff; + border: 1px solid #a5f3fc; + border-radius: 999px; + padding: 5px 10px; + font-size: 0.84rem; + font-weight: 700; + white-space: nowrap; +} + +.field { + display: grid; + gap: 8px; + margin-bottom: 16px; + font-weight: 700; +} + +.field span { + display: flex; + justify-content: space-between; + gap: 12px; +} + +input[type="text"], +input[type="file"] { + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + padding: 12px 14px; + font-size: 1rem; +} + +input[type="text"]:focus { + outline: none; + border-color: #2563eb; + background: #fff; +} + +input:disabled { + background: #eef2f6; + color: #98a2b3; + cursor: not-allowed; +} + +.btn { + border: 0; + border-radius: 8px; + padding: 11px 16px; + font-weight: 800; + cursor: pointer; +} + +.btn-primary, +.btn-success { + width: 100%; + color: #fff; +} + +.btn-primary { + background: #2563eb; +} + +.btn-success { + background: #16803c; +} + +.btn-outline { + background: #fff; + color: #344054; + border: 1px solid #cbd5e1; +} + +.compact { + width: auto; + align-self: center; +} + +.btn:disabled { + background: #98a2b3; + cursor: not-allowed; +} + +.alert, +.notice { + border-radius: 8px; + padding: 11px 13px; + margin: 12px 0; + font-size: 0.93rem; +} + +.notice { + background: #f8fafc; + border: 1px solid #d9e2ec; + color: #475467; +} + +.alert-error { + background: #fff1f2; + border: 1px solid #fecdd3; + color: #be123c; +} + +.alert-warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} + +.alert-info { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1d4ed8; +} + +.capacity-grid, +.details { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 12px; +} + +.capacity-grid div, +.details div { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px; +} + +.capacity-grid div.blocked { + background: #fff7ed; + border-color: #fdba74; +} + +.capacity-grid div.blocked strong { + color: #9a3412; +} + +small { + color: #667085; + font-weight: 700; +} + +small.over { + color: #be123c; +} + +.result-panel { + text-align: center; +} + +.image-frame { + margin: 16px 0; + border: 1px dashed #cbd5e1; + border-radius: 8px; + padding: 10px; + background: #f8fafc; +} + +.image-frame img { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + border-radius: 4px; +} + +.download-link { + text-decoration: none; +} + +.status { + display: inline-block; + border-radius: 999px; + padding: 9px 16px; + font-weight: 800; + margin-bottom: 12px; +} + +.status.yes { + background: #dcfce7; + color: #166534; +} + +.status.no { + background: #fee2e2; + color: #991b1b; +} + +pre { + text-align: left; + white-space: pre-wrap; + word-break: break-word; + background: #0f172a; + color: #e2e8f0; + border-radius: 8px; + padding: 16px; +} + +.plans-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-top: 16px; +} + +.plan-card { + border: 1px solid #d9e2ec; + border-radius: 8px; + padding: 20px; + background: #fff; + display: flex; + flex-direction: column; + transition: + transform 0.2s, + border-color 0.2s; +} + +.plan-card.current { + border-color: #2563eb; + background: #eff6ff; + transform: scale(1.02); +} + +.plan-card h3 { + margin: 0 0 10px; + font-size: 1.4rem; + color: #172033; +} + +.plan-tokens { + font-size: 1.1rem; + margin-bottom: 16px; + color: #475467; +} + +.plan-ops { + margin: 0 0 20px; + padding: 0; + list-style: none; + flex-grow: 1; +} + +.plan-ops li { + padding: 4px 0; + font-size: 0.9rem; + color: #667085; +} + +.plan-ops li::before { + content: "✓"; + color: #16a34a; + margin-right: 8px; + font-weight: bold; +} + +.payment-session-status { + margin-top: 24px; + border-top: 2px dashed #d9e2ec; + padding-top: 24px; +} + +.payment-session-status.pending .panel { + border-color: #2563eb; + background: #f8fafc; +} + +.payment-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.btn-error { + background: #dc2626; + color: #fff; + width: 100%; +} + +.status-tag { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + background: #e2e8f0; + color: #475467; + font-size: 0.85rem; + text-transform: uppercase; +} + +.status-pending { + background: #fef3c7; + color: #92400e; +} + +@media (max-width: 760px) { + .topbar, + .panel-heading { + flex-direction: column; + align-items: stretch; + } + + .account-panel, + .tabs, + .capacity-grid, + .details { + grid-template-columns: 1fr; + } + + .compact { + width: 100%; + } +} diff --git a/gui/static/robots.txt b/gui/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/gui/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/gui/svelte.config.js b/gui/svelte.config.js new file mode 100644 index 0000000..70b9115 --- /dev/null +++ b/gui/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + // Static adapter in SPA mode: every route falls back to index.html and runs + // client-side only (see src/routes/+layout.js). Served by nginx in Docker. + adapter: adapter({ + fallback: 'index.html' + }) + } +}; + +export default config; diff --git a/gui/vite.config.js b/gui/vite.config.js new file mode 100644 index 0000000..5d2124f --- /dev/null +++ b/gui/vite.config.js @@ -0,0 +1,16 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + // Dev-only proxy so the same relative paths used in production (behind nginx) + // also work with `npm run dev`. In Docker, nginx handles this routing instead. + server: { + proxy: { + '/api/subscriptions': 'http://localhost:8085', + '/api/tokens': 'http://localhost:8085', + '/api': 'http://localhost:8082', + '/auth': 'http://localhost:8081' + } + } +}); diff --git a/intro-intellij/README.md b/intro-intellij/README.md deleted file mode 100644 index ace174c..0000000 --- a/intro-intellij/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Intro + Intellij orientation - -Introductory meeting - -# Java - Git - -**Task 1 - Java & sdkman** -- Install one of the latest Java (JDK 25): [jdk-link](https://www.oracle.com/pl/java/technologies/downloads/) -- Set environment variable: *JAVA_HOME* in your operating system -- Using command line or terminal, type: *java --version* -- ![](https://github.com/zzpj/pl-java2024/blob/main/intro/jdk21-mint.png "jdk21 mint") -- Manage SDK (including JDK) like a pro - using https://sdkman.io/ (Linux/Windows/Mac friendly) - -**Task 2 - Git** -- GitHub account creation: [sign up](https://github.com/) -- Git familiarization: [learn git branching](https://learngitbranching.js.org/) -- [Interesting GIT aliases](https://github.com/jakubnabrdalik/gitkurwa) - -Notable resources: -- - -+ [Pet Clinic Project](https://github.com/spring-projects/spring-petclinic) -+ [IntelliJ IDEA Conf 2024](https://youtu.be/ZD_YxTmQ16Q) -+ [IntelliJ IDEA. Debugger Essentials](https://youtu.be/59RC8gVPlvk) -+ [IntelliJ IDEA. Debugger Advanced](https://www.youtube.com/watch?v=40Og3hTV--k) -+ [IntelliJ IDEA. Debugger Professional](https://youtu.be/JPR3w3Qtwzw) -+ [Top 15 IntelliJ IDEA shortcuts](https://youtu.be/QYO5_riePOQ) -+ [open-source IntelliJ plugins](https://docs.google.com/spreadsheets/d/1TYXZd68TbuSRYj-9qlP2TBH1fD2nbLK3OGMKKmBBFKs) -+ [VIM as Your editor](https://www.youtube.com/playlist?list=PLGC1ANqgjg7m5z41mW-A5b-7CKUeCnWII) -+ [IntelliJ Wizardry with Heinz Kabutz 2022 Edition](https://javaspecialists.teachable.com/p/intellij-wizardry-2022) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..496690f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,7 @@ +rootProject.name = "pl-java2026" + +include("auth-server") +include("config-server") +include("eureka-server") +include("ai-service") +include("subscription-service") \ No newline at end of file diff --git a/stegocloud-openapi.json b/stegocloud-openapi.json new file mode 100644 index 0000000..7613689 --- /dev/null +++ b/stegocloud-openapi.json @@ -0,0 +1,890 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "StegoCloud Combined API", + "version": "1.1.0", + "description": "Combined OpenAPI documentation for all StegoCloud services.\n\nThis single document aggregates four independently deployed services. Paths are the REAL on-service paths, and each path declares its own `servers` entry (host port) so the API can be exercised directly from Swagger UI:\n\n- auth-server — http://localhost:8081 — paths under `/auth`\n- subscription-service — http://localhost:8085 — paths under `/api/subscriptions`, `/api/payments`, `/api/tokens`\n- ai-service — http://localhost:8084 — `/api/classify` (internal-only; normally invoked by watermark-service, not the browser)\n- watermark-service — http://localhost:8082 — paths under `/api/watermark` plus `/health`\n\nThere is no API gateway. A browser reaches the services same-origin through the GUI nginx proxy (port 5173): `/auth/` -> auth-server, `/api/subscriptions|payments|tokens/` -> subscription-service, other `/api/` -> watermark-service.\n\nAuth model: obtain a JWT from `POST /auth/login`, then send `Authorization: Bearer `. Protected services validate the token by calling auth-server `POST /auth/validate`.\n\nKNOWN ISSUE: the subscription-service token-reservation endpoints (`/api/tokens/reservations*`) are documented here as the intended contract, but the backing controller is currently missing its `@RestController`/`@RequestMapping(\"/api/tokens/reservations\")` annotations, so they return 404 until that is fixed. This breaks watermark paid operations, which depend on them." + }, + "servers": [ + { "url": "http://localhost:8081", "description": "auth-server" }, + { "url": "http://localhost:8085", "description": "subscription-service" }, + { "url": "http://localhost:8084", "description": "ai-service (internal-only)" }, + { "url": "http://localhost:8082", "description": "watermark-service" } + ], + "tags": [ + { "name": "auth: Authentication", "description": "Registration, login and JWT validation (auth-server, port 8081). All endpoints are public." }, + { "name": "subscription: Subscription Status", "description": "Service health/status (subscription-service, port 8085). Public." }, + { "name": "subscription: Subscription Query", "description": "Plans, current subscription and token balance (subscription-service, port 8085)." }, + { "name": "subscription: Mock Payment", "description": "Mock payment session lifecycle for plan upgrades (subscription-service, port 8085)." }, + { "name": "subscription: Token Reservation", "description": "Reserve/consume/release operation tokens (subscription-service, port 8085). KNOWN ISSUE: controller not wired (returns 404) until @RestController/@RequestMapping are added." }, + { "name": "ai: Classification", "description": "ONNX MobileNetV2 image classification (ai-service, port 8084). Internal-only." }, + { "name": "watermark: Watermark", "description": "Encrypted PNG watermark embed/detect/extract/visualize/capacity (watermark-service, port 8082)." }, + { "name": "watermark: Health", "description": "Watermark-service liveness (port 8082). Public." } + ], + "paths": { + "/auth/register": { + "servers": [{ "url": "http://localhost:8081", "description": "auth-server" }], + "post": { + "tags": ["auth: Authentication"], + "summary": "Register user", + "description": "Creates a new user account. Unknown JSON properties are rejected. New accounts get the USER role.", + "operationId": "register", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/auth_RegisterRequest" } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/auth_RegisterResponse" } + } + } + }, + "400": { + "description": "Validation failure (field->message map), malformed JSON, or unknown property", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/auth_FieldErrors" } } + } + }, + "409": { + "description": "Duplicate email or username (field->message map)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/auth_FieldErrors" } } + } + } + } + } + }, + "/auth/login": { + "servers": [{ "url": "http://localhost:8081", "description": "auth-server" }], + "post": { + "tags": ["auth: Authentication"], + "summary": "Login user", + "description": "Authenticates a user and returns a signed JWT (HMAC-SHA, 24h expiry, claims sub/userId/role).", + "operationId": "login", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/auth_LoginRequest" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/auth_LoginResponse" } + } + } + }, + "400": { + "description": "Validation failure (field->message map)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/auth_FieldErrors" } } + } + }, + "401": { + "description": "Invalid credentials. Plain-text body: \"Invalid password\" or \"User not found\".", + "content": { "text/plain": { "schema": { "type": "string" } } } + } + } + } + }, + "/auth/validate": { + "servers": [{ "url": "http://localhost:8081", "description": "auth-server" }], + "post": { + "tags": ["auth: Authentication"], + "summary": "Validate token", + "description": "Returns true if the JWT parses and the signature is valid, false otherwise. Never errors on a bad token. Used by other services to validate bearer tokens.", + "operationId": "validateToken", + "parameters": [ + { + "name": "token", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { "application/json": { "schema": { "type": "boolean" } } } + } + } + } + }, + "/api/subscriptions/status": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "get": { + "tags": ["subscription: Subscription Status"], + "summary": "Service status", + "description": "Public health/status of the subscription service.", + "operationId": "status", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_ServiceStatus" } } + } + } + } + } + }, + "/api/subscriptions/plans": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "get": { + "tags": ["subscription: Subscription Query"], + "summary": "List available plans", + "description": "Returns all subscription plans with their monthly token grant and allowed operations.", + "operationId": "plans", + "security": [{ "HTTPBearer": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/subscription_PlanView" } } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/subscriptions/me": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "get": { + "tags": ["subscription: Subscription Query"], + "summary": "Current subscription", + "description": "Returns the caller's active subscription. A user with no subscription is auto-initialized to FREE.", + "operationId": "currentSubscription", + "security": [{ "HTTPBearer": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_CurrentSubscriptionView" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/subscriptions/me/tokens": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "get": { + "tags": ["subscription: Subscription Query"], + "summary": "Token balance", + "description": "Returns the caller's available and reserved token counts.", + "operationId": "tokenBalance", + "security": [{ "HTTPBearer": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenBalanceView" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/payments/mock/sessions": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Mock Payment"], + "summary": "Create payment session", + "description": "Starts a mock payment session for a plan upgrade. The target must be a valid upgrade (FREE->STANDARD, FREE->PRO, STANDARD->PRO); downgrades or repurchasing the active plan are rejected. NOTE: subscription-service has no @RestControllerAdvice, so domain violations surface as HTTP 500 with Spring's default error body.", + "operationId": "createSession", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/subscription_CreatePaymentSessionRequest" } + } + } + }, + "responses": { + "200": { + "description": "OK (status PENDING)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_PaymentSessionResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/SpringDefaultError" } + } + } + }, + "/api/payments/mock/sessions/{sessionId}/succeed": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Mock Payment"], + "summary": "Finalize session as succeeded", + "description": "Marks the session SUCCEEDED and applies the plan: starts a new one-month period and adds the plan's monthly tokens to the balance. Idempotent and owner-scoped.", + "operationId": "succeed", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "OK (status SUCCEEDED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_PaymentSessionResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/SpringDefaultError" } + } + } + }, + "/api/payments/mock/sessions/{sessionId}/fail": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Mock Payment"], + "summary": "Finalize session as failed", + "description": "Marks the session FAILED. No subscription change. Idempotent and owner-scoped.", + "operationId": "fail", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "OK (status FAILED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_PaymentSessionResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/SpringDefaultError" } + } + } + }, + "/api/payments/mock/sessions/{sessionId}/cancel": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Mock Payment"], + "summary": "Finalize session as cancelled", + "description": "Marks the session CANCELLED. No subscription change. Idempotent and owner-scoped.", + "operationId": "cancel", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "OK (status CANCELLED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_PaymentSessionResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/SpringDefaultError" } + } + } + }, + "/api/tokens/reservations": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Token Reservation"], + "summary": "Reserve tokens", + "description": "Reserves tokens for an operation (15-minute TTL). KNOWN ISSUE: the backing controller is missing @RestController/@RequestMapping(\"/api/tokens/reservations\") and currently returns 404; documented as the intended contract.", + "operationId": "reserveTokens", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/subscription_CreateTokenReservationRequest" } + } + } + }, + "responses": { + "201": { + "description": "Created (status RESERVED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenReservationResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { + "description": "OPERATION_NOT_ALLOWED — plan does not permit the operation", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenReservationErrorResponse" } } + } + }, + "409": { + "description": "Reservation rejected — code one of INSUFFICIENT_TOKENS, PLAN_NOT_FOUND, SUBSCRIPTION_EXPIRED", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenReservationErrorResponse" } } + } + } + } + } + }, + "/api/tokens/reservations/{reservationId}/consume": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Token Reservation"], + "summary": "Consume reservation", + "description": "Finalizes a reservation, deducting the tokens from the balance. Owner-scoped. KNOWN ISSUE: see POST /api/tokens/reservations.", + "operationId": "consumeReservation", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { "name": "reservationId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "OK (status CONSUMED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenReservationResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/tokens/reservations/{reservationId}/release": { + "servers": [{ "url": "http://localhost:8085", "description": "subscription-service" }], + "post": { + "tags": ["subscription: Token Reservation"], + "summary": "Release reservation", + "description": "Cancels a reservation, returning the tokens to the balance. Owner-scoped. KNOWN ISSUE: see POST /api/tokens/reservations.", + "operationId": "releaseReservation", + "security": [{ "HTTPBearer": [] }], + "parameters": [ + { "name": "reservationId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } + ], + "responses": { + "200": { + "description": "OK (status RELEASED)", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/subscription_TokenReservationResponse" } } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/classify": { + "servers": [{ "url": "http://localhost:8084", "description": "ai-service (internal-only)" }], + "post": { + "tags": ["ai: Classification"], + "summary": "Classify image", + "description": "Classifies an image with an ONNX MobileNetV2 model. Internal-only: in normal operation watermark-service calls this during embed; it is not exposed via the GUI proxy. Multipart limits: file 20MB, request 25MB.", + "operationId": "classify", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { "file": { "type": "string", "format": "binary" } }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/ai_ClassificationResult" } } + } + }, + "400": { + "description": "Bad request (IllegalArgumentException)", + "content": { "text/plain": { "schema": { "type": "string" } } } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { + "description": "Classification/IO/ONNX error or unreadable/oversized image", + "content": { "text/plain": { "schema": { "type": "string" } } } + } + } + } + }, + "/api/watermark/embed": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "post": { + "tags": ["watermark: Watermark"], + "summary": "Embed watermark", + "description": "Embeds an AES-GCM encrypted text watermark into a PNG using the caller's identity as owner. Reserves EMBED_768 or EMBED_1024 tokens (by image pixel count vs 1920x1080) and, when the plan/balance allow, AI_CLASSIFICATION. Returns the watermarked PNG bytes.", + "operationId": "embed", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { "$ref": "#/components/schemas/watermark_EmbedBody" } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PNG image bytes", + "headers": { + "X-Image-Category": { "schema": { "type": "string" }, "description": "AI category (\"unknown\" when not classified)" }, + "X-Image-Label": { "schema": { "type": "string" }, "description": "AI top-1 label" }, + "X-Image-Confidence": { "schema": { "type": "string" }, "description": "AI top-1 confidence" }, + "X-Image-Category-Confidence": { "schema": { "type": "string" }, "description": "AI aggregated category confidence" }, + "X-Max-Text-Bytes": { "schema": { "type": "string" }, "description": "Max embeddable text bytes for this image/owner" }, + "X-Watermark-Length-Bits": { "schema": { "type": "string" }, "description": "Selected watermark tier in bits (768 or 1024)" } + }, + "content": { "image/png": { "schema": { "type": "string", "format": "binary" } } } + }, + "400": { + "description": "Blank text, non-PNG image, image too small, or text too long for image/owner", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "401": { "$ref": "#/components/responses/WatermarkUnauthorized" }, + "403": { + "description": "Token reservation forbidden for the operation (propagated from subscription-service)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "409": { + "description": "Token reservation conflict (propagated from subscription-service)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "413": { + "description": "Text larger than 4096 bytes (or image larger than 25MB)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "422": { + "description": "Embed verification failed at every tier, or request validation error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HTTPValidationError" } } } + }, + "503": { + "description": "auth-server or subscription-service unavailable", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + } + } + } + }, + "/api/watermark/detect": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "post": { + "tags": ["watermark: Watermark"], + "summary": "Detect watermark", + "description": "Checks whether a PNG contains a StegoCloud watermark and returns the owner identity if found. Reserves DETECT (1 token).", + "operationId": "detect", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { "schema": { "$ref": "#/components/schemas/watermark_ImageBody" } } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_DetectResponse" } } } + }, + "401": { "$ref": "#/components/responses/WatermarkUnauthorized" }, + "413": { + "description": "Image larger than 25MB", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "422": { + "description": "Request validation error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HTTPValidationError" } } } + }, + "503": { + "description": "auth-server or subscription-service unavailable", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + } + } + } + }, + "/api/watermark/extract": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "post": { + "tags": ["watermark: Watermark"], + "summary": "Extract watermark text", + "description": "Extracts and decrypts the hidden text from a watermarked PNG. The caller must be the watermark owner or an admin token. Reserves EXTRACT (2 tokens).", + "operationId": "extract", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { "schema": { "$ref": "#/components/schemas/watermark_ImageBody" } } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_ExtractResponse" } } } + }, + "400": { + "description": "No watermark found in this image", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "401": { "$ref": "#/components/responses/WatermarkUnauthorized" }, + "403": { + "description": "Requester is not allowed to read this watermark (not owner, not admin)", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "413": { + "description": "Image larger than 25MB", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "422": { + "description": "Request validation error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HTTPValidationError" } } } + }, + "503": { + "description": "auth-server or subscription-service unavailable", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + } + } + } + }, + "/api/watermark/visualize": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "post": { + "tags": ["watermark: Watermark"], + "summary": "Visualize watermark", + "description": "Generates a PNG heatmap of the watermark distribution. Reserves VISUALIZE (3 tokens).", + "operationId": "visualize", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { "schema": { "$ref": "#/components/schemas/watermark_ImageBody" } } + } + }, + "responses": { + "200": { + "description": "Heatmap PNG image bytes", + "content": { "image/png": { "schema": { "type": "string", "format": "binary" } } } + }, + "401": { "$ref": "#/components/responses/WatermarkUnauthorized" }, + "422": { + "description": "Request validation error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HTTPValidationError" } } } + }, + "503": { + "description": "auth-server or subscription-service unavailable", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + } + } + } + }, + "/api/watermark/capacity": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "post": { + "tags": ["watermark: Watermark"], + "summary": "Check watermark capacity", + "description": "Calculates the maximum embeddable text size for an image and owner. Requires authentication but reserves no tokens (CAPACITY_CHECK is free).", + "operationId": "capacity", + "security": [{ "HTTPBearer": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { "schema": { "$ref": "#/components/schemas/watermark_ImageBody" } } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_CapacityResponse" } } } + }, + "401": { "$ref": "#/components/responses/WatermarkUnauthorized" }, + "413": { + "description": "Image larger than 25MB", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "422": { + "description": "Request validation error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HTTPValidationError" } } } + } + } + } + }, + "/health": { + "servers": [{ "url": "http://localhost:8082", "description": "watermark-service" }], + "get": { + "tags": ["watermark: Health"], + "summary": "Health", + "description": "Liveness probe for watermark-service. Public. This is the service's only health endpoint (no /actuator).", + "operationId": "health", + "responses": { + "200": { + "description": "OK", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_HealthResponse" } } } + } + } + } + } + }, + "components": { + "responses": { + "Unauthorized": { + "description": "Missing or invalid bearer token", + "content": { "application/json": { "schema": { "type": "object" } } } + }, + "WatermarkUnauthorized": { + "description": "Missing/invalid bearer token (401) — body {\"detail\":{\"error\":\"Invalid or expired token\"}}", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/watermark_Error" } } } + }, + "SpringDefaultError": { + "description": "Spring Boot default error response. subscription-service has no @RestControllerAdvice, so domain violations (unknown session, wrong owner, already finalized, illegal upgrade) surface here.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SpringErrorBody" } } } + } + }, + "schemas": { + "auth_RegisterRequest": { + "type": "object", + "properties": { + "username": { "type": "string", "minLength": 3, "maxLength": 50 }, + "email": { "type": "string", "format": "email" }, + "password": { "type": "string" } + }, + "required": ["username", "email", "password"] + }, + "auth_RegisterResponse": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "username": { "type": "string" }, + "email": { "type": "string" }, + "role": { "type": "string", "enum": ["ADMIN", "USER"] } + } + }, + "auth_LoginRequest": { + "type": "object", + "properties": { + "email": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["email", "password"] + }, + "auth_LoginResponse": { + "type": "object", + "properties": { "token": { "type": "string", "description": "Signed JWT (use as Authorization: Bearer)" } } + }, + "auth_FieldErrors": { + "type": "object", + "description": "Map of field name to validation/conflict message.", + "additionalProperties": { "type": "string" } + }, + "subscription_CreatePaymentSessionRequest": { + "type": "object", + "properties": { "targetPlan": { "$ref": "#/components/schemas/subscription_PlanCode" } }, + "required": ["targetPlan"] + }, + "subscription_PaymentSessionResponse": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "userId": { "type": "string" }, + "targetPlan": { "$ref": "#/components/schemas/subscription_PlanCode" }, + "status": { "type": "string", "enum": ["PENDING", "SUCCEEDED", "FAILED", "CANCELLED"] } + } + }, + "subscription_ServiceStatus": { + "type": "object", + "properties": { + "service": { "type": "string" }, + "status": { "type": "string" } + } + }, + "subscription_PlanCode": { + "type": "string", + "enum": ["FREE", "STANDARD", "PRO"] + }, + "subscription_TokenOperation": { + "type": "string", + "enum": ["CAPACITY_CHECK", "DETECT", "EXTRACT", "VISUALIZE", "EMBED_768", "EMBED_1024", "AI_CLASSIFICATION"] + }, + "subscription_PlanView": { + "type": "object", + "properties": { + "code": { "$ref": "#/components/schemas/subscription_PlanCode" }, + "monthlyTokens": { "type": "integer", "format": "int32" }, + "allowedOperations": { + "type": "array", + "items": { "$ref": "#/components/schemas/subscription_TokenOperation" }, + "uniqueItems": true + } + } + }, + "subscription_CurrentSubscriptionView": { + "type": "object", + "properties": { + "userId": { "type": "string" }, + "planCode": { "$ref": "#/components/schemas/subscription_PlanCode" }, + "activeFrom": { "type": "string", "format": "date-time" }, + "activeUntil": { "type": "string", "format": "date-time" } + } + }, + "subscription_TokenBalanceView": { + "type": "object", + "properties": { + "userId": { "type": "string" }, + "availableTokens": { "type": "integer", "format": "int32" }, + "reservedTokens": { "type": "integer", "format": "int32" } + } + }, + "subscription_CreateTokenReservationRequest": { + "type": "object", + "properties": { + "operation": { "$ref": "#/components/schemas/subscription_TokenOperation" }, + "externalOperationId": { "type": "string", "nullable": true } + }, + "required": ["operation"] + }, + "subscription_TokenReservationResponse": { + "type": "object", + "properties": { + "reservationId": { "type": "string", "format": "uuid" }, + "userId": { "type": "string" }, + "operation": { "$ref": "#/components/schemas/subscription_TokenOperation" }, + "tokens": { "type": "integer", "format": "int32" }, + "status": { "type": "string", "enum": ["RESERVED", "CONSUMED", "RELEASED"] }, + "expiresAt": { "type": "string", "format": "date-time" } + } + }, + "subscription_TokenReservationErrorResponse": { + "type": "object", + "properties": { + "code": { "type": "string", "enum": ["INSUFFICIENT_TOKENS", "OPERATION_NOT_ALLOWED", "PLAN_NOT_FOUND", "SUBSCRIPTION_EXPIRED"] }, + "message": { "type": "string" } + } + }, + "ai_ClassificationResult": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "category": { "type": "string" }, + "confidence": { "type": "number", "format": "double" }, + "categoryConfidence": { "type": "number", "format": "double" }, + "top3": { "type": "array", "items": { "$ref": "#/components/schemas/ai_TopPrediction" } } + } + }, + "ai_TopPrediction": { + "type": "object", + "properties": { + "label": { "type": "string" }, + "confidence": { "type": "number", "format": "double" } + } + }, + "watermark_EmbedBody": { + "type": "object", + "properties": { + "image": { "type": "string", "format": "binary", "title": "Image", "description": "PNG image" }, + "text": { "type": "string", "title": "Text", "description": "Watermark payload (max 4096 bytes UTF-8)" } + }, + "required": ["image", "text"], + "title": "Body_embed_api_watermark_embed_post" + }, + "watermark_ImageBody": { + "type": "object", + "properties": { + "image": { "type": "string", "format": "binary", "title": "Image" } + }, + "required": ["image"] + }, + "watermark_DetectResponse": { + "type": "object", + "properties": { + "watermarked": { "type": "boolean" }, + "ownerIdentity": { "type": "string", "nullable": true }, + "version": { "type": "integer", "nullable": true, "description": "1 when watermarked, otherwise null" }, + "lengthBits": { "type": "integer", "nullable": true } + }, + "required": ["watermarked"] + }, + "watermark_ExtractResponse": { + "type": "object", + "properties": { + "ownerIdentity": { "type": "string" }, + "text": { "type": "string" } + }, + "required": ["ownerIdentity", "text"] + }, + "watermark_CapacityResponse": { + "type": "object", + "properties": { + "maxTextBytes": { "type": "integer" }, + "minImageWidth": { "type": "integer" }, + "minImageHeight": { "type": "integer" }, + "imageWidth": { "type": "integer" }, + "imageHeight": { "type": "integer" }, + "imageOk": { "type": "boolean" }, + "lengthBits": { "type": "integer" } + } + }, + "watermark_HealthResponse": { + "type": "object", + "properties": { "status": { "type": "string", "example": "UP" } } + }, + "watermark_Error": { + "type": "object", + "description": "FastAPI error body. `detail` is a human-readable string, or an object such as {\"error\": \"...\"} for auth failures.", + "properties": { + "detail": { + "anyOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + }, + "watermark_HTTPValidationError": { + "type": "object", + "properties": { + "detail": { + "type": "array", + "items": { "$ref": "#/components/schemas/watermark_ValidationError" }, + "title": "Detail" + } + }, + "title": "HTTPValidationError" + }, + "watermark_ValidationError": { + "type": "object", + "properties": { + "loc": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "integer" }] }, + "title": "Location" + }, + "msg": { "type": "string", "title": "Message" }, + "type": { "type": "string", "title": "Error Type" } + }, + "required": ["loc", "msg", "type"], + "title": "ValidationError" + }, + "SpringErrorBody": { + "type": "object", + "description": "Spring Boot default error response.", + "properties": { + "timestamp": { "type": "string", "format": "date-time" }, + "status": { "type": "integer" }, + "error": { "type": "string" }, + "path": { "type": "string" } + } + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} diff --git a/subscription-service/Dockerfile b/subscription-service/Dockerfile new file mode 100644 index 0000000..10b4d3c --- /dev/null +++ b/subscription-service/Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /workspace + +COPY . . +RUN chmod +x gradlew && ./gradlew :subscription-service:bootJar --no-daemon + +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=builder /workspace/subscription-service/build/libs/*.jar app.jar +EXPOSE 8085 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/subscription-service/build.gradle.kts b/subscription-service/build.gradle.kts new file mode 100644 index 0000000..507ed40 --- /dev/null +++ b/subscription-service/build.gradle.kts @@ -0,0 +1,99 @@ +plugins { + java + jacoco + id("org.springframework.boot") version "3.5.11" + id("io.spring.dependency-management") version "1.1.7" + id("org.sonarqube") version "7.2.3.7755" + // Spring Cloud Contract Verifier — version aligned with the spring-cloud BOM (2025.0.1 manages 4.3.1). + id("org.springframework.cloud.contract") version "4.3.1" + `maven-publish` +} + +group = "pl.zzpj" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +extra["springCloudVersion"] = "2025.0.1" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("springdocVersion")}") + implementation("org.springframework.cloud:spring-cloud-starter-config") + implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("com.h2database:h2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // Architecture tests + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") + + // Spring Cloud Contract — provider (verifier) + consumer (stub runner) sides. + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier") + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +tasks.withType { + useJUnitPlatform() +} + +// Spring Cloud Contract Verifier configuration. +// Generates provider-side tests (run via the `contractTest` task / source set) from the +// Groovy contracts in src/contractTest/resources/contracts. The base class wires RestAssuredMockMvc +// standalone against the real controller so no full app context / DB is required. +contracts { + testFramework.set(org.springframework.cloud.contract.verifier.config.TestFramework.JUNIT5) + baseClassForTests.set("pl.zzpj.subscription_service.contract.SubscriptionStatusContractBase") +} + +// Publish the generated WireMock stubs jar to the local Maven repository so the consumer-side +// StubRunner test (StubsMode.LOCAL, which resolves from ~/.m2) can boot WireMock from it. +publishing { + publications { + create("stubs") { + artifactId = "subscription-service" + artifact(tasks.named("verifierStubsJar")) + } + } +} + +// The consumer StubRunner test runs in the `test` task and needs the stubs available in ~/.m2 +// before it executes. +tasks.named("test") { + dependsOn("publishStubsPublicationToMavenLocal") +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + } +} + +tasks.named("sonar") { + dependsOn(tasks.jacocoTestReport) +} diff --git a/subscription-service/src/contractTest/resources/contracts/subscription_status_should_return_up.groovy b/subscription-service/src/contractTest/resources/contracts/subscription_status_should_return_up.groovy new file mode 100644 index 0000000..bb284db --- /dev/null +++ b/subscription-service/src/contractTest/resources/contracts/subscription_status_should_return_up.groovy @@ -0,0 +1,27 @@ +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + description(""" + Given the subscription service is running + When a client requests GET /api/subscriptions/status + Then it responds 200 with the service identity and UP status + """) + request { + method GET() + url "/api/subscriptions/status" + } + response { + status OK() + headers { + contentType(applicationJson()) + } + body( + service: "subscription-service", + status: "UP" + ) + bodyMatchers { + jsonPath('$.service', byEquality()) + jsonPath('$.status', byEquality()) + } + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/SubscriptionServiceApplication.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/SubscriptionServiceApplication.java new file mode 100644 index 0000000..86b74d0 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/SubscriptionServiceApplication.java @@ -0,0 +1,16 @@ +package pl.zzpj.subscription_service; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableFeignClients +public class SubscriptionServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(SubscriptionServiceApplication.class, args); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/PaymentApplicationService.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/PaymentApplicationService.java new file mode 100644 index 0000000..f449b54 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/PaymentApplicationService.java @@ -0,0 +1,120 @@ +package pl.zzpj.subscription_service.application; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.zzpj.subscription_service.domain.payment.PaymentCompletion; +import pl.zzpj.subscription_service.domain.payment.PaymentOutcome; +import pl.zzpj.subscription_service.domain.payment.PaymentProvider; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.persistence.SubscriptionStore; + +@Service +public class PaymentApplicationService { + + private final PaymentProvider paymentProvider; + private final SubscriptionStore subscriptionStore; + private final SubscriptionCatalog subscriptionCatalog; + private final SubscriptionQueryService subscriptionQueryService; + private final Clock clock; + + public PaymentApplicationService( + PaymentProvider paymentProvider, + SubscriptionStore subscriptionStore, + SubscriptionCatalog subscriptionCatalog, + SubscriptionQueryService subscriptionQueryService, + Clock clock) { + this.paymentProvider = paymentProvider; + this.subscriptionStore = subscriptionStore; + this.subscriptionCatalog = subscriptionCatalog; + this.subscriptionQueryService = subscriptionQueryService; + this.clock = clock; + } + + @Transactional + public PaymentSession initiatePayment(String userId, PlanCode targetPlan) { + UserSubscriptionState currentState = subscriptionQueryService.stateFor(userId); + ensureSubscriptionReadyForUpgrade(currentState); + ensureUpgradeAllowed(currentState.subscription().planCode(), targetPlan); + return paymentProvider.createSession(userId, targetPlan); + } + + @Transactional + public PaymentSession completePayment(String userId, UUID sessionId, PaymentOutcome outcome) { + PaymentCompletion completion = paymentProvider.completeSession(sessionId, userId, outcome); + PaymentSession session = completion.session(); + + if (!completion.completedNow()) { + return session; + } + + switch (outcome) { + case PaymentOutcome.Succeeded succeeded -> applyPaymentSuccess(session); + case PaymentOutcome.Failed failed -> { + /* No-op */ + } + case PaymentOutcome.Cancelled cancelled -> { + /* No-op */ + } + } + + return session; + } + + private void applyPaymentSuccess(PaymentSession session) { + SubscriptionPlan plan = + subscriptionCatalog + .findPlan(session.targetPlan()) + .orElseThrow( + () -> new IllegalStateException("Plan not found: " + session.targetPlan())); + + subscriptionQueryService.stateFor(session.userId()); + UserSubscriptionState currentState = + subscriptionStore + .findForUpdate(session.userId()) + .orElseThrow( + () -> + new IllegalStateException( + "Subscription not found for user " + session.userId())); + ensureSubscriptionReadyForUpgrade(currentState); + ensureUpgradeAllowed(currentState.subscription().planCode(), session.targetPlan()); + + Instant now = clock.instant(); + + ActiveSubscription updatedSubscription = + new ActiveSubscription( + session.userId(), + session.targetPlan(), + now, + now.atZone(ZoneOffset.UTC).plusMonths(1).toInstant()); + + TokenBalance updatedTokenBalance = + new TokenBalance( + session.userId(), + Math.addExact(currentState.tokenBalance().availableTokens(), plan.monthlyTokens()), + currentState.tokenBalance().reservedTokens()); + + subscriptionStore.save(new UserSubscriptionState(updatedSubscription, updatedTokenBalance)); + } + + private void ensureUpgradeAllowed(PlanCode currentPlan, PlanCode targetPlan) { + if (!currentPlan.canUpgradeTo(targetPlan)) { + throw new IllegalArgumentException( + "Plan change from " + currentPlan + " to " + targetPlan + " is not an upgrade"); + } + } + + private void ensureSubscriptionReadyForUpgrade(UserSubscriptionState currentState) { + if (currentState.subscription().isExpiredAt(clock.instant())) { + throw new IllegalStateException("Expired subscription still has pending token reservations"); + } + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/SubscriptionQueryService.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/SubscriptionQueryService.java new file mode 100644 index 0000000..2668239 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/SubscriptionQueryService.java @@ -0,0 +1,68 @@ +package pl.zzpj.subscription_service.application; + +import java.time.Clock; +import java.util.Comparator; +import java.util.List; +import org.springframework.stereotype.Service; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.persistence.SubscriptionStore; + +@Service +public class SubscriptionQueryService { + + private static final PlanCode DEFAULT_PLAN = PlanCode.FREE; + + private final SubscriptionCatalog subscriptionCatalog; + private final SubscriptionStore subscriptionStore; + private final Clock clock; + + public SubscriptionQueryService( + SubscriptionCatalog subscriptionCatalog, SubscriptionStore subscriptionStore, Clock clock) { + this.subscriptionCatalog = subscriptionCatalog; + this.subscriptionStore = subscriptionStore; + this.clock = clock; + } + + public List availablePlans() { + return subscriptionCatalog.plans().values().stream() + .sorted(Comparator.comparing(plan -> plan.code().ordinal())) + .toList(); + } + + public UserSubscriptionState stateFor(String userId) { + SubscriptionPlan defaultPlan = + subscriptionCatalog + .findPlan(DEFAULT_PLAN) + .orElseThrow( + () -> + new IllegalStateException( + "Default plan " + DEFAULT_PLAN + " is not configured")); + + UserSubscriptionState initialState = + new UserSubscriptionState( + new ActiveSubscription(userId, defaultPlan.code(), clock.instant(), null), + new TokenBalance(userId, defaultPlan.monthlyTokens(), 0)); + + UserSubscriptionState state = subscriptionStore.getOrCreate(userId, initialState); + return expirePaidPlanIfEligible(state, defaultPlan); + } + + private UserSubscriptionState expirePaidPlanIfEligible( + UserSubscriptionState state, SubscriptionPlan defaultPlan) { + if (!state.subscription().isExpiredAt(clock.instant()) + || state.tokenBalance().reservedTokens() > 0) { + return state; + } + + UserSubscriptionState freeState = + new UserSubscriptionState( + new ActiveSubscription( + state.subscription().userId(), defaultPlan.code(), clock.instant(), null), + new TokenBalance(state.tokenBalance().userId(), defaultPlan.monthlyTokens(), 0)); + return subscriptionStore.save(freeState); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/TokenReservationCommandService.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/TokenReservationCommandService.java new file mode 100644 index 0000000..8ef9919 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/TokenReservationCommandService.java @@ -0,0 +1,117 @@ +package pl.zzpj.subscription_service.application; + +import java.time.Clock; +import java.time.Instant; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.zzpj.subscription_service.application.command.CreateTokenReservationCommand; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.domain.token.TokenReservationPolicy; +import pl.zzpj.subscription_service.domain.token.decision.Accepted; +import pl.zzpj.subscription_service.domain.token.decision.TokenDecision; +import pl.zzpj.subscription_service.domain.token.reservation.TokenReservationStatus; +import pl.zzpj.subscription_service.persistence.entity.TokenBalanceEntity; +import pl.zzpj.subscription_service.persistence.entity.TokenReservationEntity; +import pl.zzpj.subscription_service.persistence.repository.TokenBalanceRepository; +import pl.zzpj.subscription_service.persistence.repository.TokenReservationRepository; + +@Service +public class TokenReservationCommandService { + + private final SubscriptionQueryService subscriptionQueryService; + private final TokenBalanceRepository tokenBalanceRepository; + private final TokenReservationRepository tokenReservationRepository; + private final TokenReservationPolicy reservationPolicy; + private final Clock clock; + + private static final String TOKEN_BALANCE_NOT_FOUND_ERROR = + "Token balance not found for authenticated user "; + + public TokenReservationCommandService( + SubscriptionQueryService subscriptionQueryService, + TokenBalanceRepository tokenBalanceRepository, + TokenReservationRepository tokenReservationRepository, + TokenReservationPolicy reservationPolicy, + Clock clock) { + this.subscriptionQueryService = subscriptionQueryService; + this.tokenBalanceRepository = tokenBalanceRepository; + this.tokenReservationRepository = tokenReservationRepository; + this.reservationPolicy = reservationPolicy; + this.clock = clock; + } + + @Transactional + public TokenDecision reserve(String userId, CreateTokenReservationCommand command) { + UserSubscriptionState state = subscriptionQueryService.stateFor(userId); + ActiveSubscription subscription = state.subscription(); + TokenBalance tokenBalance = state.tokenBalance(); + + Instant now = clock.instant(); + TokenDecision decision = + reservationPolicy.decide(subscription, tokenBalance, command.operation(), now); + if (decision instanceof Accepted accepted) { + TokenBalance reservedBalance = tokenBalance.reserve(accepted.reservation().tokens()); + tokenBalanceRepository.save(TokenBalanceEntity.from(reservedBalance)); + tokenReservationRepository.save( + TokenReservationEntity.from(accepted.reservation(), now, command.externalOperationId())); + } + return decision; + } + + @Transactional + public TokenReservationEntity consume(String userId, UUID reservationId) { + TokenReservationEntity reservation = findOwnedReservation(userId, reservationId); + ensureReserved(reservation); + + TokenBalance tokenBalance = + tokenBalanceRepository + .findById(userId) + .map(TokenBalanceEntity::toDomain) + .orElseThrow(() -> new IllegalStateException(TOKEN_BALANCE_NOT_FOUND_ERROR + userId)); + + TokenBalance updatedBalance = tokenBalance.consumeReserved(reservation.getTokens()); + tokenBalanceRepository.save(TokenBalanceEntity.from(updatedBalance)); + reservation.markConsumed(clock.instant()); + TokenReservationEntity savedReservation = tokenReservationRepository.save(reservation); + subscriptionQueryService.stateFor(userId); + return savedReservation; + } + + @Transactional + public TokenReservationEntity release(String userId, UUID reservationId) { + TokenReservationEntity reservation = findOwnedReservation(userId, reservationId); + ensureReserved(reservation); + + TokenBalance tokenBalance = + tokenBalanceRepository + .findById(userId) + .map(TokenBalanceEntity::toDomain) + .orElseThrow(() -> new IllegalStateException(TOKEN_BALANCE_NOT_FOUND_ERROR + userId)); + + TokenBalance updatedBalance = tokenBalance.releaseReserved(reservation.getTokens()); + tokenBalanceRepository.save(TokenBalanceEntity.from(updatedBalance)); + reservation.markReleased(clock.instant()); + TokenReservationEntity savedReservation = tokenReservationRepository.save(reservation); + subscriptionQueryService.stateFor(userId); + return savedReservation; + } + + private TokenReservationEntity findOwnedReservation(String userId, UUID reservationId) { + TokenReservationEntity reservation = + tokenReservationRepository + .findById(reservationId) + .orElseThrow(() -> new IllegalArgumentException("Token reservation not found")); + if (!reservation.getUserId().equals(userId)) { + throw new IllegalArgumentException("Token reservation belongs to another user"); + } + return reservation; + } + + private void ensureReserved(TokenReservationEntity reservation) { + if (reservation.getStatus() != TokenReservationStatus.RESERVED) { + throw new IllegalArgumentException("Token reservation is already " + reservation.getStatus()); + } + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserIdentityResolver.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserIdentityResolver.java new file mode 100644 index 0000000..8aab508 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserIdentityResolver.java @@ -0,0 +1,15 @@ +package pl.zzpj.subscription_service.application; + +import java.security.Principal; +import org.springframework.stereotype.Component; + +@Component +public class UserIdentityResolver { + + public String resolve(Principal principal) { + if (principal != null && principal.getName() != null && !principal.getName().isBlank()) { + return principal.getName(); + } + throw new IllegalArgumentException("Authenticated principal is required"); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserSubscriptionState.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserSubscriptionState.java new file mode 100644 index 0000000..45195d2 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/UserSubscriptionState.java @@ -0,0 +1,6 @@ +package pl.zzpj.subscription_service.application; + +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.token.TokenBalance; + +public record UserSubscriptionState(ActiveSubscription subscription, TokenBalance tokenBalance) {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/application/command/CreateTokenReservationCommand.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/command/CreateTokenReservationCommand.java new file mode 100644 index 0000000..1ebaf73 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/application/command/CreateTokenReservationCommand.java @@ -0,0 +1,12 @@ +package pl.zzpj.subscription_service.application.command; + +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record CreateTokenReservationCommand(TokenOperation operation, String externalOperationId) { + + public CreateTokenReservationCommand { + if (operation == null) { + throw new IllegalArgumentException("operation must not be null"); + } + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/client/AuthClient.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/client/AuthClient.java new file mode 100644 index 0000000..97505db --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/client/AuthClient.java @@ -0,0 +1,12 @@ +package pl.zzpj.subscription_service.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "auth-server") +public interface AuthClient { + + @PostMapping("/auth/validate") + Boolean validateToken(@RequestParam("token") String token); +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/config/ClockConfig.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/ClockConfig.java new file mode 100644 index 0000000..20db07f --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/ClockConfig.java @@ -0,0 +1,14 @@ +package pl.zzpj.subscription_service.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionDomainConfig.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionDomainConfig.java new file mode 100644 index 0000000..31091bc --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionDomainConfig.java @@ -0,0 +1,42 @@ +package pl.zzpj.subscription_service.config; + +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import pl.zzpj.subscription_service.domain.pricing.PricingCatalog; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.TokenReservationPolicy; + +@Configuration +public class SubscriptionDomainConfig { + + @Bean + public PricingCatalog pricingCatalog(SubscriptionProperties properties) { + return new PricingCatalog(properties.tokenCosts()); + } + + @Bean + public SubscriptionCatalog subscriptionCatalog(SubscriptionProperties properties) { + Map plans = + properties.plans().entrySet().stream() + .collect( + Collectors.toUnmodifiableMap( + entry -> PlanCode.valueOf(entry.getKey().toUpperCase(Locale.ROOT)), + entry -> + new SubscriptionPlan( + PlanCode.valueOf(entry.getKey().toUpperCase(Locale.ROOT)), + entry.getValue().monthlyTokens(), + entry.getValue().allowedOperations()))); + return new SubscriptionCatalog(plans); + } + + @Bean + public TokenReservationPolicy tokenReservationPolicy( + SubscriptionCatalog subscriptionCatalog, PricingCatalog pricingCatalog) { + return new TokenReservationPolicy(subscriptionCatalog, pricingCatalog); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionProperties.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionProperties.java new file mode 100644 index 0000000..4eba20f --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/SubscriptionProperties.java @@ -0,0 +1,16 @@ +package pl.zzpj.subscription_service.config; + +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import pl.zzpj.subscription_service.config.properties.PlanDefinition; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +@ConfigurationProperties(prefix = "subscription") +public record SubscriptionProperties( + Map plans, Map tokenCosts) { + + public SubscriptionProperties { + plans = plans == null ? Map.of() : Map.copyOf(plans); + tokenCosts = tokenCosts == null ? Map.of() : Map.copyOf(tokenCosts); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/config/properties/PlanDefinition.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/properties/PlanDefinition.java new file mode 100644 index 0000000..08d13bb --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/config/properties/PlanDefinition.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service.config.properties; + +import java.util.Set; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record PlanDefinition(int monthlyTokens, Set allowedOperations) { + + public PlanDefinition { + allowedOperations = allowedOperations == null ? Set.of() : Set.copyOf(allowedOperations); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/MockPaymentController.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/MockPaymentController.java new file mode 100644 index 0000000..2d9e473 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/MockPaymentController.java @@ -0,0 +1,59 @@ +package pl.zzpj.subscription_service.controller; + +import java.security.Principal; +import java.util.UUID; +import org.springframework.web.bind.annotation.*; +import pl.zzpj.subscription_service.application.PaymentApplicationService; +import pl.zzpj.subscription_service.application.UserIdentityResolver; +import pl.zzpj.subscription_service.controller.dto.payment.CreatePaymentSessionRequest; +import pl.zzpj.subscription_service.controller.dto.payment.PaymentSessionResponse; +import pl.zzpj.subscription_service.domain.payment.PaymentOutcome; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; + +@RestController +@RequestMapping("/api/payments/mock/sessions") +public class MockPaymentController { + + private final PaymentApplicationService paymentService; + private final UserIdentityResolver userIdentityResolver; + + public MockPaymentController( + PaymentApplicationService paymentService, UserIdentityResolver userIdentityResolver) { + this.paymentService = paymentService; + this.userIdentityResolver = userIdentityResolver; + } + + @PostMapping + public PaymentSessionResponse createSession( + @RequestBody CreatePaymentSessionRequest request, Principal principal) { + String userId = userIdentityResolver.resolve(principal); + PaymentSession session = paymentService.initiatePayment(userId, request.targetPlan()); + return PaymentSessionResponse.from(session); + } + + @PostMapping("/{sessionId}/succeed") + public PaymentSessionResponse succeed(@PathVariable UUID sessionId, Principal principal) { + String userId = userIdentityResolver.resolve(principal); + PaymentSession session = + paymentService.completePayment( + userId, sessionId, new PaymentOutcome.Succeeded(UUID.randomUUID().toString())); + return PaymentSessionResponse.from(session); + } + + @PostMapping("/{sessionId}/fail") + public PaymentSessionResponse fail(@PathVariable UUID sessionId, Principal principal) { + String userId = userIdentityResolver.resolve(principal); + PaymentSession session = + paymentService.completePayment( + userId, sessionId, new PaymentOutcome.Failed("Mocked failure")); + return PaymentSessionResponse.from(session); + } + + @PostMapping("/{sessionId}/cancel") + public PaymentSessionResponse cancel(@PathVariable UUID sessionId, Principal principal) { + String userId = userIdentityResolver.resolve(principal); + PaymentSession session = + paymentService.completePayment(userId, sessionId, new PaymentOutcome.Cancelled()); + return PaymentSessionResponse.from(session); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionQueryController.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionQueryController.java new file mode 100644 index 0000000..529922a --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionQueryController.java @@ -0,0 +1,64 @@ +package pl.zzpj.subscription_service.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.security.Principal; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.zzpj.subscription_service.application.SubscriptionQueryService; +import pl.zzpj.subscription_service.application.UserIdentityResolver; +import pl.zzpj.subscription_service.application.UserSubscriptionState; +import pl.zzpj.subscription_service.controller.dto.CurrentSubscriptionView; +import pl.zzpj.subscription_service.controller.dto.PlanView; +import pl.zzpj.subscription_service.controller.dto.TokenBalanceView; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; + +@RestController +@RequestMapping("/api/subscriptions") +@Tag(name = "Subscription Query", description = "API for querying user subscriptions and plans") +public class SubscriptionQueryController { + + private final SubscriptionQueryService subscriptionQueryService; + private final UserIdentityResolver userIdentityResolver; + + public SubscriptionQueryController( + SubscriptionQueryService subscriptionQueryService, + UserIdentityResolver userIdentityResolver) { + this.subscriptionQueryService = subscriptionQueryService; + this.userIdentityResolver = userIdentityResolver; + } + + @GetMapping("/plans") + @Operation( + summary = "List available plans", + description = "Returns a list of all available subscription plans.") + public List plans() { + return subscriptionQueryService.availablePlans().stream().map(this::toPlanView).toList(); + } + + @GetMapping("/me") + @Operation( + summary = "Current subscription", + description = "Returns details of the current user's active subscription.") + public CurrentSubscriptionView currentSubscription(Principal principal) { + String userId = userIdentityResolver.resolve(principal); + UserSubscriptionState state = subscriptionQueryService.stateFor(userId); + return CurrentSubscriptionView.from(state.subscription()); + } + + @GetMapping("/me/tokens") + @Operation( + summary = "Token balance", + description = "Returns the remaining token balance for the current user.") + public TokenBalanceView tokenBalance(Principal principal) { + String userId = userIdentityResolver.resolve(principal); + UserSubscriptionState state = subscriptionQueryService.stateFor(userId); + return TokenBalanceView.from(state.tokenBalance()); + } + + private PlanView toPlanView(SubscriptionPlan plan) { + return new PlanView(plan.code(), plan.monthlyTokens(), plan.allowedOperations()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionStatusController.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionStatusController.java new file mode 100644 index 0000000..27fd151 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/SubscriptionStatusController.java @@ -0,0 +1,22 @@ +package pl.zzpj.subscription_service.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.zzpj.subscription_service.controller.dto.ServiceStatus; + +@RestController +@RequestMapping("/api/subscriptions") +@Tag(name = "Subscription Status", description = "Service health and status API") +public class SubscriptionStatusController { + + @GetMapping("/status") + @Operation( + summary = "Service status", + description = "Returns the current status of the subscription service.") + public ServiceStatus status() { + return new ServiceStatus("subscription-service", "UP"); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/TokenReservationController.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/TokenReservationController.java new file mode 100644 index 0000000..810aedc --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/TokenReservationController.java @@ -0,0 +1,97 @@ +package pl.zzpj.subscription_service.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.security.Principal; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import pl.zzpj.subscription_service.application.TokenReservationCommandService; +import pl.zzpj.subscription_service.application.UserIdentityResolver; +import pl.zzpj.subscription_service.application.command.CreateTokenReservationCommand; +import pl.zzpj.subscription_service.controller.dto.CreateTokenReservationRequest; +import pl.zzpj.subscription_service.controller.dto.TokenReservationErrorResponse; +import pl.zzpj.subscription_service.controller.dto.TokenReservationResponse; +import pl.zzpj.subscription_service.domain.token.TokenReservation; +import pl.zzpj.subscription_service.domain.token.decision.Accepted; +import pl.zzpj.subscription_service.domain.token.decision.RejectedInsufficientTokens; +import pl.zzpj.subscription_service.domain.token.decision.RejectedOperationNotAllowed; +import pl.zzpj.subscription_service.domain.token.decision.RejectedPlanNotFound; +import pl.zzpj.subscription_service.domain.token.decision.RejectedSubscriptionExpired; +import pl.zzpj.subscription_service.domain.token.decision.TokenDecision; +import pl.zzpj.subscription_service.persistence.entity.TokenReservationEntity; + +@RestController +@RequestMapping("/api/tokens/reservations") +@Tag(name = "Token Reservation", description = "API for managing token reservations for operations") +public class TokenReservationController { + + private final TokenReservationCommandService reservationCommandService; + private final UserIdentityResolver userIdentityResolver; + + public TokenReservationController( + TokenReservationCommandService reservationCommandService, + UserIdentityResolver userIdentityResolver) { + this.reservationCommandService = reservationCommandService; + this.userIdentityResolver = userIdentityResolver; + } + + @PostMapping + @Operation( + summary = "Reserve tokens", + description = "Creates a new token reservation for a specific operation.") + public ResponseEntity reserve( + Principal principal, @RequestBody CreateTokenReservationRequest request) { + String userId = userIdentityResolver.resolve(principal); + TokenDecision decision = + reservationCommandService.reserve( + userId, + new CreateTokenReservationCommand(request.operation(), request.externalOperationId())); + return toResponse(decision); + } + + @PostMapping("/{reservationId}/consume") + @Operation( + summary = "Consume reservation", + description = "Finalizes a token reservation, deducting tokens from the balance.") + public TokenReservationResponse consume(Principal principal, @PathVariable UUID reservationId) { + String userId = userIdentityResolver.resolve(principal); + TokenReservationEntity reservation = reservationCommandService.consume(userId, reservationId); + return TokenReservationResponse.from(reservation); + } + + @PostMapping("/{reservationId}/release") + @Operation( + summary = "Release reservation", + description = "Cancels a token reservation, returning tokens to the balance.") + public TokenReservationResponse release(Principal principal, @PathVariable UUID reservationId) { + String userId = userIdentityResolver.resolve(principal); + TokenReservationEntity reservation = reservationCommandService.release(userId, reservationId); + return TokenReservationResponse.from(reservation); + } + + private ResponseEntity toResponse(TokenDecision decision) { + return switch (decision) { + case Accepted(TokenReservation reservation) -> + ResponseEntity.status(HttpStatus.CREATED) + .body(TokenReservationResponse.from(reservation)); + case RejectedInsufficientTokens rejected -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(TokenReservationErrorResponse.from("INSUFFICIENT_TOKENS", rejected)); + case RejectedOperationNotAllowed rejected -> + ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(TokenReservationErrorResponse.from("OPERATION_NOT_ALLOWED", rejected)); + case RejectedPlanNotFound rejected -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(TokenReservationErrorResponse.from("PLAN_NOT_FOUND", rejected)); + case RejectedSubscriptionExpired rejected -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(TokenReservationErrorResponse.from("SUBSCRIPTION_EXPIRED", rejected)); + }; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CreateTokenReservationRequest.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CreateTokenReservationRequest.java new file mode 100644 index 0000000..b45ecfd --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CreateTokenReservationRequest.java @@ -0,0 +1,5 @@ +package pl.zzpj.subscription_service.controller.dto; + +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record CreateTokenReservationRequest(TokenOperation operation, String externalOperationId) {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CurrentSubscriptionView.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CurrentSubscriptionView.java new file mode 100644 index 0000000..ba0eef4 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/CurrentSubscriptionView.java @@ -0,0 +1,17 @@ +package pl.zzpj.subscription_service.controller.dto; + +import java.time.Instant; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public record CurrentSubscriptionView( + String userId, PlanCode planCode, Instant activeFrom, Instant activeUntil) { + + public static CurrentSubscriptionView from(ActiveSubscription subscription) { + return new CurrentSubscriptionView( + subscription.userId(), + subscription.planCode(), + subscription.activeFrom(), + subscription.activeUntil()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/PlanView.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/PlanView.java new file mode 100644 index 0000000..a8fa32f --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/PlanView.java @@ -0,0 +1,12 @@ +package pl.zzpj.subscription_service.controller.dto; + +import java.util.Set; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record PlanView(PlanCode code, int monthlyTokens, Set allowedOperations) { + + public PlanView { + allowedOperations = allowedOperations == null ? Set.of() : Set.copyOf(allowedOperations); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/ServiceStatus.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/ServiceStatus.java new file mode 100644 index 0000000..5b40183 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/ServiceStatus.java @@ -0,0 +1,3 @@ +package pl.zzpj.subscription_service.controller.dto; + +public record ServiceStatus(String service, String status) {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenBalanceView.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenBalanceView.java new file mode 100644 index 0000000..1a54057 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenBalanceView.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service.controller.dto; + +import pl.zzpj.subscription_service.domain.token.TokenBalance; + +public record TokenBalanceView(String userId, int availableTokens, int reservedTokens) { + + public static TokenBalanceView from(TokenBalance tokenBalance) { + return new TokenBalanceView( + tokenBalance.userId(), tokenBalance.availableTokens(), tokenBalance.reservedTokens()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationErrorResponse.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationErrorResponse.java new file mode 100644 index 0000000..c606e20 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationErrorResponse.java @@ -0,0 +1,10 @@ +package pl.zzpj.subscription_service.controller.dto; + +import pl.zzpj.subscription_service.domain.token.decision.TokenDecision; + +public record TokenReservationErrorResponse(String code, String message) { + + public static TokenReservationErrorResponse from(String code, TokenDecision decision) { + return new TokenReservationErrorResponse(code, TokenDecision.describe(decision)); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationResponse.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationResponse.java new file mode 100644 index 0000000..fc46040 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/TokenReservationResponse.java @@ -0,0 +1,37 @@ +package pl.zzpj.subscription_service.controller.dto; + +import java.time.Instant; +import java.util.UUID; +import pl.zzpj.subscription_service.domain.token.TokenOperation; +import pl.zzpj.subscription_service.domain.token.TokenReservation; +import pl.zzpj.subscription_service.domain.token.reservation.TokenReservationStatus; +import pl.zzpj.subscription_service.persistence.entity.TokenReservationEntity; + +public record TokenReservationResponse( + UUID reservationId, + String userId, + TokenOperation operation, + int tokens, + TokenReservationStatus status, + Instant expiresAt) { + + public static TokenReservationResponse from(TokenReservation reservation) { + return new TokenReservationResponse( + reservation.reservationId(), + reservation.userId(), + reservation.operation(), + reservation.tokens(), + TokenReservationStatus.RESERVED, + reservation.expiresAt()); + } + + public static TokenReservationResponse from(TokenReservationEntity reservation) { + return new TokenReservationResponse( + reservation.getId(), + reservation.getUserId(), + reservation.getOperation(), + reservation.getTokens(), + reservation.getStatus(), + reservation.getExpiresAt()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/CreatePaymentSessionRequest.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/CreatePaymentSessionRequest.java new file mode 100644 index 0000000..037bb31 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/CreatePaymentSessionRequest.java @@ -0,0 +1,5 @@ +package pl.zzpj.subscription_service.controller.dto.payment; + +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public record CreatePaymentSessionRequest(PlanCode targetPlan) {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/PaymentSessionResponse.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/PaymentSessionResponse.java new file mode 100644 index 0000000..28c403f --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/controller/dto/payment/PaymentSessionResponse.java @@ -0,0 +1,13 @@ +package pl.zzpj.subscription_service.controller.dto.payment; + +import java.util.UUID; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public record PaymentSessionResponse( + UUID id, String userId, PlanCode targetPlan, PaymentSession.Status status) { + public static PaymentSessionResponse from(PaymentSession session) { + return new PaymentSessionResponse( + session.id(), session.userId(), session.targetPlan(), session.status()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentCompletion.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentCompletion.java new file mode 100644 index 0000000..dd099a7 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentCompletion.java @@ -0,0 +1,3 @@ +package pl.zzpj.subscription_service.domain.payment; + +public record PaymentCompletion(PaymentSession session, boolean completedNow) {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentOutcome.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentOutcome.java new file mode 100644 index 0000000..906ef15 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentOutcome.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service.domain.payment; + +public sealed interface PaymentOutcome + permits PaymentOutcome.Succeeded, PaymentOutcome.Failed, PaymentOutcome.Cancelled { + + record Succeeded(String transactionId) implements PaymentOutcome {} + + record Failed(String reason) implements PaymentOutcome {} + + record Cancelled() implements PaymentOutcome {} +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentProvider.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentProvider.java new file mode 100644 index 0000000..9211a40 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentProvider.java @@ -0,0 +1,12 @@ +package pl.zzpj.subscription_service.domain.payment; + +import java.util.UUID; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public interface PaymentProvider { + PaymentSession createSession(String userId, PlanCode targetPlan); + + PaymentSession getSession(UUID sessionId); + + PaymentCompletion completeSession(UUID sessionId, String userId, PaymentOutcome outcome); +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentSession.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentSession.java new file mode 100644 index 0000000..a23e5f4 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/payment/PaymentSession.java @@ -0,0 +1,43 @@ +package pl.zzpj.subscription_service.domain.payment; + +import java.time.Instant; +import java.util.UUID; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public record PaymentSession( + UUID id, + String userId, + PlanCode targetPlan, + Status status, + Instant createdAt, + Instant updatedAt) { + public enum Status { + PENDING, + SUCCEEDED, + FAILED, + CANCELLED + } + + public PaymentSession { + if (id == null) id = UUID.randomUUID(); + if (createdAt == null) createdAt = Instant.now(); + if (updatedAt == null) updatedAt = Instant.now(); + } + + public static PaymentSession create(String userId, PlanCode targetPlan) { + return new PaymentSession( + UUID.randomUUID(), userId, targetPlan, Status.PENDING, Instant.now(), Instant.now()); + } + + public PaymentSession succeed() { + return new PaymentSession(id, userId, targetPlan, Status.SUCCEEDED, createdAt, Instant.now()); + } + + public PaymentSession fail() { + return new PaymentSession(id, userId, targetPlan, Status.FAILED, createdAt, Instant.now()); + } + + public PaymentSession cancel() { + return new PaymentSession(id, userId, targetPlan, Status.CANCELLED, createdAt, Instant.now()); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/pricing/PricingCatalog.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/pricing/PricingCatalog.java new file mode 100644 index 0000000..3b457de --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/pricing/PricingCatalog.java @@ -0,0 +1,28 @@ +package pl.zzpj.subscription_service.domain.pricing; + +import java.util.Map; +import java.util.Objects; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record PricingCatalog(Map tokenCosts) { + + public PricingCatalog { + tokenCosts = tokenCosts == null ? Map.of() : Map.copyOf(tokenCosts); + tokenCosts.forEach( + (operation, tokens) -> { + Objects.requireNonNull(operation, "operation must not be null"); + if (tokens == null || tokens < 0) { + throw new IllegalArgumentException("token cost must not be negative"); + } + }); + } + + public int costOf(TokenOperation operation) { + Objects.requireNonNull(operation, "operation must not be null"); + Integer tokens = tokenCosts.get(operation); + if (tokens == null) { + throw new IllegalArgumentException("No token cost configured for operation " + operation); + } + return tokens; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/ActiveSubscription.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/ActiveSubscription.java new file mode 100644 index 0000000..5cb1532 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/ActiveSubscription.java @@ -0,0 +1,21 @@ +package pl.zzpj.subscription_service.domain.subscription; + +import java.time.Instant; +import java.util.Objects; + +public record ActiveSubscription( + String userId, PlanCode planCode, Instant activeFrom, Instant activeUntil) { + + public ActiveSubscription { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId must not be blank"); + } + Objects.requireNonNull(planCode, "planCode must not be null"); + Objects.requireNonNull(activeFrom, "activeFrom must not be null"); + } + + public boolean isExpiredAt(Instant instant) { + Objects.requireNonNull(instant, "instant must not be null"); + return activeUntil != null && !activeUntil.isAfter(instant); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/PlanCode.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/PlanCode.java new file mode 100644 index 0000000..c835d3a --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/PlanCode.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service.domain.subscription; + +public enum PlanCode { + FREE, + STANDARD, + PRO; + + public boolean canUpgradeTo(PlanCode targetPlan) { + return targetPlan != null && targetPlan.ordinal() > ordinal(); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionCatalog.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionCatalog.java new file mode 100644 index 0000000..025ec0b --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionCatalog.java @@ -0,0 +1,21 @@ +package pl.zzpj.subscription_service.domain.subscription; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public record SubscriptionCatalog(Map plans) { + + public SubscriptionCatalog { + plans = plans == null ? Map.of() : Map.copyOf(plans); + plans.forEach( + (code, plan) -> { + Objects.requireNonNull(code, "plan code must not be null"); + Objects.requireNonNull(plan, "plan must not be null"); + }); + } + + public Optional findPlan(PlanCode code) { + return Optional.ofNullable(plans.get(code)); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionPlan.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionPlan.java new file mode 100644 index 0000000..f82b772 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/subscription/SubscriptionPlan.java @@ -0,0 +1,21 @@ +package pl.zzpj.subscription_service.domain.subscription; + +import java.util.Objects; +import java.util.Set; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record SubscriptionPlan( + PlanCode code, int monthlyTokens, Set allowedOperations) { + + public SubscriptionPlan { + Objects.requireNonNull(code, "code must not be null"); + if (monthlyTokens < 0) { + throw new IllegalArgumentException("monthlyTokens must not be negative"); + } + allowedOperations = allowedOperations == null ? Set.of() : Set.copyOf(allowedOperations); + } + + public boolean allows(TokenOperation operation) { + return allowedOperations.contains(operation); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenBalance.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenBalance.java new file mode 100644 index 0000000..c7ee994 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenBalance.java @@ -0,0 +1,41 @@ +package pl.zzpj.subscription_service.domain.token; + +public record TokenBalance(String userId, int availableTokens, int reservedTokens) { + + public TokenBalance { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId must not be blank"); + } + if (availableTokens < 0) { + throw new IllegalArgumentException("availableTokens must not be negative"); + } + if (reservedTokens < 0) { + throw new IllegalArgumentException("reservedTokens must not be negative"); + } + } + + public boolean canReserve(int tokens) { + return tokens >= 0 && availableTokens >= tokens; + } + + public TokenBalance reserve(int tokens) { + if (!canReserve(tokens)) { + throw new IllegalArgumentException("Not enough available tokens"); + } + return new TokenBalance(userId, availableTokens - tokens, reservedTokens + tokens); + } + + public TokenBalance consumeReserved(int tokens) { + if (tokens < 0 || reservedTokens < tokens) { + throw new IllegalArgumentException("Not enough reserved tokens"); + } + return new TokenBalance(userId, availableTokens, reservedTokens - tokens); + } + + public TokenBalance releaseReserved(int tokens) { + if (tokens < 0 || reservedTokens < tokens) { + throw new IllegalArgumentException("Not enough reserved tokens"); + } + return new TokenBalance(userId, availableTokens + tokens, reservedTokens - tokens); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenOperation.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenOperation.java new file mode 100644 index 0000000..2b3cf53 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenOperation.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service.domain.token; + +public enum TokenOperation { + CAPACITY_CHECK, + DETECT, + EXTRACT, + VISUALIZE, + EMBED_768, + EMBED_1024, + AI_CLASSIFICATION +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservation.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservation.java new file mode 100644 index 0000000..8f72ecd --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservation.java @@ -0,0 +1,21 @@ +package pl.zzpj.subscription_service.domain.token; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record TokenReservation( + UUID reservationId, String userId, TokenOperation operation, int tokens, Instant expiresAt) { + + public TokenReservation { + Objects.requireNonNull(reservationId, "reservationId must not be null"); + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId must not be blank"); + } + Objects.requireNonNull(operation, "operation must not be null"); + if (tokens < 0) { + throw new IllegalArgumentException("tokens must not be negative"); + } + Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicy.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicy.java new file mode 100644 index 0000000..168c004 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicy.java @@ -0,0 +1,80 @@ +package pl.zzpj.subscription_service.domain.token; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import pl.zzpj.subscription_service.domain.pricing.PricingCatalog; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.decision.Accepted; +import pl.zzpj.subscription_service.domain.token.decision.RejectedInsufficientTokens; +import pl.zzpj.subscription_service.domain.token.decision.RejectedOperationNotAllowed; +import pl.zzpj.subscription_service.domain.token.decision.RejectedPlanNotFound; +import pl.zzpj.subscription_service.domain.token.decision.RejectedSubscriptionExpired; +import pl.zzpj.subscription_service.domain.token.decision.TokenDecision; + +public class TokenReservationPolicy { + + private static final Duration DEFAULT_RESERVATION_TTL = Duration.ofMinutes(15); + + private final SubscriptionCatalog subscriptionCatalog; + private final PricingCatalog pricingCatalog; + private final Duration reservationTtl; + + public TokenReservationPolicy( + SubscriptionCatalog subscriptionCatalog, PricingCatalog pricingCatalog) { + this(subscriptionCatalog, pricingCatalog, DEFAULT_RESERVATION_TTL); + } + + public TokenReservationPolicy( + SubscriptionCatalog subscriptionCatalog, + PricingCatalog pricingCatalog, + Duration reservationTtl) { + this.subscriptionCatalog = + Objects.requireNonNull(subscriptionCatalog, "subscriptionCatalog must not be null"); + this.pricingCatalog = Objects.requireNonNull(pricingCatalog, "pricingCatalog must not be null"); + this.reservationTtl = Objects.requireNonNull(reservationTtl, "reservationTtl must not be null"); + } + + public TokenDecision decide( + ActiveSubscription subscription, + TokenBalance balance, + TokenOperation operation, + Instant now) { + Objects.requireNonNull(subscription, "subscription must not be null"); + Objects.requireNonNull(balance, "balance must not be null"); + Objects.requireNonNull(operation, "operation must not be null"); + Objects.requireNonNull(now, "now must not be null"); + + if (subscription.isExpiredAt(now)) { + return new RejectedSubscriptionExpired(subscription.activeUntil()); + } + + return subscriptionCatalog + .findPlan(subscription.planCode()) + .map(plan -> decideForPlan(plan, subscription, balance, operation, now)) + .orElseGet(() -> new RejectedPlanNotFound(subscription.planCode())); + } + + private TokenDecision decideForPlan( + SubscriptionPlan plan, + ActiveSubscription subscription, + TokenBalance balance, + TokenOperation operation, + Instant now) { + if (!plan.allows(operation)) { + return new RejectedOperationNotAllowed(plan.code(), operation); + } + + int cost = pricingCatalog.costOf(operation); + if (!balance.canReserve(cost)) { + return new RejectedInsufficientTokens(operation, cost, balance.availableTokens()); + } + + return new Accepted( + new TokenReservation( + UUID.randomUUID(), subscription.userId(), operation, cost, now.plus(reservationTtl))); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/Accepted.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/Accepted.java new file mode 100644 index 0000000..32fff81 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/Accepted.java @@ -0,0 +1,5 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import pl.zzpj.subscription_service.domain.token.TokenReservation; + +public record Accepted(TokenReservation reservation) implements TokenDecision {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedInsufficientTokens.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedInsufficientTokens.java new file mode 100644 index 0000000..1b25efd --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedInsufficientTokens.java @@ -0,0 +1,6 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record RejectedInsufficientTokens( + TokenOperation operation, int requiredTokens, int availableTokens) implements TokenDecision {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedOperationNotAllowed.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedOperationNotAllowed.java new file mode 100644 index 0000000..efb8d52 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedOperationNotAllowed.java @@ -0,0 +1,7 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.token.TokenOperation; + +public record RejectedOperationNotAllowed(PlanCode planCode, TokenOperation operation) + implements TokenDecision {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedPlanNotFound.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedPlanNotFound.java new file mode 100644 index 0000000..8508813 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedPlanNotFound.java @@ -0,0 +1,5 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +public record RejectedPlanNotFound(PlanCode planCode) implements TokenDecision {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedSubscriptionExpired.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedSubscriptionExpired.java new file mode 100644 index 0000000..d479ab4 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/RejectedSubscriptionExpired.java @@ -0,0 +1,5 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import java.time.Instant; + +public record RejectedSubscriptionExpired(Instant expiredAt) implements TokenDecision {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/TokenDecision.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/TokenDecision.java new file mode 100644 index 0000000..2cb70b6 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/decision/TokenDecision.java @@ -0,0 +1,42 @@ +package pl.zzpj.subscription_service.domain.token.decision; + +import pl.zzpj.subscription_service.domain.token.TokenReservation; + +public sealed interface TokenDecision + permits Accepted, + RejectedInsufficientTokens, + RejectedOperationNotAllowed, + RejectedPlanNotFound, + RejectedSubscriptionExpired { + + static String describe(TokenDecision decision) { + return switch (decision) { + case Accepted( + TokenReservation( + var reservationId, + var userId, + var operation, + var tokens, + var expiresAt)) -> + "Reserved " + + tokens + + " tokens for " + + operation + + " as " + + reservationId + + " for user " + + userId; + case RejectedInsufficientTokens(var operation, var requiredTokens, var availableTokens) -> + "Cannot reserve " + + requiredTokens + + " tokens for " + + operation + + "; available tokens: " + + availableTokens; + case RejectedOperationNotAllowed(var planCode, var operation) -> + "Plan " + planCode + " does not allow operation " + operation; + case RejectedPlanNotFound(var planCode) -> "Subscription plan " + planCode + " was not found"; + case RejectedSubscriptionExpired(var expiredAt) -> "Subscription expired at " + expiredAt; + }; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/reservation/TokenReservationStatus.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/reservation/TokenReservationStatus.java new file mode 100644 index 0000000..2721a3a --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/domain/token/reservation/TokenReservationStatus.java @@ -0,0 +1,7 @@ +package pl.zzpj.subscription_service.domain.token.reservation; + +public enum TokenReservationStatus { + RESERVED, + CONSUMED, + RELEASED +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProvider.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProvider.java new file mode 100644 index 0000000..4d7cde6 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProvider.java @@ -0,0 +1,72 @@ +package pl.zzpj.subscription_service.infrastructure.payment; + +import java.util.UUID; +import org.springframework.stereotype.Component; +import pl.zzpj.subscription_service.domain.payment.PaymentCompletion; +import pl.zzpj.subscription_service.domain.payment.PaymentOutcome; +import pl.zzpj.subscription_service.domain.payment.PaymentProvider; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.persistence.entity.PaymentSessionEntity; +import pl.zzpj.subscription_service.persistence.repository.PaymentSessionRepository; + +@Component +public class MockPaymentProvider implements PaymentProvider { + + private final PaymentSessionRepository repository; + + public MockPaymentProvider(PaymentSessionRepository repository) { + this.repository = repository; + } + + @Override + public PaymentSession createSession(String userId, PlanCode targetPlan) { + PaymentSession session = PaymentSession.create(userId, targetPlan); + repository.save(PaymentSessionEntity.from(session)); + return session; + } + + @Override + public PaymentSession getSession(UUID sessionId) { + return repository + .findById(sessionId) + .map(PaymentSessionEntity::toDomain) + .orElseThrow(() -> new IllegalArgumentException("Session not found: " + sessionId)); + } + + @Override + public PaymentCompletion completeSession(UUID sessionId, String userId, PaymentOutcome outcome) { + PaymentSession session = + repository + .findByIdForUpdate(sessionId) + .map(PaymentSessionEntity::toDomain) + .orElseThrow(() -> new IllegalArgumentException("Session not found: " + sessionId)); + if (!session.userId().equals(userId)) { + throw new IllegalArgumentException("Payment session belongs to another user"); + } + if (session.status() != PaymentSession.Status.PENDING) { + if (session.status() == statusFor(outcome)) { + return new PaymentCompletion(session, false); + } + throw new IllegalStateException("Session already completed with status " + session.status()); + } + + PaymentSession updatedSession = + switch (outcome) { + case PaymentOutcome.Succeeded succeeded -> session.succeed(); + case PaymentOutcome.Failed failed -> session.fail(); + case PaymentOutcome.Cancelled cancelled -> session.cancel(); + }; + + repository.save(PaymentSessionEntity.from(updatedSession)); + return new PaymentCompletion(updatedSession, true); + } + + private PaymentSession.Status statusFor(PaymentOutcome outcome) { + return switch (outcome) { + case PaymentOutcome.Succeeded succeeded -> PaymentSession.Status.SUCCEEDED; + case PaymentOutcome.Failed failed -> PaymentSession.Status.FAILED; + case PaymentOutcome.Cancelled cancelled -> PaymentSession.Status.CANCELLED; + }; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/SubscriptionStore.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/SubscriptionStore.java new file mode 100644 index 0000000..7c838b7 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/SubscriptionStore.java @@ -0,0 +1,77 @@ +package pl.zzpj.subscription_service.persistence; + +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import pl.zzpj.subscription_service.application.UserSubscriptionState; +import pl.zzpj.subscription_service.persistence.entity.ActiveSubscriptionEntity; +import pl.zzpj.subscription_service.persistence.entity.TokenBalanceEntity; +import pl.zzpj.subscription_service.persistence.repository.ActiveSubscriptionRepository; +import pl.zzpj.subscription_service.persistence.repository.TokenBalanceRepository; + +@Component +public class SubscriptionStore { + + private final ActiveSubscriptionRepository subscriptionRepository; + private final TokenBalanceRepository tokenBalanceRepository; + + public SubscriptionStore( + ActiveSubscriptionRepository subscriptionRepository, + TokenBalanceRepository tokenBalanceRepository) { + this.subscriptionRepository = subscriptionRepository; + this.tokenBalanceRepository = tokenBalanceRepository; + } + + @Transactional + public UserSubscriptionState getOrCreate(String userId, UserSubscriptionState initialState) { + return find(userId).orElseGet(() -> create(initialState)); + } + + @Transactional + public UserSubscriptionState save(UserSubscriptionState state) { + subscriptionRepository.save(ActiveSubscriptionEntity.from(state.subscription())); + tokenBalanceRepository.save(TokenBalanceEntity.from(state.tokenBalance())); + return state; + } + + @Transactional(readOnly = true) + public Optional find(String userId) { + return subscriptionRepository + .findById(userId) + .flatMap( + subscription -> + tokenBalanceRepository + .findById(userId) + .map( + tokenBalance -> + new UserSubscriptionState( + subscription.toDomain(), tokenBalance.toDomain()))); + } + + @Transactional + public Optional findForUpdate(String userId) { + return subscriptionRepository + .findByIdForUpdate(userId) + .flatMap( + subscription -> + tokenBalanceRepository + .findById(userId) + .map( + tokenBalance -> + new UserSubscriptionState( + subscription.toDomain(), tokenBalance.toDomain()))); + } + + private UserSubscriptionState create(UserSubscriptionState initialState) { + subscriptionRepository.insertIfMissing( + initialState.subscription().userId(), + initialState.subscription().planCode().name(), + initialState.subscription().activeFrom()); + tokenBalanceRepository.insertIfMissing( + initialState.tokenBalance().userId(), + initialState.tokenBalance().availableTokens(), + initialState.tokenBalance().reservedTokens()); + return find(initialState.subscription().userId()) + .orElseThrow(() -> new IllegalStateException("Subscription state could not be created")); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/ActiveSubscriptionEntity.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/ActiveSubscriptionEntity.java new file mode 100644 index 0000000..967b158 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/ActiveSubscriptionEntity.java @@ -0,0 +1,56 @@ +package pl.zzpj.subscription_service.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +@Entity +@Table(name = "active_subscriptions", schema = "subscription_schema") +public class ActiveSubscriptionEntity { + + @Id + @Column(name = "user_id", nullable = false) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "plan_code", nullable = false) + private PlanCode planCode; + + @Column(name = "active_from", nullable = false) + private Instant activeFrom; + + @Column(name = "active_until") + private Instant activeUntil; + + protected ActiveSubscriptionEntity() {} + + public ActiveSubscriptionEntity( + String userId, PlanCode planCode, Instant activeFrom, Instant activeUntil) { + this.userId = userId; + this.planCode = planCode; + this.activeFrom = activeFrom; + this.activeUntil = activeUntil; + } + + public static ActiveSubscriptionEntity from(ActiveSubscription subscription) { + return new ActiveSubscriptionEntity( + subscription.userId(), + subscription.planCode(), + subscription.activeFrom(), + subscription.activeUntil()); + } + + public ActiveSubscription toDomain() { + return new ActiveSubscription(userId, planCode, activeFrom, activeUntil); + } + + public String getUserId() { + return userId; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/PaymentSessionEntity.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/PaymentSessionEntity.java new file mode 100644 index 0000000..3fd558c --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/PaymentSessionEntity.java @@ -0,0 +1,66 @@ +package pl.zzpj.subscription_service.persistence.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; + +@Entity +@Table(name = "payment_sessions", schema = "subscription_schema") +public class PaymentSessionEntity { + + @Id private UUID id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_plan", nullable = false) + private PlanCode targetPlan; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentSession.Status status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected PaymentSessionEntity() {} + + public PaymentSessionEntity( + UUID id, + String userId, + PlanCode targetPlan, + PaymentSession.Status status, + Instant createdAt, + Instant updatedAt) { + this.id = id; + this.userId = userId; + this.targetPlan = targetPlan; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static PaymentSessionEntity from(PaymentSession session) { + return new PaymentSessionEntity( + session.id(), + session.userId(), + session.targetPlan(), + session.status(), + session.createdAt(), + session.updatedAt()); + } + + public PaymentSession toDomain() { + return new PaymentSession(id, userId, targetPlan, status, createdAt, updatedAt); + } + + public UUID getId() { + return id; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenBalanceEntity.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenBalanceEntity.java new file mode 100644 index 0000000..0100634 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenBalanceEntity.java @@ -0,0 +1,43 @@ +package pl.zzpj.subscription_service.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import pl.zzpj.subscription_service.domain.token.TokenBalance; + +@Entity +@Table(name = "token_balances", schema = "subscription_schema") +public class TokenBalanceEntity { + + @Id + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "available_tokens", nullable = false) + private int availableTokens; + + @Column(name = "reserved_tokens", nullable = false) + private int reservedTokens; + + protected TokenBalanceEntity() {} + + public TokenBalanceEntity(String userId, int availableTokens, int reservedTokens) { + this.userId = userId; + this.availableTokens = availableTokens; + this.reservedTokens = reservedTokens; + } + + public static TokenBalanceEntity from(TokenBalance tokenBalance) { + return new TokenBalanceEntity( + tokenBalance.userId(), tokenBalance.availableTokens(), tokenBalance.reservedTokens()); + } + + public TokenBalance toDomain() { + return new TokenBalance(userId, availableTokens, reservedTokens); + } + + public String getUserId() { + return userId; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenReservationEntity.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenReservationEntity.java new file mode 100644 index 0000000..aed0188 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/entity/TokenReservationEntity.java @@ -0,0 +1,123 @@ +package pl.zzpj.subscription_service.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; +import pl.zzpj.subscription_service.domain.token.TokenOperation; +import pl.zzpj.subscription_service.domain.token.TokenReservation; +import pl.zzpj.subscription_service.domain.token.reservation.TokenReservationStatus; + +@Entity +@Table(name = "token_reservations", schema = "subscription_schema") +public class TokenReservationEntity { + + @Id + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "operation", nullable = false) + private TokenOperation operation; + + @Column(name = "tokens", nullable = false) + private int tokens; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TokenReservationStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "external_operation_id") + private String externalOperationId; + + protected TokenReservationEntity() {} + + public TokenReservationEntity( + UUID id, + String userId, + TokenOperation operation, + int tokens, + TokenReservationStatus status, + Instant createdAt, + Instant expiresAt, + Instant completedAt, + String externalOperationId) { + this.id = id; + this.userId = userId; + this.operation = operation; + this.tokens = tokens; + this.status = status; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.completedAt = completedAt; + this.externalOperationId = externalOperationId; + } + + public static TokenReservationEntity from( + TokenReservation reservation, Instant createdAt, String externalOperationId) { + return new TokenReservationEntity( + reservation.reservationId(), + reservation.userId(), + reservation.operation(), + reservation.tokens(), + TokenReservationStatus.RESERVED, + createdAt, + reservation.expiresAt(), + null, + externalOperationId); + } + + public TokenReservation toDomain() { + return new TokenReservation(id, userId, operation, tokens, expiresAt); + } + + public UUID getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public int getTokens() { + return tokens; + } + + public TokenOperation getOperation() { + return operation; + } + + public TokenReservationStatus getStatus() { + return status; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void markConsumed(Instant completedAt) { + this.status = TokenReservationStatus.CONSUMED; + this.completedAt = completedAt; + } + + public void markReleased(Instant completedAt) { + this.status = TokenReservationStatus.RELEASED; + this.completedAt = completedAt; + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/ActiveSubscriptionRepository.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/ActiveSubscriptionRepository.java new file mode 100644 index 0000000..6063abb --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/ActiveSubscriptionRepository.java @@ -0,0 +1,35 @@ +package pl.zzpj.subscription_service.persistence.repository; + +import jakarta.persistence.LockModeType; +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.zzpj.subscription_service.persistence.entity.ActiveSubscriptionEntity; + +public interface ActiveSubscriptionRepository + extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "select subscription from ActiveSubscriptionEntity subscription where subscription.userId =" + + " :userId") + Optional findByIdForUpdate(@Param("userId") String userId); + + @Modifying + @Query( + value = + """ +INSERT INTO subscription_schema.active_subscriptions (user_id, plan_code, active_from, active_until) +VALUES (:userId, :planCode, :activeFrom, NULL) +ON CONFLICT (user_id) DO NOTHING +""", + nativeQuery = true) + void insertIfMissing( + @Param("userId") String userId, + @Param("planCode") String planCode, + @Param("activeFrom") Instant activeFrom); +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/PaymentSessionRepository.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/PaymentSessionRepository.java new file mode 100644 index 0000000..f17128d --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/PaymentSessionRepository.java @@ -0,0 +1,17 @@ +package pl.zzpj.subscription_service.persistence.repository; + +import jakarta.persistence.LockModeType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.zzpj.subscription_service.persistence.entity.PaymentSessionEntity; + +public interface PaymentSessionRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select session from PaymentSessionEntity session where session.id = :id") + Optional findByIdForUpdate(@Param("id") UUID id); +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenBalanceRepository.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenBalanceRepository.java new file mode 100644 index 0000000..a9e483c --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenBalanceRepository.java @@ -0,0 +1,24 @@ +package pl.zzpj.subscription_service.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.zzpj.subscription_service.persistence.entity.TokenBalanceEntity; + +public interface TokenBalanceRepository extends JpaRepository { + + @Modifying + @Query( + value = + """ + INSERT INTO subscription_schema.token_balances (user_id, available_tokens, reserved_tokens) + VALUES (:userId, :availableTokens, :reservedTokens) + ON CONFLICT (user_id) DO NOTHING + """, + nativeQuery = true) + void insertIfMissing( + @Param("userId") String userId, + @Param("availableTokens") int availableTokens, + @Param("reservedTokens") int reservedTokens); +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenReservationRepository.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenReservationRepository.java new file mode 100644 index 0000000..49c2b1b --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/persistence/repository/TokenReservationRepository.java @@ -0,0 +1,7 @@ +package pl.zzpj.subscription_service.persistence.repository; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import pl.zzpj.subscription_service.persistence.entity.TokenReservationEntity; + +public interface TokenReservationRepository extends JpaRepository {} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/security/JwtFilter.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/security/JwtFilter.java new file mode 100644 index 0000000..61af68d --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/security/JwtFilter.java @@ -0,0 +1,93 @@ +package pl.zzpj.subscription_service.security; + +import feign.FeignException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import pl.zzpj.subscription_service.client.AuthClient; + +@Component +public class JwtFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String CHARACTER_ENCODING_UTF8 = "UTF-8"; + + private final AuthClient authClient; + + public JwtFilter(AuthClient authClient) { + this.authClient = authClient; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authHeader = request.getHeader(AUTHORIZATION_HEADER); + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(BEARER_PREFIX.length()); + try { + if (Boolean.TRUE.equals(authClient.validateToken(token))) { + String principal = extractPrincipalFromToken(token); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + return; + } + handleError(response, "Invalid or expired token"); + } catch (FeignException exception) { + handleError(response, "Authentication service is currently unavailable"); + } catch (IllegalArgumentException exception) { + handleError(response, "Could not read token principal"); + } + } + + private String extractPrincipalFromToken(String token) { + String[] chunks = token.split("\\."); + if (chunks.length < 2) { + throw new IllegalArgumentException("JWT payload is missing"); + } + String payload = new String(Base64.getUrlDecoder().decode(chunks[1]), StandardCharsets.UTF_8); + String userId = extractJsonNumberField(payload, "userId"); + if (userId == null) { + throw new IllegalArgumentException("JWT principal claims are missing"); + } + return userId; + } + + private String extractJsonNumberField(String json, String field) { + String search = "\"" + field + "\":"; + int start = json.indexOf(search); + if (start == -1) { + return null; + } + start += search.length(); + int end = start; + while (end < json.length() && Character.isDigit(json.charAt(end))) { + end++; + } + return end > start ? json.substring(start, end) : null; + } + + private void handleError(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(CONTENT_TYPE_JSON); + response.setCharacterEncoding(CHARACTER_ENCODING_UTF8); + response.getWriter().write("{\"error\":\"" + message + "\"}"); + } +} diff --git a/subscription-service/src/main/java/pl/zzpj/subscription_service/security/SecurityConfig.java b/subscription-service/src/main/java/pl/zzpj/subscription_service/security/SecurityConfig.java new file mode 100644 index 0000000..49dac76 --- /dev/null +++ b/subscription-service/src/main/java/pl/zzpj/subscription_service/security/SecurityConfig.java @@ -0,0 +1,43 @@ +package pl.zzpj.subscription_service.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + public SecurityConfig(JwtFilter jwtFilter) { + this.jwtFilter = jwtFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/actuator/health", + "/actuator/health/**", + "/api/subscriptions/status", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/subscription-service/src/main/resources/application.yaml b/subscription-service/src/main/resources/application.yaml new file mode 100644 index 0000000..cbf03e4 --- /dev/null +++ b/subscription-service/src/main/resources/application.yaml @@ -0,0 +1,5 @@ +spring: + application: + name: subscription-service + config: + import: "optional:configserver:http://localhost:8888" \ No newline at end of file diff --git a/subscription-service/src/main/resources/db/migration/V1__create_subscription_tables.sql b/subscription-service/src/main/resources/db/migration/V1__create_subscription_tables.sql new file mode 100644 index 0000000..90a4962 --- /dev/null +++ b/subscription-service/src/main/resources/db/migration/V1__create_subscription_tables.sql @@ -0,0 +1,20 @@ +CREATE SCHEMA IF NOT EXISTS subscription_schema; + +CREATE TABLE subscription_schema.active_subscriptions ( + user_id VARCHAR(255) PRIMARY KEY, + plan_code VARCHAR(32) NOT NULL, + active_from TIMESTAMP NOT NULL, + active_until TIMESTAMP +); + +CREATE TABLE subscription_schema.token_balances ( + user_id VARCHAR(255) PRIMARY KEY, + available_tokens INTEGER NOT NULL, + reserved_tokens INTEGER NOT NULL, + CONSTRAINT token_balances_available_non_negative CHECK (available_tokens >= 0), + CONSTRAINT token_balances_reserved_non_negative CHECK (reserved_tokens >= 0), + CONSTRAINT token_balances_subscription_fk + FOREIGN KEY (user_id) + REFERENCES subscription_schema.active_subscriptions (user_id) + ON DELETE CASCADE +); diff --git a/subscription-service/src/main/resources/db/migration/V2__create_token_reservations_table.sql b/subscription-service/src/main/resources/db/migration/V2__create_token_reservations_table.sql new file mode 100644 index 0000000..69e8dca --- /dev/null +++ b/subscription-service/src/main/resources/db/migration/V2__create_token_reservations_table.sql @@ -0,0 +1,19 @@ +CREATE TABLE subscription_schema.token_reservations ( + id UUID PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + operation VARCHAR(64) NOT NULL, + tokens INTEGER NOT NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + external_operation_id VARCHAR(255), + CONSTRAINT token_reservations_tokens_non_negative CHECK (tokens >= 0), + CONSTRAINT token_reservations_balance_fk + FOREIGN KEY (user_id) + REFERENCES subscription_schema.token_balances (user_id) + ON DELETE CASCADE +); + +CREATE INDEX token_reservations_user_id_idx ON subscription_schema.token_reservations (user_id); +CREATE INDEX token_reservations_external_operation_id_idx ON subscription_schema.token_reservations (external_operation_id); diff --git a/subscription-service/src/main/resources/db/migration/V3__seed_demo_subscriptions.sql b/subscription-service/src/main/resources/db/migration/V3__seed_demo_subscriptions.sql new file mode 100644 index 0000000..a9b47cd --- /dev/null +++ b/subscription-service/src/main/resources/db/migration/V3__seed_demo_subscriptions.sql @@ -0,0 +1,17 @@ +INSERT INTO subscription_schema.active_subscriptions (user_id, plan_code, active_from, active_until) +VALUES + ('1', 'PRO', CURRENT_TIMESTAMP, NULL), + ('2', 'FREE', CURRENT_TIMESTAMP, NULL), + ('3', 'STANDARD', CURRENT_TIMESTAMP, NULL), + ('4', 'PRO', CURRENT_TIMESTAMP, NULL), + ('5', 'FREE', CURRENT_TIMESTAMP, NULL) +ON CONFLICT DO NOTHING; + +INSERT INTO subscription_schema.token_balances (user_id, available_tokens, reserved_tokens) +VALUES + ('1', 2500, 0), + ('2', 50, 0), + ('3', 500, 0), + ('4', 2500, 0), + ('5', 3, 0) +ON CONFLICT DO NOTHING; diff --git a/subscription-service/src/main/resources/db/migration/V4__create_payment_sessions_table.sql b/subscription-service/src/main/resources/db/migration/V4__create_payment_sessions_table.sql new file mode 100644 index 0000000..874833c --- /dev/null +++ b/subscription-service/src/main/resources/db/migration/V4__create_payment_sessions_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE subscription_schema.payment_sessions ( + id UUID PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + target_plan VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/SubscriptionServiceApplicationTests.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/SubscriptionServiceApplicationTests.java new file mode 100644 index 0000000..23c28dc --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/SubscriptionServiceApplicationTests.java @@ -0,0 +1,11 @@ +package pl.zzpj.subscription_service; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SubscriptionServiceApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/application/PaymentApplicationServiceTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/PaymentApplicationServiceTest.java new file mode 100644 index 0000000..6a405f6 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/PaymentApplicationServiceTest.java @@ -0,0 +1,133 @@ +package pl.zzpj.subscription_service.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import pl.zzpj.subscription_service.domain.payment.PaymentCompletion; +import pl.zzpj.subscription_service.domain.payment.PaymentOutcome; +import pl.zzpj.subscription_service.domain.payment.PaymentProvider; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.persistence.SubscriptionStore; + +@ExtendWith(MockitoExtension.class) +class PaymentApplicationServiceTest { + + @Mock private PaymentProvider paymentProvider; + + @Mock private SubscriptionStore subscriptionStore; + + @Mock private SubscriptionCatalog subscriptionCatalog; + + @Mock private SubscriptionQueryService subscriptionQueryService; + + private final Instant now = Instant.parse("2026-06-18T12:00:00Z"); + private final Clock clock = Clock.fixed(now, ZoneOffset.UTC); + + private PaymentApplicationService service; + + @BeforeEach + void setUp() { + service = + new PaymentApplicationService( + paymentProvider, + subscriptionStore, + subscriptionCatalog, + subscriptionQueryService, + clock); + } + + @Test + void shouldRejectDowngradeWhenCreatingPaymentSession() { + String userId = "user123"; + when(subscriptionQueryService.stateFor(userId)).thenReturn(state(userId, PlanCode.PRO, 100, 0)); + + assertThrows( + IllegalArgumentException.class, () -> service.initiatePayment(userId, PlanCode.STANDARD)); + verify(paymentProvider, never()).createSession(any(), any()); + } + + @Test + void shouldAddPlanTokensAndStartNewMonthlyPeriodOnUpgrade() { + String userId = "user123"; + UUID sessionId = UUID.randomUUID(); + PaymentSession session = + new PaymentSession( + sessionId, + userId, + PlanCode.PRO, + PaymentSession.Status.SUCCEEDED, + now.minusSeconds(10), + now); + UserSubscriptionState currentState = state(userId, PlanCode.STANDARD, 420, 3); + + when(paymentProvider.completeSession( + sessionId, userId, new PaymentOutcome.Succeeded("transaction"))) + .thenReturn(new PaymentCompletion(session, true)); + when(subscriptionCatalog.findPlan(PlanCode.PRO)) + .thenReturn(Optional.of(new SubscriptionPlan(PlanCode.PRO, 2500, Set.of()))); + when(subscriptionStore.findForUpdate(userId)).thenReturn(Optional.of(currentState)); + when(subscriptionStore.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.completePayment(userId, sessionId, new PaymentOutcome.Succeeded("transaction")); + + ArgumentCaptor stateCaptor = + ArgumentCaptor.forClass(UserSubscriptionState.class); + verify(subscriptionStore).save(stateCaptor.capture()); + UserSubscriptionState savedState = stateCaptor.getValue(); + assertEquals(PlanCode.PRO, savedState.subscription().planCode()); + assertEquals(now, savedState.subscription().activeFrom()); + assertEquals(Instant.parse("2026-07-18T12:00:00Z"), savedState.subscription().activeUntil()); + assertEquals(2920, savedState.tokenBalance().availableTokens()); + assertEquals(3, savedState.tokenBalance().reservedTokens()); + } + + @Test + void shouldNotApplyTokensAgainForIdempotentCompletion() { + String userId = "user123"; + UUID sessionId = UUID.randomUUID(); + PaymentSession session = + new PaymentSession( + sessionId, + userId, + PlanCode.PRO, + PaymentSession.Status.SUCCEEDED, + now.minusSeconds(10), + now); + PaymentOutcome outcome = new PaymentOutcome.Succeeded("transaction"); + when(paymentProvider.completeSession(sessionId, userId, outcome)) + .thenReturn(new PaymentCompletion(session, false)); + + service.completePayment(userId, sessionId, outcome); + + verify(subscriptionStore, never()).save(any()); + verify(subscriptionCatalog, never()).findPlan(any()); + } + + private UserSubscriptionState state( + String userId, PlanCode planCode, int availableTokens, int reservedTokens) { + return new UserSubscriptionState( + new ActiveSubscription(userId, planCode, now.minusSeconds(60), null), + new TokenBalance(userId, availableTokens, reservedTokens)); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/application/SubscriptionQueryServiceTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/SubscriptionQueryServiceTest.java new file mode 100644 index 0000000..1584600 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/SubscriptionQueryServiceTest.java @@ -0,0 +1,121 @@ +package pl.zzpj.subscription_service.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.persistence.SubscriptionStore; + +@ExtendWith(MockitoExtension.class) +class SubscriptionQueryServiceTest { + + @Mock private SubscriptionCatalog subscriptionCatalog; + + @Mock private SubscriptionStore subscriptionStore; + + private final Instant fixedInstant = Instant.parse("2026-06-15T12:00:00Z"); + private final Clock clock = Clock.fixed(fixedInstant, ZoneId.of("UTC")); + + private SubscriptionQueryService subscriptionQueryService; + + @BeforeEach + void setUp() { + subscriptionQueryService = + new SubscriptionQueryService(subscriptionCatalog, subscriptionStore, clock); + } + + @Test + void shouldReturnAvailablePlansSortedByOrdinal() { + SubscriptionPlan free = new SubscriptionPlan(PlanCode.FREE, 50, Set.of()); + SubscriptionPlan pro = new SubscriptionPlan(PlanCode.PRO, 2500, Set.of()); + + when(subscriptionCatalog.plans()).thenReturn(Map.of(PlanCode.FREE, free, PlanCode.PRO, pro)); + + var plans = subscriptionQueryService.availablePlans(); + + assertEquals(2, plans.size()); + assertEquals(PlanCode.FREE, plans.get(0).code()); + assertEquals(PlanCode.PRO, plans.get(1).code()); + } + + @Test + void shouldReturnStateForUser() { + String userId = "user123"; + SubscriptionPlan free = new SubscriptionPlan(PlanCode.FREE, 50, Set.of()); + when(subscriptionCatalog.findPlan(PlanCode.FREE)).thenReturn(Optional.of(free)); + + UserSubscriptionState expectedState = + new UserSubscriptionState( + new ActiveSubscription(userId, PlanCode.FREE, fixedInstant, null), + new TokenBalance(userId, 50, 0)); + when(subscriptionStore.getOrCreate(eq(userId), any())).thenReturn(expectedState); + + UserSubscriptionState actualState = subscriptionQueryService.stateFor(userId); + + assertEquals(expectedState, actualState); + } + + @Test + void shouldResetExpiredPaidPlanToFreeWhenReservationsAreSettled() { + String userId = "user123"; + SubscriptionPlan free = new SubscriptionPlan(PlanCode.FREE, 50, Set.of()); + when(subscriptionCatalog.findPlan(PlanCode.FREE)).thenReturn(Optional.of(free)); + + UserSubscriptionState expiredState = + new UserSubscriptionState( + new ActiveSubscription( + userId, + PlanCode.PRO, + fixedInstant.minusSeconds(2_000_000), + fixedInstant.minusSeconds(1)), + new TokenBalance(userId, 1234, 0)); + when(subscriptionStore.getOrCreate(eq(userId), any())).thenReturn(expiredState); + when(subscriptionStore.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + UserSubscriptionState actualState = subscriptionQueryService.stateFor(userId); + + assertEquals(PlanCode.FREE, actualState.subscription().planCode()); + assertEquals(50, actualState.tokenBalance().availableTokens()); + assertEquals(0, actualState.tokenBalance().reservedTokens()); + verify(subscriptionStore).save(actualState); + } + + @Test + void shouldWaitForReservedTokensBeforeResettingExpiredPlan() { + String userId = "user123"; + SubscriptionPlan free = new SubscriptionPlan(PlanCode.FREE, 50, Set.of()); + when(subscriptionCatalog.findPlan(PlanCode.FREE)).thenReturn(Optional.of(free)); + + UserSubscriptionState expiredState = + new UserSubscriptionState( + new ActiveSubscription( + userId, + PlanCode.PRO, + fixedInstant.minusSeconds(2_000_000), + fixedInstant.minusSeconds(1)), + new TokenBalance(userId, 1234, 3)); + when(subscriptionStore.getOrCreate(eq(userId), any())).thenReturn(expiredState); + + UserSubscriptionState actualState = subscriptionQueryService.stateFor(userId); + + assertEquals(expiredState, actualState); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/application/TokenReservationCommandServiceTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/TokenReservationCommandServiceTest.java new file mode 100644 index 0000000..51b1962 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/application/TokenReservationCommandServiceTest.java @@ -0,0 +1,181 @@ +package pl.zzpj.subscription_service.application; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import pl.zzpj.subscription_service.application.command.CreateTokenReservationCommand; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.token.TokenBalance; +import pl.zzpj.subscription_service.domain.token.TokenOperation; +import pl.zzpj.subscription_service.domain.token.TokenReservation; +import pl.zzpj.subscription_service.domain.token.TokenReservationPolicy; +import pl.zzpj.subscription_service.domain.token.decision.Accepted; +import pl.zzpj.subscription_service.domain.token.decision.RejectedInsufficientTokens; +import pl.zzpj.subscription_service.domain.token.decision.TokenDecision; +import pl.zzpj.subscription_service.domain.token.reservation.TokenReservationStatus; +import pl.zzpj.subscription_service.persistence.entity.TokenBalanceEntity; +import pl.zzpj.subscription_service.persistence.entity.TokenReservationEntity; +import pl.zzpj.subscription_service.persistence.repository.TokenBalanceRepository; +import pl.zzpj.subscription_service.persistence.repository.TokenReservationRepository; + +@ExtendWith(MockitoExtension.class) +class TokenReservationCommandServiceTest { + + @Mock private SubscriptionQueryService subscriptionQueryService; + + @Mock private TokenBalanceRepository tokenBalanceRepository; + + @Mock private TokenReservationRepository tokenReservationRepository; + + @Mock private TokenReservationPolicy reservationPolicy; + + private final Instant fixedInstant = Instant.parse("2026-06-15T12:00:00Z"); + private final Clock clock = Clock.fixed(fixedInstant, ZoneId.of("UTC")); + + private TokenReservationCommandService service; + + @BeforeEach + void setUp() { + service = + new TokenReservationCommandService( + subscriptionQueryService, + tokenBalanceRepository, + tokenReservationRepository, + reservationPolicy, + clock); + } + + @Test + void shouldReserveTokensSuccessfully() { + String userId = "user123"; + CreateTokenReservationCommand command = + new CreateTokenReservationCommand(TokenOperation.DETECT, "op1"); + + ActiveSubscription sub = new ActiveSubscription(userId, PlanCode.FREE, fixedInstant, null); + TokenBalance balance = new TokenBalance(userId, 100, 0); + + when(subscriptionQueryService.stateFor(userId)) + .thenReturn(new UserSubscriptionState(sub, balance)); + + TokenReservation reservation = + new TokenReservation( + UUID.randomUUID(), userId, TokenOperation.DETECT, 1, fixedInstant.plusSeconds(3600)); + TokenDecision decision = new Accepted(reservation); + when(reservationPolicy.decide(any(), any(), eq(TokenOperation.DETECT), eq(fixedInstant))) + .thenReturn(decision); + + TokenDecision result = service.reserve(userId, command); + + assertTrue(result instanceof Accepted); + verify(tokenBalanceRepository).save(any()); + verify(tokenReservationRepository).save(any()); + } + + @Test + void shouldNotUpdateRepositoriesWhenReservationRejected() { + String userId = "user123"; + CreateTokenReservationCommand command = + new CreateTokenReservationCommand(TokenOperation.DETECT, "op1"); + + ActiveSubscription sub = new ActiveSubscription(userId, PlanCode.FREE, fixedInstant, null); + TokenBalance balance = new TokenBalance(userId, 0, 0); + + when(subscriptionQueryService.stateFor(userId)) + .thenReturn(new UserSubscriptionState(sub, balance)); + + TokenDecision decision = new RejectedInsufficientTokens(TokenOperation.DETECT, 1, 0); + when(reservationPolicy.decide(any(), any(), eq(TokenOperation.DETECT), eq(fixedInstant))) + .thenReturn(decision); + + TokenDecision result = service.reserve(userId, command); + + assertTrue(result instanceof RejectedInsufficientTokens); + verify(tokenBalanceRepository, never()).save(any()); + verify(tokenReservationRepository, never()).save(any()); + } + + @Test + void shouldConsumeReservationSuccessfully() { + String userId = "user123"; + UUID resId = UUID.randomUUID(); + TokenReservation reservation = + new TokenReservation( + resId, userId, TokenOperation.DETECT, 5, fixedInstant.plusSeconds(3600)); + TokenReservationEntity entity = + TokenReservationEntity.from(reservation, fixedInstant, "extOp1"); + + when(tokenReservationRepository.findById(resId)).thenReturn(Optional.of(entity)); + when(tokenBalanceRepository.findById(userId)) + .thenReturn(Optional.of(TokenBalanceEntity.from(new TokenBalance(userId, 100, 5)))); + + service.consume(userId, resId); + + assertEquals(TokenReservationStatus.CONSUMED, entity.getStatus()); + verify(tokenBalanceRepository).save(any()); + verify(tokenReservationRepository).save(any()); + } + + @Test + void shouldReleaseReservationSuccessfully() { + String userId = "user123"; + UUID resId = UUID.randomUUID(); + TokenReservation reservation = + new TokenReservation( + resId, userId, TokenOperation.DETECT, 5, fixedInstant.plusSeconds(3600)); + TokenReservationEntity entity = + TokenReservationEntity.from(reservation, fixedInstant, "extOp1"); + + when(tokenReservationRepository.findById(resId)).thenReturn(Optional.of(entity)); + when(tokenBalanceRepository.findById(userId)) + .thenReturn(Optional.of(TokenBalanceEntity.from(new TokenBalance(userId, 100, 5)))); + + service.release(userId, resId); + + assertEquals(TokenReservationStatus.RELEASED, entity.getStatus()); + verify(tokenBalanceRepository).save(any()); + verify(tokenReservationRepository).save(any()); + } + + @Test + void shouldThrowExceptionWhenConsumingNonOwnedReservation() { + String userId = "user123"; + UUID resId = UUID.randomUUID(); + TokenReservation reservation = + new TokenReservation( + resId, "otherUser", TokenOperation.DETECT, 5, fixedInstant.plusSeconds(3600)); + TokenReservationEntity entity = + TokenReservationEntity.from(reservation, fixedInstant, "extOp1"); + + when(tokenReservationRepository.findById(resId)).thenReturn(Optional.of(entity)); + + assertThrows(IllegalArgumentException.class, () -> service.consume(userId, resId)); + } + + @Test + void shouldThrowExceptionWhenConsumingAlreadyConsumed() { + String userId = "user123"; + UUID resId = UUID.randomUUID(); + TokenReservation reservation = + new TokenReservation( + resId, userId, TokenOperation.DETECT, 5, fixedInstant.plusSeconds(3600)); + TokenReservationEntity entity = + TokenReservationEntity.from(reservation, fixedInstant, "extOp1"); + entity.markConsumed(fixedInstant); + + when(tokenReservationRepository.findById(resId)).thenReturn(Optional.of(entity)); + + assertThrows(IllegalArgumentException.class, () -> service.consume(userId, resId)); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/architecture/ArchitectureTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/architecture/ArchitectureTest.java new file mode 100644 index 0000000..a108bcb --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/architecture/ArchitectureTest.java @@ -0,0 +1,67 @@ +package pl.zzpj.subscription_service.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RestController; + +/** + * ArchUnit architecture rules scoped to the {@code pl.zzpj.subscription_service} package. + */ +class ArchitectureTest { + + private static final String BASE_PACKAGE = "pl.zzpj.subscription_service"; + + private static JavaClasses importedClasses; + + @BeforeAll + static void importClasses() { + importedClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(BASE_PACKAGE); + } + + @Test + void restControllersShouldBeNamedController() { + ArchRule rule = classes() + .that() + .areAnnotatedWith(RestController.class) + .should() + .haveSimpleNameEndingWith("Controller") + .allowEmptyShould(true); + + rule.check(importedClasses); + } + + @Test + void controllerLayerShouldNotBeAccessedByApplicationOrDomain() { + ArchRule rule = noClasses() + .that() + .resideInAnyPackage(BASE_PACKAGE + ".application..", BASE_PACKAGE + ".domain..") + .should() + .accessClassesThat() + .resideInAPackage(BASE_PACKAGE + ".controller..") + .allowEmptyShould(true); + + rule.check(importedClasses); + } + + @Test + void noClassesShouldUseStandardStreamsForLogging() { + ArchRule rule = noClasses() + .should() + .accessField(System.class, "out") + .orShould() + .accessField(System.class, "err") + .because("use SLF4J for logging instead of System.out / System.err") + .allowEmptyShould(true); + + rule.check(importedClasses); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusContractBase.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusContractBase.java new file mode 100644 index 0000000..33e005b --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusContractBase.java @@ -0,0 +1,21 @@ +package pl.zzpj.subscription_service.contract; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import org.junit.jupiter.api.BeforeEach; +import pl.zzpj.subscription_service.controller.SubscriptionStatusController; + +/** + * Base class for the Spring Cloud Contract generated provider tests. + * + *

Sets up {@link RestAssuredMockMvc} in standalone mode against the real + * {@link SubscriptionStatusController}. The {@code /api/subscriptions/status} endpoint is + * whitelisted (permitAll) and DB-free, so no full Spring context, security filter chain or + * database is required for the generated tests to pass. + */ +public abstract class SubscriptionStatusContractBase { + + @BeforeEach + void setUp() { + RestAssuredMockMvc.standaloneSetup(new SubscriptionStatusController()); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusStubRunnerTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusStubRunnerTest.java new file mode 100644 index 0000000..0406ed8 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/contract/SubscriptionStatusStubRunnerTest.java @@ -0,0 +1,50 @@ +package pl.zzpj.subscription_service.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.stubrunner.StubFinder; +import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; +import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties; +import org.springframework.web.client.RestClient; + +/** + * Consumer-side contract test. + * + *

Boots WireMock from the stub jar generated locally by the {@code verifierStubsJar} task + * (StubsMode.LOCAL) and verifies that a consumer can call {@code /api/subscriptions/status} + * through the stub and receive the contracted {@code ServiceStatus} response. This proves the + * provider contract and the consumer's expectations stay in sync without a running provider. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@AutoConfigureStubRunner( + ids = "pl.zzpj:subscription-service:+:stubs", + stubsMode = StubRunnerProperties.StubsMode.LOCAL +) +class SubscriptionStatusStubRunnerTest { + + @Autowired + private StubFinder stubFinder; + + @Test + void consumerCanReadServiceStatusThroughStub() { + String baseUrl = stubFinder + .findStubUrl("pl.zzpj", "subscription-service") + .toString(); + + ServiceStatusResponse response = RestClient.create() + .get() + .uri(baseUrl + "/api/subscriptions/status") + .retrieve() + .body(ServiceStatusResponse.class); + + assertThat(response).isNotNull(); + assertThat(response.service()).isEqualTo("subscription-service"); + assertThat(response.status()).isEqualTo("UP"); + } + + record ServiceStatusResponse(String service, String status) { + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicyTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicyTest.java new file mode 100644 index 0000000..5091ccb --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/domain/token/TokenReservationPolicyTest.java @@ -0,0 +1,36 @@ +package pl.zzpj.subscription_service.domain.token; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import pl.zzpj.subscription_service.domain.pricing.PricingCatalog; +import pl.zzpj.subscription_service.domain.subscription.ActiveSubscription; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionCatalog; +import pl.zzpj.subscription_service.domain.subscription.SubscriptionPlan; +import pl.zzpj.subscription_service.domain.token.decision.RejectedSubscriptionExpired; + +class TokenReservationPolicyTest { + + @Test + void shouldRejectNewReservationForExpiredSubscription() { + Instant now = Instant.parse("2026-06-18T12:00:00Z"); + SubscriptionPlan pro = new SubscriptionPlan(PlanCode.PRO, 2500, Set.of(TokenOperation.DETECT)); + TokenReservationPolicy policy = + new TokenReservationPolicy( + new SubscriptionCatalog(Map.of(PlanCode.PRO, pro)), + new PricingCatalog(Map.of(TokenOperation.DETECT, 1))); + ActiveSubscription expiredSubscription = + new ActiveSubscription( + "user123", PlanCode.PRO, now.minusSeconds(2_000_000), now.minusSeconds(1)); + + var decision = + policy.decide( + expiredSubscription, new TokenBalance("user123", 100, 1), TokenOperation.DETECT, now); + + assertInstanceOf(RejectedSubscriptionExpired.class, decision); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProviderTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProviderTest.java new file mode 100644 index 0000000..e057a74 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/infrastructure/payment/MockPaymentProviderTest.java @@ -0,0 +1,64 @@ +package pl.zzpj.subscription_service.infrastructure.payment; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import pl.zzpj.subscription_service.domain.payment.PaymentOutcome; +import pl.zzpj.subscription_service.domain.payment.PaymentSession; +import pl.zzpj.subscription_service.domain.subscription.PlanCode; +import pl.zzpj.subscription_service.persistence.entity.PaymentSessionEntity; +import pl.zzpj.subscription_service.persistence.repository.PaymentSessionRepository; + +@ExtendWith(MockitoExtension.class) +class MockPaymentProviderTest { + + @Mock private PaymentSessionRepository repository; + + @Test + void shouldReturnExistingResultForRepeatedSuccessfulCompletion() { + UUID sessionId = UUID.randomUUID(); + PaymentSession succeededSession = + session(sessionId, "user123", PaymentSession.Status.SUCCEEDED); + when(repository.findByIdForUpdate(sessionId)) + .thenReturn(Optional.of(PaymentSessionEntity.from(succeededSession))); + MockPaymentProvider provider = new MockPaymentProvider(repository); + + var completion = + provider.completeSession( + sessionId, "user123", new PaymentOutcome.Succeeded("another-request-id")); + + assertFalse(completion.completedNow()); + verify(repository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void shouldRejectCompletionByAnotherUser() { + UUID sessionId = UUID.randomUUID(); + PaymentSession pendingSession = session(sessionId, "owner", PaymentSession.Status.PENDING); + when(repository.findByIdForUpdate(sessionId)) + .thenReturn(Optional.of(PaymentSessionEntity.from(pendingSession))); + MockPaymentProvider provider = new MockPaymentProvider(repository); + + assertThrows( + IllegalArgumentException.class, + () -> + provider.completeSession( + sessionId, "other-user", new PaymentOutcome.Succeeded("transaction"))); + verify(repository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + private PaymentSession session(UUID sessionId, String userId, PaymentSession.Status status) { + Instant now = Instant.parse("2026-06-18T12:00:00Z"); + return new PaymentSession(sessionId, userId, PlanCode.PRO, status, now.minusSeconds(10), now); + } +} diff --git a/subscription-service/src/test/java/pl/zzpj/subscription_service/security/SubscriptionQueryControllerSecurityTest.java b/subscription-service/src/test/java/pl/zzpj/subscription_service/security/SubscriptionQueryControllerSecurityTest.java new file mode 100644 index 0000000..e29f316 --- /dev/null +++ b/subscription-service/src/test/java/pl/zzpj/subscription_service/security/SubscriptionQueryControllerSecurityTest.java @@ -0,0 +1,110 @@ +package pl.zzpj.subscription_service.security; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import pl.zzpj.subscription_service.application.SubscriptionQueryService; +import pl.zzpj.subscription_service.application.UserIdentityResolver; +import pl.zzpj.subscription_service.client.AuthClient; +import pl.zzpj.subscription_service.controller.SubscriptionQueryController; + +/** + * Security slice test for a SECURED (non-whitelisted) endpoint: GET /api/subscriptions/plans. + * + *

Wires the real {@link SecurityConfig} + {@link JwtFilter} so the actual auth behavior is + * exercised. {@link AuthClient} and the controller's service collaborators are mocked. + * + *

DEVIATION FROM ISSUE #19: the issue mentions a 403 for expired/tampered tokens and (loosely) + * a 401 for unauthenticated access. This service has NO role model. The REAL behavior is: + *

    + *
  • NO Authorization header -> Spring Security has no custom authentication entry point on this + * STATELESS chain, so the default kicks in and returns 403 (not 401).
  • + *
  • Header present but {@link AuthClient#validateToken} is false (tampered/expired) -> + * {@link JwtFilter} writes 401 "Invalid or expired token" (never 403).
  • + *
  • Valid token -> filter authenticates with EMPTY authorities and the request proceeds (200).
  • + *
+ * We assert the REAL behavior, not a fabricated 403-for-tampered. + */ +@WebMvcTest(SubscriptionQueryController.class) +@Import({SecurityConfig.class, JwtFilter.class}) +class SubscriptionQueryControllerSecurityTest { + + // A token whose 2nd ('.'-separated) segment is base64url-encoded JSON containing a numeric + // userId, as required by JwtFilter#extractPrincipalFromToken. Header/signature are irrelevant + // because validateToken is mocked. + private static final String VALID_TOKEN = buildToken("{\"userId\":42}"); + private static final String TAMPERED_TOKEN = "tampered.jwt.token"; + + private static String buildToken(String payloadJson) { + String header = base64Url("{\"alg\":\"HS256\"}"); + String payload = base64Url(payloadJson); + return header + "." + payload + ".signature"; + } + + private static String base64Url(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthClient authClient; + + @MockitoBean + private SubscriptionQueryService subscriptionQueryService; + + @MockitoBean + private UserIdentityResolver userIdentityResolver; + + @Test + void shouldRejectWhenNoAuthorizationHeader() throws Exception { + // No header -> JwtFilter passes through (no Bearer), then Spring Security rejects the + // unauthenticated request. With no custom authentication entry point on this STATELESS + // chain the default applies and the status is 403 (the issue loosely says 401; the REAL + // behavior of this SecurityConfig is 403 — documented deviation). + mockMvc + .perform(get("/api/subscriptions/plans")) + .andExpect(status().isForbidden()); + } + + @Test + void shouldReturn401WhenTokenIsInvalidOrTampered() throws Exception { + // Header present but authClient says the token is invalid -> JwtFilter writes 401. + when(authClient.validateToken(TAMPERED_TOKEN)).thenReturn(false); + + mockMvc + .perform( + get("/api/subscriptions/plans") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + TAMPERED_TOKEN) + ) + .andExpect(status().isUnauthorized()); + } + + @Test + void shouldReturn200WhenTokenIsValid() throws Exception { + // Valid token -> JwtFilter authenticates (empty authorities) and the request proceeds. + when(authClient.validateToken(VALID_TOKEN)).thenReturn(true); + when(subscriptionQueryService.availablePlans()).thenReturn(List.of()); + + mockMvc + .perform( + get("/api/subscriptions/plans") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + VALID_TOKEN) + ) + .andExpect(status().isOk()); + } +} diff --git a/subscription-service/src/test/resources/application.yaml b/subscription-service/src/test/resources/application.yaml new file mode 100644 index 0000000..47b9d51 --- /dev/null +++ b/subscription-service/src/test/resources/application.yaml @@ -0,0 +1,54 @@ +spring: + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:subscription_service_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: validate + flyway: + enabled: true + +eureka: + client: + enabled: false + +subscription: + plans: + free: + monthly-tokens: 50 + allowed-operations: + - CAPACITY_CHECK + - DETECT + - EMBED_768 + standard: + monthly-tokens: 500 + allowed-operations: + - CAPACITY_CHECK + - DETECT + - EXTRACT + - VISUALIZE + - EMBED_768 + - EMBED_1024 + pro: + monthly-tokens: 2500 + allowed-operations: + - CAPACITY_CHECK + - DETECT + - EXTRACT + - VISUALIZE + - EMBED_768 + - EMBED_1024 + - AI_CLASSIFICATION + token-costs: + CAPACITY_CHECK: 0 + DETECT: 1 + EXTRACT: 2 + VISUALIZE: 3 + EMBED_768: 5 + EMBED_1024: 8 + AI_CLASSIFICATION: 2 diff --git a/watermark-service-py/.dockerignore b/watermark-service-py/.dockerignore new file mode 100644 index 0000000..7600396 --- /dev/null +++ b/watermark-service-py/.dockerignore @@ -0,0 +1,15 @@ +.venv/ +venv/ +test_env/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ +tests/ +*.md +.git/ +.gitignore diff --git a/watermark-service-py/Dockerfile b/watermark-service-py/Dockerfile new file mode 100644 index 0000000..6fde6a8 --- /dev/null +++ b/watermark-service-py/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements-runtime.txt . +# `--no-deps invisible-watermark` skips the upstream torch dep so we don't pull +# both the CPU-only torch wheel (above) AND torch GPU build. PyWavelets and +# opencv-python-headless (the other transitives) are pinned in requirements-runtime.txt. +RUN pip install --no-cache-dir -r requirements-runtime.txt \ + && pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu torch==2.5.1+cpu \ + && pip install --no-cache-dir --no-deps invisible-watermark==0.2.0 + +COPY app/ ./app/ + +# Drop root: uvicorn binds the unprivileged port 8082, so no need for CAP_NET_BIND. +RUN useradd --create-home --uid 10001 app && chown -R app:app /app +USER app + +EXPOSE 8082 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8082/health', timeout=3).read()" || exit 1 + +ENTRYPOINT [] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/watermark-service-py/README.md b/watermark-service-py/README.md new file mode 100644 index 0000000..1cdbc3f --- /dev/null +++ b/watermark-service-py/README.md @@ -0,0 +1,116 @@ +# watermark-service (Python) + +Production replacement for the Java `watermark-service`. Uses [`invisible-watermark`](https://github.com/ShieldMnt/invisible-watermark) (DwtDctSvd mode) for the actual frequency-domain embedding, with an AES-GCM crypto layer on top so the payload is **encrypted and authenticated** before it ever reaches the algorithm. + +## Run (in compose) + +``` +docker compose up -d --build watermark-service +``` + +Eureka name: `WATERMARK-SERVICE`. Port: `8082`. Drop-in for the (now removed) Java service — clients (GUI, Feign) need no changes. + +## Endpoints + +| Method | Path | Auth | Notes | +|---|---|---|---| +| POST | `/api/watermark/embed` | JWT | multipart `image`+`text`, returns PNG body + `X-Image-Category/Label/Confidence` + `X-Max-Text-Bytes` headers | +| POST | `/api/watermark/detect` | JWT | returns `{"watermarked", "ownerIdentity", "version"}` | +| POST | `/api/watermark/extract` | JWT | returns `{"ownerIdentity", "text"}`; 403 on owner mismatch | +| POST | `/api/watermark/visualize` | JWT | returns heatmap PNG of pixel diff from sentinel embed | +| POST | `/api/watermark/capacity` | JWT | returns `{"maxTextBytes", "minImageWidth", "minImageHeight", "imageWidth", "imageHeight", "imageOk"}` | +| GET | `/health` | none | `{"status":"UP"}` | +| GET | `/docs` | none | Swagger UI | + +## User contract: PNG in, PNG out, do not recompress + +The watermark hides bits in DCT coefficients. **JPEG compression also operates on +DCT** and aggressively rounds the same coefficients the watermark lives in, so any +JPEG re-encode (even Q=95) likely destroys it; Q=50 always destroys it. Same for +screenshots, resizes, filters, or anything that passes through a social-network +re-encoder (Discord, Twitter, Instagram). + +What the end-user MUST do for the watermark to survive: + +- **Upload PNG** to `/embed`. JPG input is accepted (the algorithm decodes it) but + the watermark has less headroom because the source already lost DCT detail. +- **Download the result** — `/embed` always returns `Content-Type: image/png`. + The GUI saves it as `watermarked_image.png`. +- **Distribute that exact file** without re-saving, re-compressing, screenshotting, + resizing, or uploading anywhere that re-encodes. Keep it PNG, byte-for-byte. + +This is a fundamental limitation of the frequency-domain watermarking family; the +Java implementation has the same constraint. The use case is leak-tracing for +copies that move untouched (recipient forwards a file as-is) — not DRM against an +adversary willing to run `convert -quality 50`. + +The GUI shows an orange warning on the embed page and on the result card; the +input filter on the embed tab restricts to `accept="image/png"`. Detect/Extract/ +Visualize keep `image/png, image/jpeg` so users can confirm "yes this JPG copy +lost the watermark" rather than getting a silent file-picker rejection. + +## Security model + +- Payload is `owner|text` UTF-8, **encrypted with AES-256-GCM** before embedding. +- Encryption key is derived from `WATERMARK_APP_KEY` (server secret) via SHA-256. Without it nobody can read or forge watermarks. +- Authentication tag (GCM 128-bit) means tampered or third-party watermarks fail to decrypt and `detect` returns `watermarked=false` — effectively zero false positives. +- Magic bytes `WMPY` + version byte at envelope head allow fast format rejection and forward-compatible upgrades. +- **Reed-Solomon ECC** (16 parity bytes, corrects up to 8 byte errors) wraps the encrypted envelope so dwtDctSvd's occasional bit-flips on borderline-sized images don't fail the GCM tag. +- Wire format: `[ MAGIC(4) | VERSION(1) | NONCE(12) | CIPHERTEXT_AND_TAG(n) ] + ECC_PARITY(16)`. Overhead: **49 bytes**. + +## Adaptive capacity + +The service picks the largest watermark capacity an image can carry. Two tiers: + +| Image min side | `length_bits` | Total bytes | Usable text (after envelope + `owner-id|`) | +|---|---|---|---| +| **≥1600 px** | 1024 | 128 | **~71 chars** | +| **≥1200 px** | 768 | 96 | **~39 chars** | +| <1200 px | — | — | rejected | + +Tiers were calibrated empirically against random-pixel images (worst case for dwtDctSvd); real photographs typically work at slightly smaller dimensions but the floors above are guaranteed-safe. + +- `POST /api/watermark/capacity` reports the picked tier + character budget for any uploaded image. +- `/embed` response includes `X-Max-Text-Bytes` and `X-Watermark-Length-Bits` headers. +- `/detect` tries each known length until one decrypts → no need to remember which tier was used. +- The GUI uses `/capacity` for a live byte-counter near the text input and disables submit for too-small images. + +## Configuration + +Configuration flows from three sources, in priority order: + +1. **Process environment variables** (set by docker-compose or operator) +2. **Spring Cloud Config Server** (`http://config-server:8888/watermark-service/default` + `/application/default`) +3. **Defaults** declared in `app/config.py` + +The service polls config-server at startup with exponential backoff (1s → 16s) and degrades gracefully to env+defaults if config-server is unreachable. + +### Env vars + +| Var | Default | Purpose | +|---|---|---| +| `CONFIG_SERVER_URL` | `http://config-server:8888` | Spring Cloud Config Server URL (set empty to disable) | +| `EUREKA_URL` | `http://eureka-server:8761/eureka/` | Eureka registry | +| `AUTH_SERVER_URL` | `http://auth-server:8081` | Token validation | +| `AI_SERVICE_URL` | `http://ai-service:8084` | Classification call from `/embed` | +| `WATERMARK_APP_KEY` | `local-dev-watermark-secret` | **REQUIRED in production.** Master secret for crypto. Rotate ⇒ all prior watermarks become unreadable. | +| `INSTANCE_HOSTNAME` | `watermark-service` | Hostname registered with Eureka | +| `LOG_LEVEL` | `INFO` | Root logger level | + +## Local tests + +``` +cd watermark-service-py +python3 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +pytest -v +``` + +`.venv/` is excluded from the Docker build context via `.dockerignore` — without it the local torch wheels (~5 GB) get copied into the image and overflow disk. + +## Roll back to Java backend + +``` +git checkout main +docker compose up -d --build watermark-service +``` diff --git a/intro-intellij/grupy/README.md b/watermark-service-py/app/__init__.py similarity index 100% rename from intro-intellij/grupy/README.md rename to watermark-service-py/app/__init__.py diff --git a/watermark-service-py/app/ai_client.py b/watermark-service-py/app/ai_client.py new file mode 100644 index 0000000..c0929b9 --- /dev/null +++ b/watermark-service-py/app/ai_client.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import httpx + +from app.config import Settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ClassificationResult: + category: str + label: str + confidence: float + category_confidence: float + + +_UNKNOWN = ClassificationResult( + category="unknown", label="unknown", confidence=0.0, category_confidence=0.0 +) + + +async def classify_or_fallback( + settings: Settings, + *, + image_bytes: bytes, + filename: str | None, + content_type: str | None, + bearer_token: str | None, +) -> ClassificationResult: + """Call ai-service for classification; on any failure return _UNKNOWN. + + Async + split connect/read timeouts so a slow ai-service can't park the + event loop. The watermark embed continues regardless of classification. + """ + headers: dict[str, str] = {} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + files = { + "file": (filename or "image", image_bytes, content_type or "application/octet-stream"), + } + timeout = httpx.Timeout(connect=2.0, read=15.0, write=15.0, pool=2.0) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + f"{settings.ai_service_url}/api/classify", + files=files, + headers=headers, + ) + if response.status_code != 200: + logger.warning( + "ai-service returned %s body=%s", + response.status_code, response.text[:300], + ) + return _UNKNOWN + body = response.json() + return ClassificationResult( + category=str(body.get("category", "unknown")), + label=str(body.get("label", "unknown")), + confidence=float(body.get("confidence", 0.0)), + category_confidence=float(body.get("categoryConfidence", 0.0)), + ) + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + logger.warning("ai-service classification failed: %s", exc) + return _UNKNOWN diff --git a/watermark-service-py/app/auth.py b/watermark-service-py/app/auth.py new file mode 100644 index 0000000..15edac5 --- /dev/null +++ b/watermark-service-py/app/auth.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import logging +import re + +import httpx +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.config import Settings + +logger = logging.getLogger(__name__) + +_bearer = HTTPBearer(auto_error=False) + +# A principal flows verbatim into the watermark envelope as the owner identity. +# Reject anything that could break the `owner|text` framing, log injection, +# or balloon an embedded payload. ASCII identifier characters only. +_PRINCIPAL_PATTERN = re.compile(r"^[A-Za-z0-9._@-]{1,64}$") +_DEFAULT_PRINCIPAL = "User" + + +def _401() -> HTTPException: + return HTTPException(status_code=401, detail={"error": "Invalid or expired token"}) + + +def _503() -> HTTPException: + return HTTPException( + status_code=503, + detail={"error": "Authentication service is currently unavailable"}, + ) + + +def _decode_principal(token: str) -> str: + """Mirror Java extractPrincipalFromToken: '{sub}-{userId}', fallback 'User'. + + The auth-server already validated the signature; we only re-parse the body + to extract the identifier. Whatever we return goes into the watermark + envelope, so it MUST pass `_PRINCIPAL_PATTERN` — anything else falls back + to the default principal rather than being embedded verbatim. + """ + try: + parts = token.split(".") + if len(parts) < 2: + return _DEFAULT_PRINCIPAL + payload_segment = parts[1] + "=" * (-len(parts[1]) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_segment)) + sub = payload.get("sub") + user_id = payload.get("userId") + if isinstance(sub, str) and user_id is not None: + candidate = f"{sub}-{user_id}" + if _PRINCIPAL_PATTERN.fullmatch(candidate): + return candidate + logger.warning("JWT principal failed sanitization: %r", candidate) + except (ValueError, json.JSONDecodeError, binascii.Error) as exc: + logger.debug("Could not parse JWT payload: %s", exc) + return _DEFAULT_PRINCIPAL + + +def require_principal( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> str: + if credentials is None or not credentials.credentials: + raise _401() + + settings: Settings = request.app.state.settings + token = credentials.credentials + + try: + with httpx.Client(timeout=httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=2.0)) as client: + response = client.post( + f"{settings.auth_server_url}/auth/validate", + params={"token": token}, + ) + except httpx.HTTPError as exc: + logger.error("auth-server unreachable: %s", exc) + raise _503() + + if response.status_code != 200: + raise _401() + try: + body = response.json() + except ValueError: + raise _401() + # Strict identity — anything other than literal JSON `true` is a rejection, + # so a future auth-server schema change (e.g. `{"valid": true}`) fails closed + # instead of silently accepting unrelated truthy bodies. + if body is not True: + raise _401() + + return _decode_principal(token) diff --git a/watermark-service-py/app/config.py b/watermark-service-py/app/config.py new file mode 100644 index 0000000..d5c97e2 --- /dev/null +++ b/watermark-service-py/app/config.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import logging +import os +import time + +import httpx +from pydantic_settings import BaseSettings, SettingsConfigDict + +logger = logging.getLogger(__name__) + +APPLICATION_NAME = "watermark-service" + +# Public-knowledge fallback used in dev/compose. Refusing to boot prod with this +# value keeps a misconfigured deploy from producing forgeable watermarks. +DEFAULT_DEV_APP_KEY = "local-dev-watermark-secret" + + +class InsecureDefaultAppKeyError(RuntimeError): + """Raised when WATERMARK_APP_KEY is the public dev default and dev-mode is off.""" + + +class Settings(BaseSettings): + """Runtime configuration. + + Precedence (highest first): + 1. Process environment variables + 2. Properties merged from Spring Cloud Config Server (application + watermark-service) + 3. Defaults declared below + """ + + config_server_url: str = "http://config-server:8888" + eureka_url: str = "http://eureka-server:8761/eureka/" + auth_server_url: str = "http://auth-server:8081" + ai_service_url: str = "http://ai-service:8084" + subscription_service_url: str = "http://subscription-service:8085" + watermark_app_key: str = DEFAULT_DEV_APP_KEY + log_level: str = "INFO" + instance_hostname: str = "watermark-service" + + model_config = SettingsConfigDict(env_file=None, case_sensitive=False) + + +# Spring's flat property keys → our snake_case Settings field names. +_PROPERTY_KEY_MAP: dict[str, str] = { + "eureka.client.serviceurl.defaultzone": "eureka_url", + "watermark.app-key": "watermark_app_key", + "auth-server.url": "auth_server_url", + "ai-service.url": "ai_service_url", + "subscription-service.url": "subscription_service_url", + "logging.level.root": "log_level", +} + +_RETRY_DELAYS_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0, 16.0) + + +def _fetch_property_sources(config_server_url: str, profile: str = "default") -> dict[str, str]: + """Pull merged properties for `application` + this service from config-server. + + Returns flattened {dotted.key: stringified_value}. On any failure returns {} — + we want the service to keep booting on legacy defaults rather than crash-loop + when config-server is reachable-but-broken or genuinely down. + """ + base = config_server_url.rstrip("/") + paths = [f"/application/{profile}", f"/{APPLICATION_NAME}/{profile}"] + + merged: dict[str, str] = {} + for path in paths: + url = f"{base}{path}" + body = _fetch_with_retry(url) + if body is None: + # One profile being unreachable shouldn't lose properties from the + # other — keep merging what we got. + continue + for source in reversed(body.get("propertySources", [])): + for key, value in (source.get("source") or {}).items(): + merged[key.lower()] = str(value) + return merged + + +def _fetch_with_retry(url: str) -> dict | None: + last_error: Exception | None = None + for delay in _RETRY_DELAYS_S: + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(url) + if response.status_code == 200: + return response.json() + last_error = RuntimeError(f"HTTP {response.status_code}") + except httpx.HTTPError as exc: + last_error = exc + logger.info("config-server %s not ready (%s); retrying in %.0fs", url, last_error, delay) + time.sleep(delay) + logger.warning("Giving up on config-server %s: %s", url, last_error) + return None + + +_MAX_PLACEHOLDER_PASSES = 16 + + +def _resolve_property(raw: str) -> str: + """Spring config values often contain ${VAR:default} placeholders. Resolve them + against the process environment so e.g. ${EUREKA_URL:...} respects the same env + var the docker-compose file sets. + + Cap iteration count to defang cyclic placeholders (${A} where A=${A}). + """ + if "${" not in raw: + return raw + result = raw + for _ in range(_MAX_PLACEHOLDER_PASSES): + if "${" not in result: + return result + start = result.index("${") + end = result.find("}", start) + if end == -1: + break + token = result[start + 2 : end] + name, _, default = token.partition(":") + value = os.environ.get(name, default) + result = result[:start] + value + result[end + 1 :] + logger.warning("Placeholder resolution gave up after %d passes: %r", _MAX_PLACEHOLDER_PASSES, raw) + return result + + +def _apply_config_overrides(properties: dict[str, str]) -> None: + """Project relevant Spring properties onto our env BEFORE Settings reads them, + so env-var precedence (set by the operator) naturally wins.""" + for spring_key, settings_field in _PROPERTY_KEY_MAP.items(): + if spring_key not in properties: + continue + env_name = settings_field.upper() + if env_name in os.environ: + continue # operator-provided env wins over config-server + os.environ[env_name] = _resolve_property(properties[spring_key]) + + +def get_settings() -> Settings: + """Bootstrap: fetch from config-server (if configured) then build Settings.""" + config_server_url = os.environ.get("CONFIG_SERVER_URL", "http://config-server:8888") + if config_server_url: + properties = _fetch_property_sources(config_server_url) + if properties: + _apply_config_overrides(properties) + logger.info( + "Loaded %d properties from config-server %s", + len(properties), config_server_url, + ) + settings = Settings() + _enforce_app_key_safety(settings) + return settings + + +def _enforce_app_key_safety(settings: Settings) -> None: + if settings.watermark_app_key != DEFAULT_DEV_APP_KEY: + return + if os.environ.get("WATERMARK_DEV_MODE", "").lower() == "true": + logger.warning( + "WATERMARK_APP_KEY is the public dev default. " + "Running because WATERMARK_DEV_MODE=true — do NOT use in production." + ) + return + raise InsecureDefaultAppKeyError( + "WATERMARK_APP_KEY is set to the public dev default. " + "Set a real secret via env or config-server, or set WATERMARK_DEV_MODE=true " + "to acknowledge the risk in a development environment." + ) diff --git a/watermark-service-py/app/crypto.py b/watermark-service-py/app/crypto.py new file mode 100644 index 0000000..a32d782 --- /dev/null +++ b/watermark-service-py/app/crypto.py @@ -0,0 +1,110 @@ +"""AES-GCM envelope + Reed-Solomon ECC for watermark payloads. + +Pipeline at embed time: + 1. Build plaintext: f"{owner}|{text}".encode("utf-8") + 2. Encrypt with AES-256-GCM (key derived from WATERMARK_APP_KEY) + 3. Prepend magic+version+nonce → "envelope" + 4. Append Reed-Solomon parity bytes → "ECC-protected envelope" + 5. Pad with NUL up to length_bits/8 bytes + 6. Hand off to invisible-watermark library + +Reverse on detect. The ECC layer recovers from up to ECC_PARITY_BYTES/2 byte errors +introduced by the frequency-domain watermarking (which can flip ~0–3 bits on +borderline-size images — without ECC the GCM tag would reject everything). + +Wire format (innermost first): + plaintext = owner | '|' | text (UTF-8) + envelope = MAGIC(4) | VERSION(1) | NONCE(12) | CIPHERTEXT_AND_TAG(n) + protected = envelope | ECC_PARITY(16) + embedded = protected.ljust(length_bits // 8, b'\\x00') +""" +from __future__ import annotations + +import hashlib +import os +from dataclasses import dataclass + +import reedsolo +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +_MAGIC = b"WMPY" +_VERSION = b"\x02" # bumped from v1 (no ECC) to v2 (ECC-wrapped) +_NONCE_LEN = 12 +_TAG_LEN = 16 +HEADER_LEN = len(_MAGIC) + len(_VERSION) + _NONCE_LEN # 17 + +# Reed-Solomon parity bytes. Corrects up to ECC_PARITY_BYTES / 2 byte errors. +# Calibrated at 16 bytes (= 8 corrected byte errors) so even threshold-sized images +# with unlucky pixel distributions round-trip reliably across seeds. Lower values +# (8) leaked occasional failures at the 768-bit tier boundary. +ECC_PARITY_BYTES = 16 + +# Total non-plaintext overhead consumed per embedded watermark. +ENVELOPE_OVERHEAD = HEADER_LEN + _TAG_LEN + ECC_PARITY_BYTES # 17 + 16 + 16 = 49 bytes + + +class CryptoError(Exception): + """Raised when an envelope is malformed, ECC fails, or authentication fails.""" + + +@dataclass(frozen=True) +class DecodedEnvelope: + owner: str + text: str + + +_rs_codec = reedsolo.RSCodec(ECC_PARITY_BYTES) + + +def _derive_key(app_key: str) -> bytes: + if not app_key: + raise ValueError("WATERMARK_APP_KEY must not be empty") + return hashlib.sha256(("watermark-app:" + app_key).encode("utf-8")).digest() + + +def seal(owner: str, text: str, *, app_key: str) -> bytes: + if "|" in owner: + raise ValueError("owner identifier must not contain '|'") + plaintext = f"{owner}|{text}".encode("utf-8") + key = _derive_key(app_key) + nonce = os.urandom(_NONCE_LEN) + aad = _MAGIC + _VERSION + ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad) + envelope = _MAGIC + _VERSION + nonce + ciphertext + return bytes(_rs_codec.encode(envelope)) + + +def unseal(blob: bytes, *, app_key: str) -> DecodedEnvelope: + if len(blob) < HEADER_LEN + _TAG_LEN + ECC_PARITY_BYTES: + raise CryptoError("envelope too short") + try: + envelope_bytes, _, _ = _rs_codec.decode(blob) + except reedsolo.ReedSolomonError as exc: + raise CryptoError("ECC failed — too many corrupted bytes") from exc + envelope = bytes(envelope_bytes) + if envelope[: len(_MAGIC)] != _MAGIC: + raise CryptoError("magic mismatch") + if envelope[len(_MAGIC) : len(_MAGIC) + 1] != _VERSION: + raise CryptoError("unsupported envelope version") + nonce = envelope[len(_MAGIC) + 1 : HEADER_LEN] + ciphertext = envelope[HEADER_LEN:] + key = _derive_key(app_key) + aad = _MAGIC + _VERSION + try: + plaintext = AESGCM(key).decrypt(nonce, ciphertext, aad) + except InvalidTag as exc: + raise CryptoError("authentication failed — wrong key or tampered envelope") from exc + text = plaintext.decode("utf-8") + if "|" not in text: + raise CryptoError("envelope plaintext missing owner separator") + owner, _, body = text.partition("|") + if not owner: + raise CryptoError("envelope plaintext has empty owner") + return DecodedEnvelope(owner=owner, text=body) + + +def max_text_bytes(length_bits: int, owner: str) -> int: + """How many UTF-8 bytes of user text fit, given the watermark capacity and owner.""" + owner_bytes = len(owner.encode("utf-8")) + 1 # owner + '|' + return max(0, length_bits // 8 - ENVELOPE_OVERHEAD - owner_bytes) diff --git a/watermark-service-py/app/eureka.py b/watermark-service-py/app/eureka.py new file mode 100644 index 0000000..209a1cc --- /dev/null +++ b/watermark-service-py/app/eureka.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import logging +import socket + +from py_eureka_client import eureka_client + +from app.config import Settings + +logger = logging.getLogger(__name__) + +_INSTANCE_PORT = 8082 + + +async def register(settings: Settings) -> None: + if not settings.eureka_url: + logger.info("EUREKA_URL empty — skipping Eureka registration") + return + host = settings.instance_hostname or socket.gethostname() + base_url = f"http://{host}:{_INSTANCE_PORT}" + try: + await eureka_client.init_async( + eureka_server=settings.eureka_url, + app_name="WATERMARK-SERVICE", + instance_port=_INSTANCE_PORT, + instance_host=host, + health_check_url=f"{base_url}/health", + status_page_url=f"{base_url}/health", + renewal_interval_in_secs=30, + duration_in_secs=90, + ) + logger.info("Registered with Eureka at %s as WATERMARK-SERVICE", settings.eureka_url) + except Exception as exc: + logger.error("Eureka registration failed: %s", exc) + + +async def deregister() -> None: + try: + await eureka_client.stop_async() + logger.info("Deregistered from Eureka") + except Exception as exc: + logger.warning("Eureka deregister failed: %s", exc) diff --git a/watermark-service-py/app/main.py b/watermark-service-py/app/main.py new file mode 100644 index 0000000..af20895 --- /dev/null +++ b/watermark-service-py/app/main.py @@ -0,0 +1,53 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.config import Settings, get_settings +from app.eureka import deregister, register +from app.routes import router as watermark_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings: Settings = app.state.settings + await register(settings) + try: + yield + finally: + await deregister() + + +def create_app() -> FastAPI: + # Configure logging before get_settings so the config-server bootstrap is visible. + # Re-applied after settings load in case LOG_LEVEL differs from INFO. + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + settings = get_settings() + logging.getLogger().setLevel(getattr(logging, settings.log_level.upper(), logging.INFO)) + app = FastAPI( + title="watermark-service", + version="0.2.0-py", + lifespan=lifespan, + ) + app.state.settings = settings + + @app.exception_handler(HTTPException) + async def _http_exception_handler(request: Request, exc: HTTPException): + if isinstance(exc.detail, dict): + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + app.include_router(watermark_router) + + @app.get("/health") + def health(): + return {"status": "UP"} + + return app + + +app = create_app() diff --git a/watermark-service-py/app/routes.py b/watermark-service-py/app/routes.py new file mode 100644 index 0000000..dc46f0b --- /dev/null +++ b/watermark-service-py/app/routes.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import base64 +import binascii +import json +import logging +from typing import Annotated + +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Request, + UploadFile, +) +from fastapi.responses import Response + +from app.ai_client import ClassificationResult, classify_or_fallback +from app.auth import require_principal +from app.config import Settings +from app.subscription_client import ( + consume_reservation, + operation_for_length_bits, + release_reservation, + reserve_tokens, +) +from app.watermark import ( + capacity_report, + detect_text, + embed_text, + visualize, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/watermark", tags=["Watermark"]) + +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +# 25 MB matches the multipart limit in the config-server-served application.yaml. +_MAX_IMAGE_BYTES = 25 * 1024 * 1024 +_MAX_TEXT_BYTES = 4096 +_UNKNOWN_CLASSIFICATION = ClassificationResult( + category="unknown", + label="unknown", + confidence=0.0, + category_confidence=0.0, +) + + +def _settings(request: Request) -> Settings: + return request.app.state.settings + + +def _bearer_from_request(request: Request) -> str | None: + header = request.headers.get("authorization", "") + if header.lower().startswith("bearer "): + return header[7:] + return None + + +def _jwt_payload(token: str | None) -> dict: + if not token: + return {} + try: + parts = token.split(".") + if len(parts) < 2: + return {} + payload_segment = parts[1] + "=" * (-len(parts[1]) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_segment)) + return payload if isinstance(payload, dict) else {} + except (ValueError, json.JSONDecodeError, binascii.Error): + return {} + + +def _is_admin_token(token: str | None) -> bool: + payload = _jwt_payload(token) + role = payload.get("role") + if isinstance(role, str) and role.upper() == "ADMIN": + return True + return payload.get("sub") == "admin" and payload.get("userId") == 1 + + +def _read_png(image_bytes: bytes) -> None: + """Reject non-PNG uploads on the embed path so the GUI's PNG-only contract + is enforced at the server boundary, not just by the file-picker filter.""" + if len(image_bytes) > _MAX_IMAGE_BYTES: + raise HTTPException( + 413, detail=f"Image too large (max {_MAX_IMAGE_BYTES} bytes)" + ) + if not image_bytes.startswith(_PNG_MAGIC): + raise HTTPException( + 400, + detail="Embed accepts PNG only. JPG/WebP destroy the watermark on the very first re-encode.", + ) + + +async def _classify_when_entitled( + settings: Settings, + *, + image_bytes: bytes, + filename: str | None, + content_type: str | None, + bearer_token: str | None, +) -> ClassificationResult: + try: + reservation = await reserve_tokens( + settings, + operation="AI_CLASSIFICATION", + bearer_token=bearer_token, + external_operation_id=f"classify:{filename or 'image'}", + ) + except HTTPException as exc: + if exc.status_code in (403, 409): + logger.info("Skipping AI classification: %s", exc.detail) + return _UNKNOWN_CLASSIFICATION + raise + + try: + classification = await classify_or_fallback( + settings, + image_bytes=image_bytes, + filename=filename, + content_type=content_type, + bearer_token=bearer_token, + ) + except Exception: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise + + await consume_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + return classification + + +@router.post( + "/embed", + summary="Embed watermark", + description="Embeds a text watermark into a PNG image using the owner's identity.", +) +async def embed( + request: Request, + image: Annotated[UploadFile, File(...)], + text: Annotated[str, Form(...)], + principal: Annotated[str, Depends(require_principal)], +): + if not text or not text.strip(): + raise HTTPException(400, detail="text must not be blank") + if len(text.encode("utf-8")) > _MAX_TEXT_BYTES: + raise HTTPException( + 413, detail=f"Text too large (max {_MAX_TEXT_BYTES} bytes)" + ) + + settings = _settings(request) + image_bytes = await image.read() + _read_png(image_bytes) + + report = capacity_report(image_bytes, owner=principal) + if not report.image_ok: + raise HTTPException( + 400, + detail=( + f"Image too small ({report.image_width}x{report.image_height}). " + f"Minimum {report.min_image_width}x{report.min_image_height} for the smallest watermark tier." + ), + ) + if len(text.encode("utf-8")) > report.max_text_bytes: + raise HTTPException( + 400, + detail=f"Text too long: max {report.max_text_bytes} bytes for this image and owner.", + ) + + bearer_token = _bearer_from_request(request) + reservation = await reserve_tokens( + settings, + operation=operation_for_length_bits(report.length_bits), + bearer_token=bearer_token, + external_operation_id=f"embed:{principal}:{image.filename or 'image'}", + ) + + try: + classification = await _classify_when_entitled( + settings, + image_bytes=image_bytes, + filename=image.filename, + content_type=image.content_type, + bearer_token=bearer_token, + ) + result = embed_text( + image_bytes, + text, + owner=principal, + app_key=settings.watermark_app_key, + ) + except ValueError as exc: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise HTTPException(400, detail=str(exc)) + except RuntimeError as exc: + # embed verification failed at every tier — image content is + # pathological for the watermark; user can try a larger image. + logger.warning( + "Embed verification failed for principal=%s: %s", principal, exc + ) + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise HTTPException(422, detail=str(exc)) + except Exception: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise + + await consume_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + + return Response( + content=result.png_bytes, + media_type="image/png", + headers={ + "X-Image-Category": classification.category, + "X-Image-Label": classification.label, + "X-Image-Confidence": str(classification.confidence), + "X-Image-Category-Confidence": str( + classification.category_confidence + ), + "X-Max-Text-Bytes": str(report.max_text_bytes), + "X-Watermark-Length-Bits": str(report.length_bits), + }, + ) + + +@router.post( + "/detect", + summary="Detect watermark", + description="Checks if a PNG image contains a watermark and returns the owner identity if found.", +) +async def detect( + request: Request, + image: Annotated[UploadFile, File(...)], + principal: Annotated[str, Depends(require_principal)], +): + settings = _settings(request) + image_bytes = await image.read() + if len(image_bytes) > _MAX_IMAGE_BYTES: + raise HTTPException( + 413, detail=f"Image too large (max {_MAX_IMAGE_BYTES} bytes)" + ) + bearer_token = _bearer_from_request(request) + reservation = await reserve_tokens( + settings, operation="DETECT", bearer_token=bearer_token + ) + try: + detection = detect_text( + image_bytes, app_key=settings.watermark_app_key + ) + except Exception: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise + await consume_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + return { + "watermarked": detection.watermarked, + "ownerIdentity": detection.owner_identity, + "version": 1 if detection.watermarked else None, + "lengthBits": detection.length_bits, + } + + +@router.post( + "/extract", + summary="Extract watermark text", + description="Extracts the hidden text from a watermarked PNG image. Requires owner or admin permissions.", +) +async def extract( + request: Request, + image: Annotated[UploadFile, File(...)], + principal: Annotated[str, Depends(require_principal)], +): + settings = _settings(request) + image_bytes = await image.read() + if len(image_bytes) > _MAX_IMAGE_BYTES: + raise HTTPException( + 413, detail=f"Image too large (max {_MAX_IMAGE_BYTES} bytes)" + ) + bearer_token = _bearer_from_request(request) + reservation = await reserve_tokens( + settings, operation="EXTRACT", bearer_token=bearer_token + ) + try: + detection = detect_text( + image_bytes, app_key=settings.watermark_app_key + ) + if not detection.watermarked: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise HTTPException(400, detail="No watermark found in this image") + if detection.owner_identity != principal and not _is_admin_token( + bearer_token + ): + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise HTTPException( + 403, detail="Requester is not allowed to read this watermark" + ) + except HTTPException: + raise + except Exception: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise + await consume_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + return {"ownerIdentity": detection.owner_identity, "text": detection.text} + + +@router.post( + "/visualize", + summary="Visualize watermark", + description="Generates a heatmap showing the watermark distribution in the PNG image.", +) +async def visualize_endpoint( + request: Request, + image: Annotated[UploadFile, File(...)], + principal: Annotated[str, Depends(require_principal)], +): + settings = _settings(request) + image_bytes = await image.read() + if len(image_bytes) > _MAX_IMAGE_BYTES: + raise HTTPException( + 413, detail=f"Image too large (max {_MAX_IMAGE_BYTES} bytes)" + ) + bearer_token = _bearer_from_request(request) + reservation = await reserve_tokens( + settings, operation="VISUALIZE", bearer_token=bearer_token + ) + try: + heatmap = visualize(image_bytes, app_key=settings.watermark_app_key) + except ValueError as exc: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise HTTPException(400, detail=str(exc)) + except Exception: + await release_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + raise + await consume_reservation( + settings, + reservation_id=reservation.reservation_id, + bearer_token=bearer_token, + ) + return Response(content=heatmap, media_type="image/png") + + +@router.post( + "/capacity", + summary="Check watermark capacity", + description="Calculates the maximum text size that can be embedded into the provided image.", +) +async def capacity( + request: Request, + image: Annotated[UploadFile, File(...)], + principal: Annotated[str, Depends(require_principal)], +): + image_bytes = await image.read() + if len(image_bytes) > _MAX_IMAGE_BYTES: + raise HTTPException( + 413, detail=f"Image too large (max {_MAX_IMAGE_BYTES} bytes)" + ) + report = capacity_report(image_bytes, owner=principal) + return { + "maxTextBytes": report.max_text_bytes, + "minImageWidth": report.min_image_width, + "minImageHeight": report.min_image_height, + "imageWidth": report.image_width, + "imageHeight": report.image_height, + "imageOk": report.image_ok, + "lengthBits": report.length_bits, + } diff --git a/watermark-service-py/app/subscription_client.py b/watermark-service-py/app/subscription_client.py new file mode 100644 index 0000000..8c62fdf --- /dev/null +++ b/watermark-service-py/app/subscription_client.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +import httpx +from fastapi import HTTPException + +from app.config import Settings + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TokenReservation: + reservation_id: str + operation: str + tokens: int + + +def operation_for_length_bits(length_bits: int) -> str: + if length_bits == 768: + return "EMBED_768" + if length_bits == 1024: + return "EMBED_1024" + raise ValueError(f"Unsupported watermark length_bits: {length_bits}") + + +async def reserve_tokens( + settings: Settings, + *, + operation: str, + bearer_token: str | None, + external_operation_id: str | None = None, +) -> TokenReservation: + headers = _auth_headers(bearer_token) + body: dict[str, Any] = {"operation": operation} + if external_operation_id: + body["externalOperationId"] = external_operation_id + + response = await _post(settings, "/api/tokens/reservations", headers=headers, json=body) + if response.status_code == 201: + payload = response.json() + return TokenReservation( + reservation_id=str(payload["reservationId"]), + operation=str(payload["operation"]), + tokens=int(payload["tokens"]), + ) + _raise_reservation_error(response) + + +async def consume_reservation(settings: Settings, *, reservation_id: str, bearer_token: str | None) -> None: + response = await _post( + settings, + f"/api/tokens/reservations/{reservation_id}/consume", + headers=_auth_headers(bearer_token), + json=None, + ) + if response.status_code >= 400: + logger.warning("subscription-service consume failed: status=%s body=%s", response.status_code, response.text[:300]) + + +async def release_reservation(settings: Settings, *, reservation_id: str, bearer_token: str | None) -> None: + response = await _post( + settings, + f"/api/tokens/reservations/{reservation_id}/release", + headers=_auth_headers(bearer_token), + json=None, + ) + if response.status_code >= 400: + logger.warning("subscription-service release failed: status=%s body=%s", response.status_code, response.text[:300]) + + +async def charge_operation(settings: Settings, *, operation: str, bearer_token: str | None) -> TokenReservation: + reservation = await reserve_tokens(settings, operation=operation, bearer_token=bearer_token) + await consume_reservation(settings, reservation_id=reservation.reservation_id, bearer_token=bearer_token) + return reservation + + +def _auth_headers(bearer_token: str | None) -> dict[str, str]: + if not bearer_token: + return {} + return {"Authorization": f"Bearer {bearer_token}"} + + +async def _post( + settings: Settings, + path: str, + *, + headers: dict[str, str], + json: dict[str, Any] | None, +) -> httpx.Response: + timeout = httpx.Timeout(connect=2.0, read=10.0, write=10.0, pool=2.0) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + return await client.post( + f"{settings.subscription_service_url}{path}", + headers=headers, + json=json, + ) + except httpx.HTTPError as exc: + logger.error("subscription-service request failed: %s", exc) + raise HTTPException(503, detail="Subscription service is currently unavailable") from exc + + +def _raise_reservation_error(response: httpx.Response) -> None: + try: + body = response.json() + except ValueError: + body = {} + code = body.get("code", "TOKEN_RESERVATION_FAILED") + message = body.get("message", "Could not reserve tokens for this operation") + if response.status_code == 401: + raise HTTPException(401, detail=message) + if response.status_code == 403: + raise HTTPException(403, detail=message) + if response.status_code == 409: + raise HTTPException(409, detail={"code": code, "message": message}) + raise HTTPException(response.status_code, detail=message) diff --git a/watermark-service-py/app/watermark.py b/watermark-service-py/app/watermark.py new file mode 100644 index 0000000..1acbbde --- /dev/null +++ b/watermark-service-py/app/watermark.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import cv2 +import numpy as np +from imwatermark import WatermarkDecoder, WatermarkEncoder + +from app import crypto + +logger = logging.getLogger(__name__) + +# Adaptive tiers: pick the largest watermark capacity the image can carry reliably. +# Empirically calibrated against random-pixel images (worst-case for dwtDctSvd). +# Real photographs typically tolerate equal or smaller dimensions. +# +# Each tier requires (min_short_side, min_long_side) — so a 1920×1080 landscape +# screenshot picks the same tier as 1080×1920 portrait. The asymmetric thresholds +# reflect the empirical finding that dwtDctSvd's bit error rate depends roughly on +# pixel-per-bit density rather than a square minimum. +# +# Capacity after AES-GCM envelope + Reed-Solomon parity: +# 1024 bits → ~71 chars of text (after owner like "alice-7|" ~8 bytes) +# 768 bits → ~39 chars +# +# Embed verifies the round-trip and falls back to the next smaller tier if the +# library's bit-flips overwhelm even Reed-Solomon, so what we promise here is the +# *typical* outcome; the actual tier used is reported back in response headers. +TIERS: tuple[tuple[int, int, int], ...] = ( + # (min_short_side, min_long_side, length_bits) + (1080, 1600, 1024), # FHD landscape/portrait, 1600×1600+, modern phone photos + (1024, 1024, 768), # 1024×1024+ (square OK), photos that didn't qualify for tier A +) + +TIERS = ( + (1024, 1024, 1024), # Above FHD pixel count uses the larger token tier. + (1024, 1024, 768), # Basic tier: 1024x1024 up to and including FHD. +) + +KNOWN_LENGTHS_BITS: tuple[int, ...] = tuple(bits for _, _, bits in TIERS) +MIN_SHORT_SIDE: int = TIERS[-1][0] +MIN_LONG_SIDE: int = TIERS[-1][1] +FULL_HD_PIXELS: int = 1920 * 1080 + + +@dataclass(frozen=True) +class WatermarkResult: + png_bytes: bytes + + +@dataclass(frozen=True) +class CapacityReport: + max_text_bytes: int + min_image_width: int + min_image_height: int + image_ok: bool + image_width: int + image_height: int + length_bits: int # 0 when image is below the minimum tier + + +def _decode_png(image_bytes: bytes) -> np.ndarray: + """Decode image bytes to a BGR ndarray, honoring EXIF orientation. + + cv2.imdecode ignores EXIF rotation; a portrait JPEG with an orientation tag + decodes as landscape pixels, which throws off our tier selector (which + looks at width vs height). Pre-apply Pillow's `exif_transpose` so the + pixel data matches what the user sees. + """ + from io import BytesIO + + from PIL import Image, ImageOps + + try: + with Image.open(BytesIO(image_bytes)) as pil_img: + oriented = ImageOps.exif_transpose(pil_img).convert("RGB") + except Exception as exc: # PIL.UnidentifiedImageError, OSError, etc. + raise ValueError(f"Could not decode image bytes: {exc}") from exc + rgb_array = np.asarray(oriented) + return cv2.cvtColor(rgb_array, cv2.COLOR_RGB2BGR) + + +def _encode_png(bgr: np.ndarray) -> bytes: + ok, buf = cv2.imencode(".png", bgr, [cv2.IMWRITE_PNG_COMPRESSION, 9]) + if not ok: + raise RuntimeError("Could not encode PNG") + return buf.tobytes() + + +def _pack(payload: bytes, length_bits: int) -> bytes: + target_bytes = length_bits // 8 + if len(payload) > target_bytes: + raise ValueError( + f"Encrypted payload too long: {len(payload)} bytes, capacity {target_bytes} bytes " + f"(at {length_bits}-bit watermark)" + ) + return payload.ljust(target_bytes, b"\x00") + + +def _unpack(raw: bytes) -> bytes: + return raw.rstrip(b"\x00") + + +def _tiers_for_image(width: int, height: int) -> list[int]: + """Return all tiers an image qualifies for, largest-capacity first. + + Empty list ⇒ image too small for any tier. + """ + short = min(width, height) + long_side = max(width, height) + if short < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE: + return [] + + if width * height > FULL_HD_PIXELS: + return [1024, 768] + return [768] + + +def select_length_bits(width: int, height: int) -> int: + """Pick the largest watermark capacity this image can carry. Raises if too small.""" + tiers = _tiers_for_image(width, height) + if not tiers: + raise ValueError( + f"Image too small: {width}x{height}, need at least " + f"{MIN_SHORT_SIDE}x{MIN_LONG_SIDE} (short × long side) for any watermark capacity" + ) + return tiers[0] + + +def capacity_report(image_bytes: bytes, *, owner: str) -> CapacityReport: + bgr = _decode_png(image_bytes) + height, width = bgr.shape[:2] + tiers = _tiers_for_image(width, height) + if not tiers: + return CapacityReport( + max_text_bytes=0, + min_image_width=MIN_SHORT_SIDE, + min_image_height=MIN_LONG_SIDE, + image_ok=False, + image_width=width, + image_height=height, + length_bits=0, + ) + length_bits = tiers[0] + return CapacityReport( + max_text_bytes=crypto.max_text_bytes(length_bits, owner), + min_image_width=MIN_SHORT_SIDE, + min_image_height=MIN_LONG_SIDE, + image_ok=True, + image_width=width, + image_height=height, + length_bits=length_bits, + ) + + +def _embed_at_tier(bgr: np.ndarray, sealed: bytes, length_bits: int) -> bytes: + payload = _pack(sealed, length_bits) + encoder = WatermarkEncoder() + encoder.set_watermark("bytes", payload) + watermarked = encoder.encode(bgr, "dwtDctSvd") + return _encode_png(watermarked) + + +def _verify_roundtrip(png_bytes: bytes, length_bits: int, expected: crypto.DecodedEnvelope, *, app_key: str) -> bool: + """Decode + decrypt the embedded image to confirm the watermark survives PNG round-trip + AND ECC+GCM produce the original payload. Catches the (rare) cases where even + Reed-Solomon can't fix the library's bit-flips. + + Any exception during decode (imwatermark internals can raise IndexError on + short reads, etc.) counts as verification failure so the caller falls back + to the next tier instead of bubbling a 500.""" + try: + bgr = _decode_png(png_bytes) + decoder = WatermarkDecoder("bytes", length_bits) + raw = _unpack(decoder.decode(bgr, "dwtDctSvd")) + decoded = crypto.unseal(raw, app_key=app_key) + except (crypto.CryptoError, Exception) as exc: # noqa: BLE001 — intentional broad catch + if not isinstance(exc, crypto.CryptoError): + logger.debug("Roundtrip verification raised %s: %s", type(exc).__name__, exc) + return False + return decoded.owner == expected.owner and decoded.text == expected.text + + +def embed_text( + image_bytes: bytes, + text: str, + *, + owner: str, + app_key: str, +) -> WatermarkResult: + bgr = _decode_png(image_bytes) + height, width = bgr.shape[:2] + candidate_tiers = _tiers_for_image(width, height) + if not candidate_tiers: + raise ValueError( + f"Image too small: {width}x{height}, need at least " + f"{MIN_SHORT_SIDE}x{MIN_LONG_SIDE} (short × long side)" + ) + + sealed = crypto.seal(owner, text, app_key=app_key) + expected = crypto.DecodedEnvelope(owner=owner, text=text) + + last_error: Exception | None = None + for length_bits in candidate_tiers: + try: + png_bytes = _embed_at_tier(bgr, sealed, length_bits) + except ValueError as exc: + # text too long for THIS tier — try the next (smaller) one, but + # remember the error in case all tiers are too small for the text + last_error = exc + continue + if _verify_roundtrip(png_bytes, length_bits, expected, app_key=app_key): + return WatermarkResult(png_bytes=png_bytes) + # round-trip verification failed — fall back to next smaller tier + + if last_error is not None: + raise last_error + raise RuntimeError( + "Embed verification failed at every tier. The image may have unusual content; " + "try a larger or differently-shaped image." + ) + + +@dataclass(frozen=True) +class DetectionResult: + watermarked: bool + owner_identity: str | None + text: str | None + length_bits: int | None + + +def detect_text(image_bytes: bytes, *, app_key: str) -> DetectionResult: + """Try each known length until one decrypts. Wrong length → GCM tag rejects.""" + bgr = _decode_png(image_bytes) + for length_bits in KNOWN_LENGTHS_BITS: + decoder = WatermarkDecoder("bytes", length_bits) + raw = _unpack(decoder.decode(bgr, "dwtDctSvd")) + try: + envelope = crypto.unseal(raw, app_key=app_key) + except crypto.CryptoError: + continue + return DetectionResult( + watermarked=True, + owner_identity=envelope.owner, + text=envelope.text, + length_bits=length_bits, + ) + return DetectionResult(watermarked=False, owner_identity=None, text=None, length_bits=None) + + +_SENTINEL_TEXT = "VISUALIZE" + + +def visualize(image_bytes: bytes, *, app_key: str) -> bytes: + """Heatmap of |watermarked - input| per pixel as pseudocolor PNG. + + The library may pad the watermarked output to a DCT-aligned size; crop back to source. + """ + bgr = _decode_png(image_bytes) + sentinel = embed_text( + image_bytes, + _SENTINEL_TEXT, + owner="visualize", + app_key=app_key, + ) + watermarked = _decode_png(sentinel.png_bytes) + + if watermarked.shape != bgr.shape: + height, width = bgr.shape[:2] + watermarked = watermarked[:height, :width] + + diff = cv2.absdiff(watermarked, bgr) + diff_max = diff.max(axis=2).astype(np.uint8) + + peak = int(diff_max.max()) + if peak > 0: + normalized = ((diff_max.astype(np.float32) / peak) * 255.0).astype(np.uint8) + else: + normalized = diff_max + + heatmap = cv2.applyColorMap(normalized, cv2.COLORMAP_JET) + return _encode_png(heatmap) diff --git a/watermark-service-py/pytest.ini b/watermark-service-py/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/watermark-service-py/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/watermark-service-py/requirements-runtime.txt b/watermark-service-py/requirements-runtime.txt new file mode 100644 index 0000000..c70a0a9 --- /dev/null +++ b/watermark-service-py/requirements-runtime.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-multipart==0.0.20 +opencv-python-headless==4.10.0.84 +numpy==1.26.4 +Pillow==11.0.0 +PyWavelets==1.9.0 +py-eureka-client==0.11.13 +httpx==0.28.1 +pydantic-settings==2.7.0 +cryptography==44.0.0 +reedsolo==1.7.0 diff --git a/watermark-service-py/requirements.txt b/watermark-service-py/requirements.txt new file mode 100644 index 0000000..2eadddb --- /dev/null +++ b/watermark-service-py/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +python-multipart==0.0.20 +invisible-watermark==0.2.0 +opencv-python-headless==4.10.0.84 +numpy==1.26.4 +Pillow==11.0.0 +py-eureka-client==0.11.13 +httpx==0.28.1 +pydantic-settings==2.7.0 +cryptography==44.0.0 +reedsolo==1.7.0 +pytest==8.3.4 +pytest-asyncio==0.25.0 +respx==0.22.0 diff --git a/watermark-service-py/tests/__init__.py b/watermark-service-py/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watermark-service-py/tests/conftest.py b/watermark-service-py/tests/conftest.py new file mode 100644 index 0000000..1453630 --- /dev/null +++ b/watermark-service-py/tests/conftest.py @@ -0,0 +1,20 @@ +import os + +# Tests must not hit a real config-server, real auth-server, or honor any of the +# operator's local env. Use unconditional assignment so a stray `CONFIG_SERVER_URL` +# in the developer's shell doesn't make tests reach out to a live instance. +os.environ["CONFIG_SERVER_URL"] = "" +os.environ["WATERMARK_APP_KEY"] = "test-app-key-deterministic" +os.environ["EUREKA_URL"] = "" +os.environ["SUBSCRIPTION_SERVICE_URL"] = "http://subscription-service:8085" + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + + +@pytest.fixture +def client() -> TestClient: + app = create_app() + return TestClient(app) diff --git a/watermark-service-py/tests/test_ai_client.py b/watermark-service-py/tests/test_ai_client.py new file mode 100644 index 0000000..193a6a9 --- /dev/null +++ b/watermark-service-py/tests/test_ai_client.py @@ -0,0 +1,72 @@ +import httpx +import pytest +import respx +from httpx import Response + +from app.ai_client import ClassificationResult, classify_or_fallback +from app.config import Settings + + +SETTINGS = Settings(ai_service_url="http://ai-service:8084") + + +@respx.mock +@pytest.mark.asyncio +async def test_classify_returns_result_on_success(): + respx.post("http://ai-service:8084/api/classify").mock( + return_value=Response( + 200, + json={ + "category": "dog", + "label": "golden retriever", + "confidence": 0.05, + "categoryConfidence": 0.89, + "top3": [], + }, + ) + ) + result = await classify_or_fallback( + SETTINGS, + image_bytes=b"fake-png", + filename="img.png", + content_type="image/png", + bearer_token="abc", + ) + assert result == ClassificationResult( + category="dog", + label="golden retriever", + confidence=0.05, + category_confidence=0.89, + ) + + +@respx.mock +@pytest.mark.asyncio +async def test_classify_returns_unknown_fallback_on_error(): + respx.post("http://ai-service:8084/api/classify").mock(return_value=Response(500)) + result = await classify_or_fallback( + SETTINGS, + image_bytes=b"fake-png", + filename="img.png", + content_type="image/png", + bearer_token="abc", + ) + assert result == ClassificationResult( + category="unknown", label="unknown", confidence=0.0, category_confidence=0.0 + ) + + +@respx.mock +@pytest.mark.asyncio +async def test_classify_returns_unknown_when_network_fails(): + respx.post("http://ai-service:8084/api/classify").mock(side_effect=httpx.ConnectError("boom")) + result = await classify_or_fallback( + SETTINGS, + image_bytes=b"fake-png", + filename="img.png", + content_type="image/png", + bearer_token="abc", + ) + assert result.category == "unknown" + assert result.label == "unknown" + assert result.confidence == 0.0 diff --git a/watermark-service-py/tests/test_auth.py b/watermark-service-py/tests/test_auth.py new file mode 100644 index 0000000..4cc2e14 --- /dev/null +++ b/watermark-service-py/tests/test_auth.py @@ -0,0 +1,76 @@ +import base64 +import json + +import httpx +import pytest +import respx +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from httpx import Response + +from app.auth import require_principal +from app.config import Settings + + +@pytest.fixture +def auth_app() -> FastAPI: + from app.main import create_app + app = create_app() + app.state.settings = Settings(auth_server_url="http://auth-server:8081") + + @app.get("/_protected") + def protected(principal: str = Depends(require_principal)): + return {"principal": principal} + + return app + + +def _jwt_with(sub: str, user_id: int) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode( + json.dumps({"sub": sub, "userId": user_id}).encode() + ).rstrip(b"=").decode() + return f"{header}.{payload}.signature-placeholder" + + +@respx.mock +def test_missing_authorization_header_returns_401(auth_app): + client = TestClient(auth_app) + response = client.get("/_protected") + assert response.status_code == 401 + assert response.json() == {"error": "Invalid or expired token"} + + +@respx.mock +def test_invalid_token_returns_401(auth_app): + respx.post("http://auth-server:8081/auth/validate").mock( + return_value=Response(200, json=False) + ) + client = TestClient(auth_app) + response = client.get("/_protected", headers={"Authorization": "Bearer bad-token"}) + assert response.status_code == 401 + assert response.json() == {"error": "Invalid or expired token"} + + +@respx.mock +def test_valid_token_extracts_principal(auth_app): + respx.post("http://auth-server:8081/auth/validate").mock( + return_value=Response(200, json=True) + ) + token = _jwt_with("alice", 42) + client = TestClient(auth_app) + response = client.get("/_protected", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + assert response.json() == {"principal": "alice-42"} + + +@respx.mock +def test_auth_server_down_returns_503(auth_app): + respx.post("http://auth-server:8081/auth/validate").mock( + side_effect=httpx.ConnectError("connection refused") + ) + token = _jwt_with("alice", 42) + client = TestClient(auth_app) + response = client.get("/_protected", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 503 + assert response.json() == {"error": "Authentication service is currently unavailable"} diff --git a/watermark-service-py/tests/test_routes.py b/watermark-service-py/tests/test_routes.py new file mode 100644 index 0000000..edf8a5d --- /dev/null +++ b/watermark-service-py/tests/test_routes.py @@ -0,0 +1,319 @@ +import base64 +import io +import json +import re + +import numpy as np +import respx +from PIL import Image +from httpx import Response + + +def _random_png(width: int, height: int | None = None, seed: int = 0) -> bytes: + if height is None: + height = width + rng = np.random.RandomState(seed) + arr = rng.randint(0, 256, (height, width, 3), dtype=np.uint8) + buf = io.BytesIO() + Image.fromarray(arr).save(buf, format="PNG") + return buf.getvalue() + + +def _jwt(sub: str, user_id: int) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode( + json.dumps({"sub": sub, "userId": user_id}).encode() + ).rstrip(b"=").decode() + return f"{header}.{payload}.signature-placeholder" + + +def _auth_headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _mock_subscription_reservations(count: int) -> None: + respx.post("http://subscription-service:8085/api/tokens/reservations").mock( + side_effect=[ + Response( + 201, + json={ + "reservationId": f"reservation-{index}", + "operation": "TEST", + "tokens": 1, + }, + ) + for index in range(count) + ] + ) + respx.post( + re.compile(r"http://subscription-service:8085/api/tokens/reservations/[^/]+/(consume|release)") + ).mock(return_value=Response(200, json={})) + + +def test_health_returns_ok(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "UP"} + + +@respx.mock +def test_embed_returns_png_with_classification_and_capacity_headers(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://ai-service:8084/api/classify").mock( + return_value=Response( + 200, + json={ + "category": "dog", + "label": "labrador", + "confidence": 0.05, + "categoryConfidence": 0.81, + "top3": [], + }, + ) + ) + _mock_subscription_reservations(2) + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/embed", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1600), "image/png")}, + data={"text": "hello"}, + ) + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["x-image-category"] == "dog" + assert response.headers["x-image-label"] == "labrador" + assert int(response.headers["x-max-text-bytes"]) > 0 + assert response.headers["x-watermark-length-bits"] == "1024" + assert response.content[:8] == b"\x89PNG\r\n\x1a\n" + + +@respx.mock +def test_detect_then_extract_roundtrip(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://ai-service:8084/api/classify").mock(return_value=Response(500)) + _mock_subscription_reservations(4) + + token = _jwt("alice", 7) + embed_response = client.post( + "/api/watermark/embed", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1600), "image/png")}, + data={"text": "hello-world"}, + ) + assert embed_response.status_code == 200 + watermarked_png = embed_response.content + + detect_response = client.post( + "/api/watermark/detect", + headers=_auth_headers(token), + files={"image": ("wm.png", watermarked_png, "image/png")}, + ) + assert detect_response.status_code == 200 + body = detect_response.json() + assert body["watermarked"] is True + assert body["ownerIdentity"] == "alice-7" + assert body["version"] == 1 + assert body["lengthBits"] == 1024 + + extract_response = client.post( + "/api/watermark/extract", + headers=_auth_headers(token), + files={"image": ("wm.png", watermarked_png, "image/png")}, + ) + assert extract_response.status_code == 200 + assert extract_response.json() == {"ownerIdentity": "alice-7", "text": "hello-world"} + + +@respx.mock +def test_embed_on_smaller_image_picks_lower_tier(client): + """A 1024x1024 image only qualifies for the 768-bit tier (1024-bit needs long ≥ 1600).""" + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://ai-service:8084/api/classify").mock(return_value=Response(500)) + _mock_subscription_reservations(3) + + token = _jwt("alice", 7) + embed_response = client.post( + "/api/watermark/embed", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1024), "image/png")}, + data={"text": "medium-sized"}, + ) + assert embed_response.status_code == 200 + assert embed_response.headers["x-watermark-length-bits"] == "768" + + detect_response = client.post( + "/api/watermark/detect", + headers=_auth_headers(token), + files={"image": ("wm.png", embed_response.content, "image/png")}, + ) + body = detect_response.json() + assert body["watermarked"] is True + assert body["lengthBits"] == 768 + + +@respx.mock +def test_embed_fhd_landscape_uses_basic_tier(client): + """1920x1080 screenshot should stay in the 768-bit tier.""" + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://ai-service:8084/api/classify").mock(return_value=Response(500)) + _mock_subscription_reservations(2) + + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/embed", + headers=_auth_headers(token), + files={"image": ("fhd.png", _random_png(1920, 1080), "image/png")}, + data={"text": "fhd screenshot"}, + ) + assert response.status_code == 200 + assert response.headers["x-watermark-length-bits"] == "768" + + +@respx.mock +def test_extract_by_wrong_user_returns_403(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://ai-service:8084/api/classify").mock(return_value=Response(500)) + _mock_subscription_reservations(3) + + alice = _jwt("alice", 7) + bob = _jwt("bob", 8) + embed_response = client.post( + "/api/watermark/embed", + headers=_auth_headers(alice), + files={"image": ("a.png", _random_png(1600), "image/png")}, + data={"text": "owner-data"}, + ) + assert embed_response.status_code == 200 + extract_response = client.post( + "/api/watermark/extract", + headers=_auth_headers(bob), + files={"image": ("wm.png", embed_response.content, "image/png")}, + ) + assert extract_response.status_code == 403 + + +@respx.mock +def test_embed_returns_409_when_tokens_are_missing(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + respx.post("http://subscription-service:8085/api/tokens/reservations").mock( + return_value=Response( + 409, + json={ + "code": "INSUFFICIENT_TOKENS", + "message": "Not enough tokens for this operation", + }, + ) + ) + + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/embed", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1600), "image/png")}, + data={"text": "hello"}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == { + "code": "INSUFFICIENT_TOKENS", + "message": "Not enough tokens for this operation", + } + + +@respx.mock +def test_detect_requires_auth(client): + response = client.post( + "/api/watermark/detect", + files={"image": ("a.png", _random_png(1600), "image/png")}, + ) + assert response.status_code == 401 + + +@respx.mock +def test_visualize_requires_auth(client): + response = client.post( + "/api/watermark/visualize", + files={"image": ("a.png", _random_png(1600), "image/png")}, + ) + assert response.status_code == 401 + + +@respx.mock +def test_capacity_for_big_image(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/capacity", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1600), "image/png")}, + ) + assert response.status_code == 200 + body = response.json() + assert body["imageOk"] is True + assert body["imageWidth"] == 1600 + assert body["lengthBits"] == 1024 + assert 70 < body["maxTextBytes"] < 90 + + +@respx.mock +def test_capacity_for_medium_image_picks_lower_tier(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/capacity", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1024), "image/png")}, + ) + assert response.status_code == 200 + body = response.json() + assert body["imageOk"] is True + assert body["lengthBits"] == 768 + assert 30 < body["maxTextBytes"] < 50 + + +@respx.mock +def test_capacity_for_fhd_landscape(client): + """1920x1080 reports the 768-bit tier.""" + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/capacity", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(1920, 1080), "image/png")}, + ) + assert response.status_code == 200 + body = response.json() + assert body["imageOk"] is True + assert body["lengthBits"] == 768 + assert body["imageWidth"] == 1920 + assert body["imageHeight"] == 1080 + + +@respx.mock +def test_capacity_reports_too_small(client): + respx.post("http://auth-server:8081/auth/validate").mock(return_value=Response(200, json=True)) + token = _jwt("alice", 7) + response = client.post( + "/api/watermark/capacity", + headers=_auth_headers(token), + files={"image": ("a.png", _random_png(800), "image/png")}, + ) + assert response.status_code == 200 + body = response.json() + assert body["imageOk"] is False + assert body["lengthBits"] == 0 + + +def test_app_boots_when_eureka_unreachable(monkeypatch): + monkeypatch.setenv("EUREKA_URL", "http://nonexistent-eureka:9999/eureka/") + from importlib import reload + + import app.main + + reload(app.main) + test_app = app.main.create_app() + from fastapi.testclient import TestClient + + with TestClient(test_app) as test_client: + assert test_client.get("/health").status_code == 200 diff --git a/watermark-service-py/tests/test_watermark.py b/watermark-service-py/tests/test_watermark.py new file mode 100644 index 0000000..a0f8df0 --- /dev/null +++ b/watermark-service-py/tests/test_watermark.py @@ -0,0 +1,159 @@ +import io + +import numpy as np +import pytest +from PIL import Image + +from app.watermark import ( + DetectionResult, + WatermarkResult, + capacity_report, + detect_text, + embed_text, + select_length_bits, + visualize, +) + +APP_KEY = "test-app-key-deterministic" + + +def _random_png(width: int, height: int | None = None, seed: int = 42) -> bytes: + if height is None: + height = width + rng = np.random.RandomState(seed) + arr = rng.randint(0, 256, (height, width, 3), dtype=np.uint8) + buf = io.BytesIO() + Image.fromarray(arr).save(buf, format="PNG") + return buf.getvalue() + + +@pytest.fixture +def big_image() -> bytes: + """1600x1600 — picks the 1024-bit tier (largest capacity).""" + return _random_png(1600) + + +@pytest.fixture +def fhd_landscape() -> bytes: + """1920x1080 FHD screenshot — short=1080, long=1920 → 1024-bit tier.""" + return _random_png(1920, 1080) + + +@pytest.fixture +def medium_image() -> bytes: + """1200x1200 — too narrow for 1024-bit tier (needs long ≥ 1600), falls to 768-bit. + + Note: 1024-bit tier rule is short≥1080 AND long≥1600 — 1200×1200 satisfies the + short floor but not the long one, so it picks the 768-bit tier. + """ + return _random_png(1200) + + +def test_embed_then_detect_big_image(big_image): + result = embed_text(big_image, "hello world", owner="user-42", app_key=APP_KEY) + assert isinstance(result, WatermarkResult) + detection = detect_text(result.png_bytes, app_key=APP_KEY) + assert isinstance(detection, DetectionResult) + assert detection.watermarked is True + assert detection.owner_identity == "user-42" + assert detection.text == "hello world" + assert detection.length_bits == 1024 + + +def test_embed_then_detect_medium_image(medium_image): + result = embed_text(medium_image, "midsize", owner="u-1", app_key=APP_KEY) + detection = detect_text(result.png_bytes, app_key=APP_KEY) + assert detection.watermarked is True + assert detection.owner_identity == "u-1" + assert detection.text == "midsize" + assert detection.length_bits == 768 + + +def test_embed_then_detect_fhd_landscape(fhd_landscape): + """1920x1080 should use the largest (1024-bit) tier.""" + result = embed_text(fhd_landscape, "fhd shot", owner="u-1", app_key=APP_KEY) + detection = detect_text(result.png_bytes, app_key=APP_KEY) + assert detection.watermarked is True + assert detection.text == "fhd shot" + assert detection.length_bits == 1024 + + +def test_embed_then_detect_fhd_portrait(): + """1080x1920 (portrait phone screenshot) should also use the largest tier.""" + img = _random_png(1080, 1920) + result = embed_text(img, "portrait", owner="u-1", app_key=APP_KEY) + detection = detect_text(result.png_bytes, app_key=APP_KEY) + assert detection.watermarked is True + assert detection.length_bits == 1024 + + +def test_detect_with_wrong_key_returns_not_watermarked(big_image): + result = embed_text(big_image, "secret", owner="user-1", app_key=APP_KEY) + detection = detect_text(result.png_bytes, app_key="totally-different-key") + assert detection.watermarked is False + assert detection.owner_identity is None + assert detection.length_bits is None + + +def test_detect_plain_image_returns_not_watermarked(big_image): + detection = detect_text(big_image, app_key=APP_KEY) + assert detection.watermarked is False + + +def test_embed_rejects_image_below_smallest_tier(): + tiny = _random_png(800) + with pytest.raises(ValueError, match="too small"): + embed_text(tiny, "hello", owner="u-1", app_key=APP_KEY) + + +def test_select_length_bits_picks_largest_tier_that_fits(): + # tier 1024-bit: short>=1080, long>=1600 + assert select_length_bits(2000, 2000) == 1024 + assert select_length_bits(1920, 1080) == 1024 + assert select_length_bits(1080, 1920) == 1024 + assert select_length_bits(1600, 1080) == 1024 + # tier 768-bit: short>=1024, long>=1024 + assert select_length_bits(1024, 1024) == 768 + assert select_length_bits(1200, 1200) == 768 + assert select_length_bits(1500, 1500) == 768 + # too small for either tier + with pytest.raises(ValueError): + select_length_bits(1023, 1023) + with pytest.raises(ValueError): + select_length_bits(1280, 720) # short side too narrow + + +def test_capacity_report_for_big_image(big_image): + report = capacity_report(big_image, owner="alice-7") + assert report.image_ok is True + assert report.length_bits == 1024 + # 128 bytes total - 41 envelope - 8 owner = ~79 bytes + assert 70 < report.max_text_bytes < 90 + + +def test_capacity_report_for_medium_image(medium_image): + report = capacity_report(medium_image, owner="alice-7") + assert report.image_ok is True + assert report.length_bits == 768 + # 96 bytes total - 49 envelope - 8 owner = ~39 bytes + assert 30 < report.max_text_bytes < 50 + + +def test_capacity_report_for_fhd_landscape(fhd_landscape): + report = capacity_report(fhd_landscape, owner="alice-7") + assert report.image_ok is True + assert report.length_bits == 1024 + assert 60 < report.max_text_bytes < 90 + + +def test_capacity_report_for_too_small_image(): + tiny = _random_png(800) + report = capacity_report(tiny, owner="alice-7") + assert report.image_ok is False + assert report.length_bits == 0 + assert report.max_text_bytes == 0 + + +def test_visualize_returns_png(big_image): + heatmap_png = visualize(big_image, app_key=APP_KEY) + assert heatmap_png[:8] == b"\x89PNG\r\n\x1a\n"