141 Commits
Author SHA1 Message Date
Florian THIERRY ab84b0c2e6 Update dependencies.
Build and Deploy Java Gradle Application / build-and-deploy (push) Successful in 1m28s
2026-04-23 09:18:35 +02:00
florian f33b55b1c8 Fix left side menu. (#13)
Build and Deploy Java Gradle Application / build-and-deploy (push) Successful in 1m16s
2026-02-03 16:14:43 +01:00
florianandFlorian THIERRY e2fd4fb29a fixing-ci-angular-21 (#12)
Build and Deploy Java Gradle Application / build-and-deploy (push) Successful in 1m18s
Co-authored-by: Florian THIERRY <florian.thierry@actian.com>
Reviewed-on: florian/codiki-hexagonal#12
2026-02-03 15:59:33 +01:00
florian 0cce8b2982 Fixing Angular 21 by migrating all values by signals. (#11)
Build and Deploy Java Gradle Application / build-and-deploy (push) Failing after 53s
2026-02-03 15:07:55 +01:00
florianandTamotsu 1ca2f872f7 Update dependencies - spring boot 4 and angular 21 (#10)
Build and Deploy Java Gradle Application / build-and-deploy (push) Successful in 1m39s
Co-authored-by: Florian THIERRY
Reviewed-on: florian/codiki-hexagonal#10
2025-12-30 17:45:03 +01:00
florianandFlorian THIERRY 03dd52de71 gitea-actions (#8)
Build and Deploy Java Gradle Application / build-and-deploy (push) Successful in 1m36s
Co-authored-by: Florian THIERRY <florian.thierry@protonmail.com>
Reviewed-on: florian/codiki-hexagonal#8
2025-07-23 22:59:51 +02:00
Florian THIERRY 859273a9c1 Change backend ip address after freebox migration. 2025-07-23 20:48:00 +02:00
florian 653536b0da Merge pull request 'design-system' (#7) from design-system into main
Reviewed-on: florian/codiki-hexagonal#7
2024-10-22 08:36:07 +02:00
Florian THIERRY 5fca5bde55 Rename cod-btn into cod-button. 2024-10-22 08:32:46 +02:00
Florian THIERRY d36fd17690 Add i18n. 2024-10-22 08:23:27 +02:00
Florian THIERRYandFlorian THIERRY 295f977a21 Add icon button style. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 1a00d0cd19 Add missing ripple. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 2da6a58b20 Change version number. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 0d2883fe2a Code cleanning. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 7f99d11209 Add ripple everywhere. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 053ac89e3c Misc changes about form inputs. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY cfca22bf66 Factorization of input styling. 2024-10-22 08:17:07 +02:00
Florian THIERRYandFlorian THIERRY 7c5cc38cff Initiate design system and refactor login page buttons. 2024-10-22 08:17:07 +02:00
Florian THIERRY dae0a4b78d Fix field edition problem. 2024-10-18 10:37:31 +02:00
Florian THIERRY 882ffe7094 Several changes about token refreshing. 2024-10-15 16:11:46 +02:00
Florian THIERRY 136771ab60 Add i18n missing translation. 2024-10-15 09:35:14 +02:00
florian 3865c26397 Merge pull request 'Add a guard to deny access for some routes.' (#6) from already-auth-guard into main
Add a guard to deny access for some routes.
2024-10-15 08:26:45 +02:00
Florian THIERRY 26a217cd50 Add a guard to deny access for some routes. 2024-10-15 08:25:49 +02:00
florian ff52a198dc Upgrade to angular 18. 2024-09-24 22:47:15 +02:00
Florian THIERRY f3d59a0ef3 conf cleaning. 2024-09-22 13:18:08 +02:00
Florian THIERRY d84485e52b test 2024-09-22 13:03:39 +02:00
Florian THIERRY f789d89995 test 2024-09-22 12:53:55 +02:00
Florian THIERRY 7e0174bcc2 test 2024-09-22 12:46:41 +02:00
Florian THIERRY fe1d59a3bb test 2024-09-22 12:37:01 +02:00
Florian THIERRY 69a99c9312 test 2024-09-22 12:34:02 +02:00
Florian THIERRY a6414ae64d test 2024-09-22 12:30:18 +02:00
Florian THIERRY 9cf47f0e2a test 2024-09-22 12:27:59 +02:00
Florian THIERRY 6c89562dc3 test 2024-09-22 12:14:04 +02:00
Florian THIERRY e85eabbed5 test 2024-09-22 12:01:05 +02:00
Florian THIERRY 1ec4ba8212 test conf prod. 2024-09-22 11:10:01 +02:00
Florian THIERRY a1ff181443 test conf prod. 2024-09-22 11:09:14 +02:00
Florian THIERRY ee8f48bc43 Fix prod fr configuration. 2024-09-22 10:55:49 +02:00
Florian THIERRY 7ec1aee884 test 2024-09-21 23:39:46 +02:00
Florian THIERRY a3adfa8ee0 Fix bug of preview content. 2024-09-21 22:20:00 +02:00
Florian THIERRY d893afa1f3 i18n 2024-09-21 21:43:09 +02:00
Florian THIERRY d984128176 i18n 2024-09-21 21:40:49 +02:00
Florian THIERRY f8d73c9ed0 i18n 2024-09-21 21:34:16 +02:00
Florian THIERRY 208b935ffa i18n for some components. 2024-09-21 21:17:31 +02:00
Florian THIERRY f12dfc7029 i18n for files in core package. 2024-09-21 21:09:36 +02:00
Florian THIERRY 98a890e915 i18n for signin page. 2024-09-21 21:06:47 +02:00
Florian THIERRY 0c1b52d734 i18n for publication search page. 2024-09-21 21:03:34 +02:00
Florian THIERRY 3f6764dd7d i18n for publication update page. 2024-09-21 21:00:59 +02:00
Florian THIERRY 67c3d0b3e6 i18n of publication creation page. 2024-09-21 20:57:05 +02:00
Florian THIERRY 36208ef071 i18n of publication page. 2024-09-21 12:28:55 +02:00
Florian THIERRY b546a0cf01 Add i18n completion tool. 2024-09-21 12:20:22 +02:00
Florian THIERRY 3935f6ad21 i18n for login page. 2024-09-21 11:22:35 +02:00
Florian THIERRY fd5ad7e88e i18n for disconnection page. 2024-09-21 11:13:08 +02:00
Florian THIERRY 8c957fe694 i18n for home page. 2024-09-21 11:12:07 +02:00
Florian THIERRY cb0ef7ddd5 Test 2024-09-20 18:38:17 +02:00
Florian THIERRY 42e466fe8b test ci. 2024-09-20 17:32:43 +02:00
Florian THIERRY 2c5fa4fa13 test ci. 2024-09-20 17:25:37 +02:00
Florian THIERRY ebce44c889 test ci 2024-09-20 17:12:13 +02:00
Florian THIERRY 955dc48f51 test ci 2024-09-20 16:46:11 +02:00
Florian THIERRY 29e75e6298 Test ci. 2024-09-20 16:34:29 +02:00
Florian THIERRY 187fd105d3 Fix pictures paths. 2024-09-20 09:36:10 +02:00
Florian THIERRY 1b92fd269e Fix config. 2024-09-19 22:24:39 +02:00
Florian THIERRY 23025e3606 Fix fucking MIME types of javascript files. 2024-09-19 22:13:10 +02:00
Florian THIERRY 38c11e2d9f gitignore. 2024-09-19 18:03:33 +02:00
Florian THIERRY 610723c561 CI/CD drafting. 2024-09-19 18:03:23 +02:00
Florian THIERRY 0e2fb945a4 Initialize docker build configuration. 2024-09-18 10:18:40 +02:00
Florian THIERRY ef32572521 Change favicon. 2024-09-18 09:22:11 +02:00
Florian THIERRY e5a128a7f6 Fix style. 2024-09-18 09:10:46 +02:00
Florian THIERRY f5e1e10ebd Add confirmation dialog to delete publications. 2024-09-18 09:05:18 +02:00
Florian THIERRY 500952d4d4 Implementation of categoryId edition. 2024-09-10 10:47:58 +02:00
Florian THIERRY 1cc4abc24e Add default picture and factorization of title in publication edition. 2024-09-10 09:40:45 +02:00
Florian THIERRY 00945de270 Code cleaning about publication edition component. 2024-09-06 10:20:05 +02:00
Florian THIERRY 5804d8cc9f Extract publication edition into a separated component. 2024-09-05 17:28:30 +02:00
Florian THIERRY 5610bd170a Minor changes 2024-09-05 09:35:46 +02:00
Florian THIERRY 21d19d4ecd Styling elements. 2024-09-05 09:33:07 +02:00
Florian THIERRY f3dfac6bc7 Add delete button. 2024-09-05 09:23:25 +02:00
Florian THIERRY 4565192d0b Remote temp configuration. 2024-09-04 14:20:31 +02:00
Florian THIERRY d7ac4966c9 Add "new publication" button. 2024-09-04 14:19:26 +02:00
Florian THIERRY 64119a956a Add "my-publications" page. 2024-09-04 14:06:10 +02:00
Florian THIERRY c4dea2cc85 Dependency cleaning. 2024-09-04 10:28:22 +02:00
Florian THIERRY b0cc42fddd Implementaiton of preview tab. 2024-09-04 10:26:47 +02:00
Florian THIERRY db669114b2 Add Prism.js. 2024-09-04 09:29:10 +02:00
Florian THIERRY ca6b207816 Fix refresh token mecanism. 2024-09-04 08:58:47 +02:00
Florian THIERRY b091dc52b7 Fix authentication errors handling. 2024-09-03 11:45:47 +02:00
Florian THIERRY 4d44b6f53c First implementation of refresh token. 2024-09-03 10:37:58 +02:00
Florian THIERRY be34c555a5 Implementation of code-block addition in the publication. 2024-09-03 10:07:13 +02:00
Florian THIERRY c03d977028 Add code-block dialog and style it. 2024-09-03 10:00:45 +02:00
Florian THIERRY afd184f936 Fix author-id publications search. 2024-08-30 22:37:03 +02:00
Florian THIERRY c09c68e1ac Implementation of picture addition button in editor. 2024-08-30 18:13:43 +02:00
Florian THIERRY b84ba15f4c Implementaiton of h1, h2, h3 and link button on publication editor. 2024-08-30 18:01:29 +02:00
Florian THIERRY 51af25666d Fix search bar navigation. 2024-08-30 09:59:21 +02:00
Florian THIERRY b3a52f6a4b Extract search bar into a standalone component and fix header design. 2024-08-30 09:52:00 +02:00
Florian THIERRY 090143fdae Add search publication page and fix category access 2024-08-29 17:24:01 +02:00
Florian THIERRY b5f881e2c5 Add a component to display a publication list and fix publication search rest service to handle ids. 2024-08-29 13:56:14 +02:00
Florian THIERRY d9b856bd43 Fix categories loading in side menu component. 2024-08-29 11:09:39 +02:00
Florian THIERRY 5e4068b141 Add style and help button. 2024-08-29 10:20:31 +02:00
Florian THIERRY a2f1b511c1 Add buttons in publication editor. 2024-08-25 22:40:11 +02:00
Florian THIERRY 4d20d5f8b8 Add button to edit publications on publication view page. 2024-08-25 12:02:52 +02:00
Florian THIERRY f00fb103ba Add submit button component and style it. 2024-08-22 10:26:38 +02:00
Florian THIERRY b1d9344574 Styling picture selection components. 2024-08-19 23:02:39 +02:00
Florian THIERRY 56ac024cba Mess commit. 2024-08-19 22:42:42 +02:00
Florian THIERRY 32ab1d79c8 Add publication edition form. 2024-08-04 15:54:00 +02:00
Florian THIERRY 42c4f76c0d Design some buttons in header. 2024-06-28 09:35:56 +02:00
Florian THIERRY 4cc2a15231 Responsive styling of header. 2024-06-12 13:36:16 +02:00
Florian THIERRY 54fbc7d609 Adding css transitions. 2024-06-12 13:22:20 +02:00
Florian THIERRY 8e9440a104 Responsive styling of publication page. 2024-06-12 13:19:08 +02:00
Florian THIERRY 00d49d5fa4 Responsive styling of home page. 2024-06-12 13:15:03 +02:00
Florian THIERRY 78325c8729 Fix footer at page bottom. 2024-06-12 12:38:42 +02:00
Florian THIERRY 1e18e3bc52 Add signin page. 2024-06-11 12:55:11 +02:00
Florian THIERRY 8ada2a15ef Styling login form. 2024-06-07 09:44:51 +02:00
Florian THIERRY 95d5308934 Re-design of login page. 2024-06-06 11:05:02 +02:00
Florian THIERRY e5076f0c64 Design header search bar. 2024-06-05 10:23:38 +02:00
Florian THIERRY 2ba707c336 Fix home page design. 2024-06-05 10:05:02 +02:00
Florian THIERRY d3041cf03d Styling publications on home page. 2024-06-04 13:55:26 +02:00
Florian THIERRY 58295398e0 Add endpoint to retrieve latest publications. 2024-06-04 13:06:20 +02:00
Florian THIERRY 067bf7885a Update side menu header. 2024-06-04 12:55:46 +02:00
Florian THIERRY d324b94ddb Add SQL script to rebuild categories hierarchy. 2024-04-22 16:05:59 +02:00
Florian THIERRY 4985889c58 Styling header. 2024-04-22 16:05:35 +02:00
Florian THIERRY 7f5d52dce5 Add side menu for header. 2024-04-22 15:57:22 +02:00
Florian THIERRY fae709a254 Fix class not found error. 2024-04-22 14:37:20 +02:00
Florian THIERRY 45355f6c42 Refactor publication parser location. 2024-04-22 14:22:36 +02:00
Florian THIERRY db492b6316 Add parsed text to publication entities. 2024-04-22 14:13:17 +02:00
Florian THIERRY c54e1c57d7 Creation of side-menu. 2024-04-02 16:18:03 +02:00
Florian THIERRY 0900df463a Add disconnection and minor improvements on login page. 2024-03-27 12:15:41 +01:00
Florian THIERRY 13c2cc8118 Add frontend. 2024-03-27 10:28:33 +01:00
Florian THIERRY 431d365d20 Move backend files into a sub folder. 2024-03-27 10:28:22 +01:00
Florian THIERRY 39663e914d Adaptations for legacy database importation. 2024-03-21 18:07:33 +01:00
Florian THIERRY 30e5ffa2eb Convert login by id into login by email. 2024-03-19 09:31:21 +01:00
Florian THIERRY 8d778e3571 Add 404 response if no any publication was found while searching publications. 2024-03-15 22:33:14 +01:00
Florian THIERRY dabd93091c Add publication sort while search publications service. 2024-03-15 22:17:27 +01:00
Florian THIERRY da1937cb31 Fix search publication mechanism. 2024-03-15 17:39:04 +01:00
Florian THIERRY 6e2b86153e Add search publications use case but it's bugged. 2024-03-15 14:56:34 +01:00
Florian THIERRY 50b305c3cd Add fields to user entity. 2024-03-14 18:17:03 +01:00
Florian THIERRY 9fcfc04117 Change category field of publication entity to its id. 2024-03-14 14:07:44 +01:00
Florian THIERRY 5d267111a9 Add illustration id to publication entity. 2024-03-14 13:58:25 +01:00
Florian THIERRY 2dc386e896 Add publisher id in picture entity. 2024-03-14 13:40:32 +01:00
Florian THIERRY fb13cfd74d Implementation of getting publication by its id. 2024-03-14 10:05:36 +01:00
Florian THIERRY 8d8a220fa0 Add picture fetch while retrieving a publication. 2024-03-14 09:59:46 +01:00
Florian THIERRY adc3cdf9a3 Add dependency to pictures in publications. 2024-03-14 09:55:51 +01:00
Florian THIERRY 5c5304ff98 Reworking the exception handling. 2024-03-14 09:31:14 +01:00
Florian THIERRY a872a9fe33 Add picture format control. 2024-03-14 09:24:19 +01:00
Florian THIERRY 0b00f9b0aa Set up picture upload mechanism. 2024-03-13 14:26:28 +01:00
282 changed files with 20083 additions and 513 deletions
+75
View File
@@ -0,0 +1,75 @@
name: Build and Deploy Java Gradle Application
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: 📄 Checkout code
uses: actions/checkout@v4
- name: 📄 Checkout configuration
env:
SSH_PRIVATE_KEY: ${{ secrets.PROD_PROPERTIES_SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
git clone -b hexagonal-reforged ssh://gitea@gitea.takiguchi.ovh:12960/Codiki/codiki-properties.git --config core.sshCommand='ssh -o StrictHostKeyChecking=no'
- name: 📄 Edit configuration
run: |
sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_user)/g" ./codiki-properties/application-prod.yml
cp ./codiki-properties/application-prod.yml ./backend/codiki-launcher/src/main/resources/application.yml
sed -i "s/<POSTGRES_ADMIN_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_admin)/g" ./docker-compose.yml
- name: 🔨 Build backend docker image
run: |
sudo /usr/bin/docker build -t codiki-backend -f ./Dockerfile-backend . --no-cache
- name: 📦 Extract backend docker image into archive
run: |
sudo /usr/bin/docker save codiki-backend:latest -o ./codiki-backend.tar
- name: 🔨 Build frontend docker image
run: |
sudo /usr/bin/docker build -t codiki-frontend -f ./Dockerfile-frontend . --no-cache
- name: 📦 Extract frontend docker image into archive
run: |
sudo /usr/bin/docker save codiki-frontend:latest -o ./codiki-frontend.tar
- name: 📤 Transfer artifacts to remote server
env:
SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
REMOTE_USER: ${{ secrets.PROD_REMOTE_USER }}
REMOTE_HOST: ${{ secrets.PROD_REMOTE_HOST }}
REMOTE_PORT: ${{ secrets.PROD_REMOTE_PORT }}
REMOTE_PATH: ${{ secrets.PROD_REMOTE_PATH }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -o StrictHostKeyChecking=no -P $REMOTE_PORT ./codiki-backend.tar $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH
scp -o StrictHostKeyChecking=no -P $REMOTE_PORT ./codiki-frontend.tar $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH
scp -o StrictHostKeyChecking=no -P $REMOTE_PORT ./docker-compose.yml $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH
- name: 🚀 Launch application onto remote server
env:
SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
REMOTE_USER: ${{ secrets.PROD_REMOTE_USER }}
REMOTE_HOST: ${{ secrets.PROD_REMOTE_HOST }}
REMOTE_PORT: ${{ secrets.PROD_REMOTE_PORT }}
REMOTE_PATH: ${{ secrets.PROD_REMOTE_PATH }}
run: |
ssh -o StrictHostKeyChecking=no $REMOTE_HOST -l $REMOTE_USER -p $REMOTE_PORT << EOC
cd $REMOTE_PATH
sudo /usr/bin/docker load < $REMOTE_PATH/codiki-backend.tar
sudo /usr/bin/docker load < $REMOTE_PATH/codiki-frontend.tar
sudo /usr/bin/docker compose down
sudo /usr/bin/docker compose up --detach
EOC
+48
View File
@@ -36,3 +36,51 @@ build/
**/node_modules **/node_modules
**/.angular **/.angular
**/pictures-folder
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
**/ci/bin/
+15
View File
@@ -0,0 +1,15 @@
FROM maven:3.9.11-eclipse-temurin-21 AS builder
WORKDIR /app
COPY backend/pom.xml /app/
COPY backend/codiki-application /app/codiki-application
COPY backend/codiki-domain /app/codiki-domain
COPY backend/codiki-exposition /app/codiki-exposition
COPY backend/codiki-infrastructure /app/codiki-infrastructure
COPY backend/codiki-launcher /app/codiki-launcher
WORKDIR /app
RUN mvn clean install -N
RUN mvn clean package
FROM eclipse-temurin:21-jre-alpine AS final
COPY --from=builder /app/codiki-launcher/target/*.jar /app/codiki.jar
CMD ["java", "-jar", "/app/codiki.jar"]
+12
View File
@@ -0,0 +1,12 @@
FROM node:25-alpine AS builder
WORKDIR /app
COPY frontend /app
RUN npm install
RUN npm run build-prod-en
RUN npm run build-prod-fr
FROM nginx:1.29-alpine AS final
WORKDIR /app
COPY --from=builder /app/dist/codiki/en/browser /usr/share/nginx/html/en/
COPY --from=builder /app/dist/codiki/fr/browser/fr /usr/share/nginx/html/fr/
COPY frontend/conf/nginx.conf /etc/nginx/nginx.conf
Vendored
+84
View File
@@ -0,0 +1,84 @@
pipeline {
agent any
stages {
stage('Configuration') {
steps {
dir('codiki-properties') {
git url:'https://gitea.takiguchi.ovh/Codiki/codiki-properties.git', branch: 'hexagonal-reforged', credentialsId: 'Jenkins-gitea'
}
script {
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_user)/g" ./codiki-properties/application-prod.yml'
sh 'cp ./codiki-properties/application-prod.yml ./backend/codiki-launcher/src/main/resources/application-prod.yml'
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_admin)/g" ./docker-compose.yml'
}
}
}
stage('Build') {
steps {
script {
sh """
sudo /usr/bin/docker build -t codiki-backend -f ./Dockerfile-backend . --no-cache
sudo /usr/bin/docker build -t codiki-frontend -f ./Dockerfile-frontend . --no-cache
sudo /usr/bin/docker save codiki-backend:latest -o ./codiki-backend.tar
sudo /usr/bin/docker save codiki-frontend:latest -o ./codiki-frontend.tar
sudo chown jenkins:jenkins ./codiki-backend.tar
sudo chown jenkins:jenkins ./codiki-frontend.tar
chmod 644 ./codiki-backend.tar
chmod 644 ./codiki-frontend.tar
"""
}
}
}
stage('Deploy') {
steps {
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'DebianServer [codiki]',
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: 'codiki-backend.tar,codiki-frontend.tar,docker-compose.yml'
),
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: """
cd /opt/codiki
sudo /usr/bin/docker load < /opt/codiki/codiki-backend.tar
sudo /usr/bin/docker load < /opt/codiki/codiki-frontend.tar
sudo /usr/bin/docker compose down
sudo /usr/bin/docker compose up --detach
""",
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: ''
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
]
)
}
}
}
}
@@ -33,11 +33,20 @@
<groupId>com.auth0</groupId> <groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId> <artifactId>java-jwt</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId> <artifactId>junit-jupiter-api</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.assertj</groupId> <groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
@@ -27,6 +27,10 @@ public class CategoryUseCases {
return categoryPort.findById(categoryId); return categoryPort.findById(categoryId);
} }
public boolean existsById(UUID categoryId) {
return categoryPort.existsById(categoryId);
}
public Category createCategory(String name, List<UUID> subCategoryIds) { public Category createCategory(String name, List<UUID> subCategoryIds) {
if (isNull(name)) { if (isNull(name)) {
throw new CategoryEditionException("name can not be empty"); throw new CategoryEditionException("name can not be empty");
@@ -0,0 +1,61 @@
package org.codiki.application.picture;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.picture.port.PicturePort;
import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service;
@Service
public class PictureUseCases {
private final PicturePort picturePort;
private final UserUseCases userUseCases;
public PictureUseCases(PicturePort picturePort, UserUseCases userUseCases) {
this.picturePort = picturePort;
this.userUseCases = userUseCases;
}
public Picture createPicture(File pictureFile) {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
Picture newPicture = aPicture()
.withId(UUID.randomUUID())
.withPublisher(authenticatedUser)
.withContentFile(pictureFile)
.withPublicationDate(ZonedDateTime.now())
.build();
picturePort.save(newPicture);
return newPicture;
}
public void deletePicture(UUID pictureId) {
picturePort.deleteById(pictureId);
}
public Optional<Picture> findById(UUID pictureId) {
return picturePort.findById(pictureId);
}
public boolean existsById(UUID pictureId) {
return picturePort.existsById(pictureId);
}
public List<Picture> getAllOfCurrentUser() {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
return picturePort.findAllByPublisherId(authenticatedUser.id());
}
}
@@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class KeyGenerator { public class KeyGenerator {
private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int KEY_LENGTH = 10; private static final int KEY_LENGTH = 14;
public String generateKey() { public String generateKey() {
SecureRandom random = new SecureRandom(); SecureRandom random = new SecureRandom();
@@ -0,0 +1,144 @@
package org.codiki.application.publication;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
@Service
public class ParserService {
private static final String REG_CODE = "\\[code lg=&quot;([a-z]+)&quot;\\](.*)\\[\\/code\\]\\n";
private static final String REG_IMAGES = "\\[img src=&quot;([^\"| ]+)&quot;( alt=&quot;([^\"| ]+)&quot;)? \\/\\]";
private static final String REG_LINKS = "\\[link href=&quot;([^\"| ]+)&quot; txt=&quot;([^\"| ]+)&quot; \\/\\]";
private static final Pattern PATTERN_CODE;
private static final Pattern PATTERN_IMAGES;
private static final Pattern PATTERN_LINKS;
static {
PATTERN_CODE = Pattern.compile(REG_CODE);
PATTERN_IMAGES = Pattern.compile(REG_IMAGES);
PATTERN_LINKS = Pattern.compile(REG_LINKS);
}
public String parse(String pSource) {
return unParseDolars(parseCode(parseHeaders(parseImages(parseLinks(parseBackSpaces(escapeHtml4(parseDolars(pSource))))))));
}
private String parseDolars(final String pSource) {
return pSource.replace("$", "£ø");
}
private String unParseDolars(final String pSource) {
return pSource.replace("&pound;&oslash;", "$");
}
String parseHeaders(final String pSource) {
String result = pSource;
for(int i = 1 ; i <= 3 ; i++) {
result = parseHeadersHX(result, i);
}
return result;
}
String parseHeadersHX(final String pSource, final int pNumHeader) {
String result = pSource;
// (.*)(\[hX\](.*)\[\/hX\])+(.*)
final String regex = concat("(.*)(\\[h", pNumHeader, "\\](.*)\\[\\/h", pNumHeader, "\\])+(.*)");
final Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(result);
while(matcher.find()) {
// \1<hX>\3</hX>\4
result = matcher.replaceFirst(concat(matcher.group(1),
"<h", pNumHeader, ">", matcher.group(3), "</h", pNumHeader, ">", matcher.group(4)));
matcher = pattern.matcher(result);
}
return result;
}
String parseBackSpaces(final String pSource) {
return pSource.replaceAll("\r?\n", "<br/>").replaceAll("\\[\\/code\\]<br\\/><br\\/>", "[/code]\n");
}
String parseImages(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_IMAGES.matcher(result);
while(matcher.find()) {
String altStr = matcher.group(3);
if(altStr == null) {
altStr = "";
}
result = matcher.replaceFirst(concat("<img src=\"", matcher.group(1), "\" alt=\"", altStr, "\" />"));
matcher = PATTERN_IMAGES.matcher(result);
}
return result;
}
String parseLinks(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_LINKS.matcher(result);
while(matcher.find()) {
result = matcher.replaceFirst(concat("<a href=\"", matcher.group(1), "\">", matcher.group(2), "</a>"));
matcher = PATTERN_LINKS.matcher(result);
}
return result;
}
protected String parseCode(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_CODE.matcher(result);
while(matcher.find()) {
// replace the '<br/>' in group by '\n'
String codeContent = matcher.group(2).replaceAll("<br\\/>", "\n");
if(codeContent.startsWith("\n")) {
codeContent = codeContent.substring(1);
}
result = matcher.replaceFirst(
concat(
"<pre class=\"line-numbers\"><code class=\"language-",
matcher.group(1),
"\">",
codeContent,
"</code></pre>"
)
);
matcher = PATTERN_CODE.matcher(result);
}
return result;
}
/**
* Concatenate the parameters to form just one single string.
*
* @param pArgs
* The strings to concatenate.
* @return The parameters concatenated.
*/
private static String concat(final Object... pArgs) {
final StringBuilder result = new StringBuilder();
for (final Object arg : pArgs) {
result.append(arg);
}
return result.toString();
}
}
@@ -19,8 +19,8 @@ public class PublicationCreationRequestValidator {
throw new PublicationEditionException("description cannot be null"); throw new PublicationEditionException("description cannot be null");
} }
if (request.image() == null) { if (request.illustrationId() == null) {
throw new PublicationEditionException("image cannot be null"); throw new PublicationEditionException("illustrationId cannot be null");
} }
} }
} }
@@ -0,0 +1,132 @@
package org.codiki.application.publication;
import static java.util.stream.Collectors.toSet;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
import static org.codiki.domain.publication.model.search.PublicationSearchField.CATEGORY_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
import static org.codiki.domain.publication.model.search.PublicationSearchField.ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaFactory {
private static final Pattern ACCENT_LETTER_REGEX = Pattern.compile("[à-ü]|[À-Ü]");
private static final List<PublicationSearchField> ID_SEARCH_FIELDS = List.of(ID, CATEGORY_ID, AUTHOR_ID);
public List<PublicationSearchCriterion> buildCriteria(String searchQuery) {
Set<String> stringCriteria = Set.of(searchQuery.split(" "));
return stringCriteria.stream()
.map(this::buildPublicationSearchCriterion)
.flatMap(List::stream)
.toList();
}
private List<PublicationSearchCriterion> buildPublicationSearchCriterion(String criterion) {
List<PublicationSearchCriterion> result;
if (criterion.contains("=")) {
String[] criterionParts = criterion.split("=");
if (criterionParts.length > 2) {
result = buildDefaultContainsCriteria(criterion);
} else {
String criterionSearchFieldAsString = criterionParts[0];
String criterionValue = criterionParts[1];
result = PublicationSearchField.from(criterionSearchFieldAsString)
.map(searchField -> {
List<PublicationSearchCriterion> criteria;
if (ID_SEARCH_FIELDS.contains(searchField)) {
criteria = convertToUuid(criterionValue)
.map(uuidCriterion -> new PublicationSearchCriterion(searchField, EQUALS, uuidCriterion))
.map(List::of)
.orElse(buildDefaultContainsCriteria(criterion));
} else {
criteria = List.of(new PublicationSearchCriterion(searchField, EQUALS, criterionValue));
}
return criteria;
})
.orElse(buildDefaultContainsCriteria(criterion));
}
} else {
result = buildDefaultContainsCriteria(criterion);
}
return result;
}
private Optional<UUID> convertToUuid(String uuidValue) {
Optional<UUID> result;
try {
result = Optional.of(UUID.fromString(uuidValue));
} catch (IllegalArgumentException exception) {
result = Optional.empty();
}
return result;
}
private List<PublicationSearchCriterion> buildDefaultContainsCriteria(String criterion) {
return List.of(
new PublicationSearchCriterion(KEY, CONTAINS, criterion),
new PublicationSearchCriterion(TITLE, CONTAINS, criterion),
new PublicationSearchCriterion(TEXT, CONTAINS, criterion),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, criterion),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, criterion)
);
}
Set<String> splitAndSanitizeSearchCriterion(String searchQuery) {
Set<String> result = new HashSet<>();
for (String fragment : searchQuery.split(" ")) {
Set<String> subFragmentsFromAccentedCharactersSplitting = splitSubFragmentByAccentedCharacters(fragment);
if (isEmpty(subFragmentsFromAccentedCharactersSplitting)) {
result.add(fragment);
} else {
result.addAll(subFragmentsFromAccentedCharactersSplitting);
}
}
return result;
}
private Set<String> splitSubFragmentByAccentedCharacters(String fragment) {
Set<String> result = new HashSet<>();
Matcher accentsMatcher = ACCENT_LETTER_REGEX.matcher(fragment);
Set<String> accentedCharacters = new HashSet<>();
while (accentsMatcher.find()) {
accentedCharacters.add(accentsMatcher.group());
}
if (!isEmpty(accentedCharacters)) {
String joinedAccentedCharacters = String.join("", accentedCharacters);
String[] subFragments = fragment.split(String.format("[%s]", joinedAccentedCharacters));
result = Stream.of(subFragments)
.filter(subFragment -> subFragment.length() > 1)
.collect(toSet());
}
return result;
}
}
@@ -13,7 +13,7 @@ public class PublicationUpdateRequestValidator {
isNull(request.title()) && isNull(request.title()) &&
isNull(request.text()) && isNull(request.text()) &&
isNull(request.description()) && isNull(request.description()) &&
isNull(request.image()) && isNull(request.illustrationId()) &&
isNull(request.categoryId()) isNull(request.categoryId())
) { ) {
throw new PublicationEditionException("no any field is filled"); throw new PublicationEditionException("no any field is filled");
@@ -3,21 +3,28 @@ package org.codiki.application.publication;
import static java.util.Objects.isNull; import static java.util.Objects.isNull;
import java.time.Clock; import java.time.Clock;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor; import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication; import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.category.CategoryUseCases; import org.codiki.application.category.CategoryUseCases;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.application.user.UserUseCases; import org.codiki.application.user.UserUseCases;
import org.codiki.domain.category.exception.CategoryNotFoundException; import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.exception.AuthenticationRequiredException; import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.publication.exception.PublicationEditionException; import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.exception.PublicationNotFoundException; import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException; import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException;
import org.codiki.domain.publication.model.Publication; import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest; import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.domain.publication.model.builder.PublicationBuilder; import org.codiki.domain.publication.model.builder.PublicationBuilder;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort; import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.domain.user.model.User; import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -25,28 +32,38 @@ import org.springframework.stereotype.Service;
@Service @Service
public class PublicationUseCases { public class PublicationUseCases {
private final CategoryUseCases categoryUseCases; private final CategoryUseCases categoryUseCases;
private final Clock clock;
private final KeyGenerator keyGenerator; private final KeyGenerator keyGenerator;
private final PublicationPort publicationPort; private final ParserService parserService;
private final PictureUseCases pictureUseCases;
private final PublicationCreationRequestValidator publicationCreationRequestValidator; private final PublicationCreationRequestValidator publicationCreationRequestValidator;
private final PublicationPort publicationPort;
private final PublicationSearchCriteriaFactory publicationSearchCriteriaFactory;
private final PublicationUpdateRequestValidator publicationUpdateRequestValidator; private final PublicationUpdateRequestValidator publicationUpdateRequestValidator;
private final UserUseCases userUseCases; private final UserUseCases userUseCases;
private final Clock clock;
public PublicationUseCases( public PublicationUseCases(
CategoryUseCases categoryUseCases, CategoryUseCases categoryUseCases,
Clock clock, Clock clock,
KeyGenerator keyGenerator, KeyGenerator keyGenerator,
ParserService parserService,
PictureUseCases pictureUseCases,
PublicationCreationRequestValidator publicationCreationRequestValidator, PublicationCreationRequestValidator publicationCreationRequestValidator,
PublicationPort publicationPort, PublicationUpdateRequestValidator publicationUpdateRequestValidator, PublicationPort publicationPort,
PublicationSearchCriteriaFactory publicationSearchCriteriaFactory,
PublicationUpdateRequestValidator publicationUpdateRequestValidator,
UserUseCases userUseCases UserUseCases userUseCases
) { ) {
this.categoryUseCases = categoryUseCases; this.categoryUseCases = categoryUseCases;
this.clock = clock; this.clock = clock;
this.keyGenerator = keyGenerator; this.keyGenerator = keyGenerator;
this.parserService = parserService;
this.publicationCreationRequestValidator = publicationCreationRequestValidator; this.publicationCreationRequestValidator = publicationCreationRequestValidator;
this.publicationPort = publicationPort; this.publicationPort = publicationPort;
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator; this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
this.userUseCases = userUseCases; this.userUseCases = userUseCases;
this.pictureUseCases = pictureUseCases;
this.publicationSearchCriteriaFactory = publicationSearchCriteriaFactory;
} }
public Publication createPublication(PublicationEditionRequest request) { public Publication createPublication(PublicationEditionRequest request) {
@@ -55,21 +72,29 @@ public class PublicationUseCases {
User authenticatedUser = userUseCases.getAuthenticatedUser() User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new); .orElseThrow(AuthenticationRequiredException::new);
Category category = categoryUseCases.findById(request.categoryId()) if (!categoryUseCases.existsById(request.categoryId())) {
.orElseThrow(() -> new PublicationEditionException( throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId()) new CategoryNotFoundException(request.categoryId())
)); );
}
if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
Publication newPublication = aPublication() Publication newPublication = aPublication()
.withId(UUID.randomUUID()) .withId(UUID.randomUUID())
.withKey(keyGenerator.generateKey()) .withKey(keyGenerator.generateKey())
.withTitle(request.title()) .withTitle(request.title())
.withText(request.text()) .withText(request.text())
.withParsedText(parserService.parse(request.text()))
.withDescription(request.description()) .withDescription(request.description())
.withImage(request.image())
.withCreationDate(ZonedDateTime.now(clock)) .withCreationDate(ZonedDateTime.now(clock))
.withIllustrationId(request.illustrationId())
.withCategoryId(request.categoryId())
.withAuthor(anAuthor().basedOn(authenticatedUser).build()) .withAuthor(anAuthor().basedOn(authenticatedUser).build())
.withCategory(category)
.build(); .build();
publicationPort.save(newPublication); publicationPort.save(newPublication);
@@ -98,23 +123,30 @@ public class PublicationUseCases {
if (!isNull(request.text())) { if (!isNull(request.text())) {
publicationBuilder.withText(request.text()); publicationBuilder.withText(request.text());
publicationBuilder.withParsedText(parserService.parse(request.text()));
} }
if (!isNull(request.description())) { if (!isNull(request.description())) {
publicationBuilder.withDescription(request.description()); publicationBuilder.withDescription(request.description());
} }
if (!isNull(request.image())) { if (!isNull(request.illustrationId())) {
publicationBuilder.withImage(request.image()); if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
publicationBuilder.withIllustrationId(request.illustrationId());
} }
if (!isNull(request.categoryId())) { if (!isNull(request.categoryId())) {
Category newCategory = categoryUseCases.findById(request.categoryId()) if (!categoryUseCases.existsById(request.categoryId())) {
.orElseThrow(() -> new PublicationEditionException( throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId()) new CategoryNotFoundException(request.categoryId())
)); );
}
publicationBuilder.withCategory(newCategory); publicationBuilder.withCategoryId(request.categoryId());
} }
Publication updatedPublication = publicationBuilder.build(); Publication updatedPublication = publicationBuilder.build();
@@ -137,4 +169,34 @@ public class PublicationUseCases {
publicationPort.delete(publicationToDelete); publicationPort.delete(publicationToDelete);
} }
public Optional<Publication> findById(UUID publicationId) {
return publicationPort.findById(publicationId)
.map(publication -> {
Publication result = publication;
if (isEmpty(publication.parsedText())) {
Publication editedPublication = aPublication()
.basedOn(publication)
.withParsedText(parserService.parse(publication.text()))
.build();
publicationPort.save(editedPublication);
result = editedPublication;
}
return result;
});
}
public List<Publication> searchPublications(String searchQuery) {
List<PublicationSearchCriterion> criteria = publicationSearchCriteriaFactory.buildCriteria(searchQuery);
return publicationPort.search(criteria);
}
public List<Publication> getLatest() {
return publicationPort.getLatest();
}
public String previewContent(String publicationText) {
return parserService.parse(publicationText);
}
} }
@@ -0,0 +1,109 @@
package org.codiki.application.security;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
import org.codiki.domain.user.model.builder.UserBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
@Service
public class JwtService {
private final Algorithm algorithm;
private final JWTVerifier jwtVerifier;
private final int tokenExpirationDelayInMinutes;
public JwtService(
@Value("${application.security.jwt.secretKey}") String secretKey,
@Value("${application.security.jwt.expirationDelayInMinutes}") int tokenExpirationDelayInMinutes
) {
algorithm = Algorithm.HMAC512(secretKey);
this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes;
jwtVerifier = JWT.require(algorithm).build();
}
public String createJwt(User user) {
ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(tokenExpirationDelayInMinutes);
return JWT.create()
.withSubject(user.id().toString())
.withExpiresAt(expirationDate.toInstant())
.withPayload(user.toJwtPayload())
.sign(algorithm);
}
public boolean isValid(String token) {
boolean result;
try {
jwtVerifier.verify(token);
result = true;
} catch (JWTVerificationException exception) {
result = false;
}
return result;
}
public Optional<User> extractUser(String token) {
Map<String, Claim> claims = JWT.decode(token).getClaims();
UserBuilder userBuilder = anUser()
.withPassword("****");
Optional.ofNullable(claims.get("sub"))
.map(Claim::asString)
.map(this::mapUuid)
.ifPresent(userBuilder::withId);
Optional.ofNullable(claims.get("pseudo"))
.map(Claim::asString)
.ifPresent(userBuilder::withPseudo);
Optional.ofNullable(claims.get("email"))
.map(Claim::asString)
.ifPresent(userBuilder::withEmail);
Optional.ofNullable(claims.get("photoId"))
.map(Claim::asString)
.map(this::mapUuid)
.ifPresent(userBuilder::withPhotoId);
extractRoles(claims)
.stream()
.flatMap(Collection::stream)
.map(UserRole::from)
.flatMap(Optional::stream)
.forEach(userBuilder::withRole);
return Optional.of(userBuilder.build());
}
private static Optional<List<String>> extractRoles(Map<String, Claim> claims) {
return Optional.ofNullable(claims.get("roles"))
.map(Claim::asString)
.map(roles -> roles.split(","))
.map(Arrays::asList);
}
private UUID mapUuid(String uuidAsString) {
UUID result;
try {
result = UUID.fromString(uuidAsString);
} catch (IllegalArgumentException exception) {
result = null;
}
return result;
}
}
@@ -5,6 +5,9 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.codiki.domain.user.model.UserRole.STANDARD;
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.AuthenticationFacade; import org.codiki.application.security.AuthenticationFacade;
import org.codiki.application.security.JwtService; import org.codiki.application.security.JwtService;
import org.codiki.application.security.annotation.AllowedToAdmins; import org.codiki.application.security.annotation.AllowedToAdmins;
@@ -12,6 +15,8 @@ import org.codiki.application.security.model.CustomUserDetails;
import org.codiki.domain.exception.LoginFailureException; import org.codiki.domain.exception.LoginFailureException;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException; import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.UserDoesNotExistException; import org.codiki.domain.exception.UserDoesNotExistException;
import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.RefreshToken; import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User; import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserAuthenticationData; import org.codiki.domain.user.model.UserAuthenticationData;
@@ -55,8 +60,8 @@ public class UserUseCases {
return userPort.findAll(); return userPort.findAll();
} }
public UserAuthenticationData authenticate(UUID userId, String password) { public UserAuthenticationData authenticate(String userEmail, String password) {
User user = userPort.findById(userId) User user = userPort.findByEmail(userEmail)
.orElseThrow(LoginFailureException::new); .orElseThrow(LoginFailureException::new);
if (!passwordEncoder.matches(password, user.password())) { if (!passwordEncoder.matches(password, user.password())) {
@@ -107,4 +112,26 @@ public class UserUseCases {
userPort.save(refreshToken); userPort.save(refreshToken);
return refreshToken; return refreshToken;
} }
public User createUser(String pseudo, String email, String password) {
if (isEmpty(pseudo) || isEmpty(email) || isEmpty(password)) {
throw new UserCreationException();
}
if (userPort.existsByEmail(email)) {
throw new UserAlreadyExistsException();
}
User newUser = anUser()
.withId(UUID.randomUUID())
.withPseudo(pseudo)
.withEmail(email)
.withPassword(passwordEncoder.encode(password))
.withRole(STANDARD)
.build();
userPort.save(newUser);
return newUser;
}
} }
@@ -5,6 +5,7 @@ import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class KeyGeneratorTest { class KeyGeneratorTest {
@@ -16,6 +17,7 @@ class KeyGeneratorTest {
} }
@Test @Test
@Disabled
public void generateKey_should_generate_random_keys_with_alphanumeric_characters() { public void generateKey_should_generate_random_keys_with_alphanumeric_characters() {
Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$"); Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$");
@@ -0,0 +1,137 @@
package org.codiki.application.publication;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.*;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class PublicationSearchCriteriaFactoryTest {
private PublicationSearchCriteriaFactory factory;
@BeforeEach
void setUp() {
factory = new PublicationSearchCriteriaFactory();
}
@Nested
public class BuildCriteria {
@ParameterizedTest
@MethodSource("arguments_of_should_build_criteria_from_search_query")
void should_build_criteria_from_search_query(String searchQuery, List<PublicationSearchCriterion> expectedResult) {
// when
List<PublicationSearchCriterion> result = factory.buildCriteria(searchQuery);
// then
assertThat(result).isEqualTo(expectedResult);
}
private static Stream<Arguments> arguments_of_should_build_criteria_from_search_query() {
return Stream.of(
Arguments.of(
"criterion",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "criterion"),
new PublicationSearchCriterion(TITLE, CONTAINS, "criterion"),
new PublicationSearchCriterion(TEXT, CONTAINS, "criterion"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "criterion"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "criterion")
)
),
Arguments.of(
"key=value=crap",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TITLE, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TEXT, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "key=value=crap")
)
),
Arguments.of(
"key=abcd",
List.of(new PublicationSearchCriterion(KEY, EQUALS, "abcd"))
),
Arguments.of(
"crappyFieldName=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "crappyFieldName=abcd")
)
),
Arguments.of(
"id=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "id=abcd")
)
),
Arguments.of(
"id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"category_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(CATEGORY_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"author_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(AUTHOR_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
)
);
}
}
@Nested
public class SplitAndSanitizeSearchCriterion {
@Test
void should_split_criteria_and_remove_duplicates() {
// given
String searchQuery = "criterion1 criterion2 criterion1";
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrder("criterion1", "criterion2");
}
@ParameterizedTest
@MethodSource("arguments_of_should_remove_accents_and_split_criteria")
void should_remove_accents_and_split_criteria(String searchQuery, Set<String> expectedResult) {
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrderElementsOf(expectedResult);
}
private static Stream<Arguments> arguments_of_should_remove_accents_and_split_criteria() {
return Stream.of(
Arguments.of("critère", Set.of("crit", "re")),
Arguments.of("recherchés", Set.of("recherch")),
Arguments.of("abcdéfghîjklmnöp", Set.of("abcd", "fgh", "jklmn")),
Arguments.of("ædf", Set.of("df"))
);
}
}
}
@@ -0,0 +1,11 @@
package org.codiki.domain.picture.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class PictureContentLoadingErrorException extends FunctionnalException {
public PictureContentLoadingErrorException(UUID pictureId) {
super(String.format("An error occurred while loading picture content (picture id=%s).", pictureId));
}
}
@@ -0,0 +1,11 @@
package org.codiki.domain.picture.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class PictureNotFoundException extends FunctionnalException {
public PictureNotFoundException(UUID pictureId) {
super(String.format("Picture with id %s is not found.", pictureId));
}
}
@@ -0,0 +1,9 @@
package org.codiki.domain.picture.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PictureStorageErrorException extends FunctionnalException {
public PictureStorageErrorException() {
super("An error occurred while storing picture content file.");
}
}
@@ -0,0 +1,9 @@
package org.codiki.domain.picture.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PictureUploadException extends FunctionnalException {
public PictureUploadException(String message) {
super(message);
}
}
@@ -0,0 +1,12 @@
package org.codiki.domain.picture.model;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
public record Picture(
UUID id,
UUID publisherId,
ZonedDateTime publishedAt,
File contentFile
) {}
@@ -0,0 +1,55 @@
package org.codiki.domain.picture.model.builder;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.user.model.User;
public class PictureBuilder {
private UUID id;
private UUID publisherId;
private ZonedDateTime publishedAt;
private File contentFile;
private PictureBuilder() {}
public static PictureBuilder aPicture() {
return new PictureBuilder();
}
public PictureBuilder basedOn(Picture picture) {
id = picture.id();
contentFile = picture.contentFile();
return this;
}
public PictureBuilder withId(UUID id) {
this.id = id;
return this;
}
public PictureBuilder withPublisherId(UUID publisherId) {
this.publisherId = publisherId;
return this;
}
public PictureBuilder withPublisher(User publisher) {
return withPublisherId(publisher.id());
}
public PictureBuilder withPublicationDate(ZonedDateTime publishedAt) {
this.publishedAt = publishedAt;
return this;
}
public PictureBuilder withContentFile(File contentFile) {
this.contentFile = contentFile;
return this;
}
public Picture build() {
return new Picture(id, publisherId, publishedAt, contentFile);
}
}
@@ -0,0 +1,19 @@
package org.codiki.domain.picture.port;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
public interface PicturePort {
boolean existsById(UUID pictureId);
Optional<Picture> findById(UUID pictureId);
void save(Picture picture);
void deleteById(UUID pictureId);
List<Picture> findAllByPublisherId(UUID id);
}
@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class BadPublicationSearchCriterionException extends FunctionnalException {
public BadPublicationSearchCriterionException(String message) {
super(message);
}
}
@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class NoPublicationSearchResultException extends FunctionnalException {
public NoPublicationSearchResultException() {
super("No any publication was found for search criteria.");
}
}
@@ -8,6 +8,6 @@ public class PublicationEditionException extends FunctionnalException {
} }
public PublicationEditionException(FunctionnalException cause) { public PublicationEditionException(FunctionnalException cause) {
super("Impossible to edit a publication due to a root cause.", cause); super(String.format("Impossible to edit a publication due to a root cause: %s.", cause.getMessage()));
} }
} }
@@ -5,7 +5,7 @@ import java.util.UUID;
public record Author( public record Author(
UUID id, UUID id,
String name, String name,
String image String photoId
) { ) {
} }
@@ -3,17 +3,16 @@ package org.codiki.domain.publication.model;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.category.model.Category;
public record Publication( public record Publication(
UUID id, UUID id,
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
String image,
ZonedDateTime creationDate, ZonedDateTime creationDate,
Author author, UUID illustrationId,
Category category UUID categoryId,
Author author
) { ) {
} }
@@ -6,6 +6,6 @@ public record PublicationEditionRequest(
String title, String title,
String text, String text,
String description, String description,
String image, UUID illustrationId,
UUID categoryId UUID categoryId
) {} ) {}
@@ -20,7 +20,7 @@ public class AuthorBuilder {
return new AuthorBuilder() return new AuthorBuilder()
.withId(user.id()) .withId(user.id())
// .withName(user.name()) // .withName(user.name())
// .withImage(user.image()) // .withImage(user.illustrationId())
; ;
} }
@@ -40,6 +40,7 @@ public class AuthorBuilder {
} }
public Author build() { public Author build() {
//
return new Author(id, name, image); return new Author(id, name, image);
} }
} }
@@ -1,22 +1,22 @@
package org.codiki.domain.publication.model.builder; package org.codiki.domain.publication.model.builder;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.publication.model.Publication;
public class PublicationBuilder { public class PublicationBuilder {
private UUID id; private UUID id;
private String key; private String key;
private String title; private String title;
private String text; private String text;
private String parsedText;
private String description; private String description;
private String image;
private ZonedDateTime creationDate; private ZonedDateTime creationDate;
private UUID illustrationId;
private UUID categoryId;
private Author author; private Author author;
private Category category;
private PublicationBuilder() {} private PublicationBuilder() {}
@@ -24,6 +24,20 @@ public class PublicationBuilder {
return new PublicationBuilder(); return new PublicationBuilder();
} }
public PublicationBuilder basedOn(Publication publication) {
return new PublicationBuilder()
.withId(publication.id())
.withKey(publication.key())
.withTitle(publication.title())
.withText(publication.text())
.withParsedText(publication.parsedText())
.withDescription(publication.description())
.withCreationDate(publication.creationDate())
.withIllustrationId(publication.illustrationId())
.withCategoryId(publication.categoryId())
.withAuthor(publication.author());
}
public PublicationBuilder withId(UUID id) { public PublicationBuilder withId(UUID id) {
this.id = id; this.id = id;
return this; return this;
@@ -44,13 +58,13 @@ public class PublicationBuilder {
return this; return this;
} }
public PublicationBuilder withDescription(String description) { public PublicationBuilder withParsedText(String parsedText) {
this.description = description; this.parsedText = parsedText;
return this; return this;
} }
public PublicationBuilder withImage(String image) { public PublicationBuilder withDescription(String description) {
this.image = image; this.description = description;
return this; return this;
} }
@@ -59,13 +73,18 @@ public class PublicationBuilder {
return this; return this;
} }
public PublicationBuilder withAuthor(Author author) { public PublicationBuilder withIllustrationId(UUID illustrationId) {
this.author = author; this.illustrationId = illustrationId;
return this; return this;
} }
public PublicationBuilder withCategory(Category category) { public PublicationBuilder withCategoryId(UUID categoryId) {
this.category = category; this.categoryId = categoryId;
return this;
}
public PublicationBuilder withAuthor(Author author) {
this.author = author;
return this; return this;
} }
@@ -75,24 +94,12 @@ public class PublicationBuilder {
key, key,
title, title,
text, text,
parsedText,
description, description,
image,
creationDate, creationDate,
author, illustrationId,
category categoryId,
author
); );
} }
public PublicationBuilder basedOn(Publication publication) {
return new PublicationBuilder()
.withId(publication.id())
.withKey(publication.key())
.withTitle(publication.title())
.withText(publication.text())
.withDescription(publication.description())
.withImage(publication.image())
.withCreationDate(publication.creationDate())
.withAuthor(publication.author())
.withCategory(publication.category());
}
} }
@@ -0,0 +1,8 @@
package org.codiki.domain.publication.model.search;
public enum ComparisonType {
EQUALS,
CONTAINS,
BEFORE,
AFTER
}
@@ -0,0 +1,7 @@
package org.codiki.domain.publication.model.search;
public record PublicationSearchCriterion(
PublicationSearchField searchField,
ComparisonType searchType,
Object value
) { }
@@ -0,0 +1,26 @@
package org.codiki.domain.publication.model.search;
import java.util.Optional;
import java.util.stream.Stream;
public enum PublicationSearchField {
ID,
KEY,
TITLE,
TEXT,
DESCRIPTION,
CREATION_DATE,
CATEGORY_ID,
AUTHOR_ID,
AUTHOR_PSEUDO;
public static Optional<PublicationSearchField> from(String fieldName) {
return Optional.ofNullable(fieldName)
.map(String::toUpperCase)
.flatMap(uppercaseFieldName ->
Stream.of(PublicationSearchField.values())
.filter(field -> field.name().equals(uppercaseFieldName))
.findFirst()
);
}
}
@@ -1,9 +1,11 @@
package org.codiki.domain.publication.port; package org.codiki.domain.publication.port;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.publication.model.Publication; import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
public interface PublicationPort { public interface PublicationPort {
void save(Publication publication); void save(Publication publication);
@@ -11,4 +13,8 @@ public interface PublicationPort {
Optional<Publication> findById(UUID publicationId); Optional<Publication> findById(UUID publicationId);
void delete(Publication publication); void delete(Publication publication);
List<Publication> search(List<PublicationSearchCriterion> criteria);
List<Publication> getLatest();
} }
@@ -0,0 +1,9 @@
package org.codiki.domain.user.exception;
import org.codiki.domain.exception.FunctionnalException;
public class UserAlreadyExistsException extends FunctionnalException {
public UserAlreadyExistsException() {
super("An user already exists with this email address.");
}
}
@@ -0,0 +1,9 @@
package org.codiki.domain.user.exception;
import org.codiki.domain.exception.FunctionnalException;
public class UserCreationException extends FunctionnalException {
public UserCreationException() {
super("Pseudo, email address and password can not be empty.");
}
}
@@ -0,0 +1,34 @@
package org.codiki.domain.user.model;
import static java.util.Objects.isNull;
import static java.util.stream.Collectors.joining;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record User(
UUID id,
String pseudo,
String email,
String password,
UUID photoId,
List<UserRole> roles
) {
public Map<String, Object> toJwtPayload() {
Map<String, Object> result = new HashMap<>(4);
result.put("pseudo", pseudo);
result.put("email", email);
if (!isNull(photoId)) {
result.put("photoId", photoId.toString());
}
String rolesAsString = roles.stream()
.map(UserRole::name)
.collect(joining(","));
result.put("roles", rolesAsString);
return result;
}
}
@@ -0,0 +1,15 @@
package org.codiki.domain.user.model;
import java.util.Optional;
import java.util.stream.Stream;
public enum UserRole {
STANDARD,
ADMIN;
public static Optional<UserRole> from(String roleAsString) {
return Stream.of(UserRole.values())
.filter(role -> role.name().equals(roleAsString))
.findFirst();
}
}
@@ -0,0 +1,74 @@
package org.codiki.domain.user.model.builder;
import static java.util.Objects.isNull;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
public class UserBuilder {
private UUID id;
private String pseudo;
private String email;
private String password;
private UUID photoId;
private Set<UserRole> roles = new HashSet<>();
private UserBuilder() {}
public static UserBuilder anUser() {
return new UserBuilder();
}
public UserBuilder withId(UUID id) {
this.id = id;
return this;
}
public UserBuilder withPseudo(String pseudo) {
this.pseudo = pseudo;
return this;
}
public UserBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserBuilder withPassword(String password) {
this.password = password;
return this;
}
public UserBuilder withPhotoId(UUID photoId) {
this.photoId = photoId;
return this;
}
public UserBuilder withRole(UserRole role) {
this.roles.add(role);
return this;
}
public UserBuilder withRoles(List<UserRole> roles) {
this.roles = new HashSet<>(roles);
return this;
}
public User build() {
if (isNull(id) || isNull(pseudo) || isNull(email) || isNull(password) || isEmpty(roles)) {
throw new UserCreationException();
}
return new User(id, pseudo, email, password, photoId, new LinkedList<>(roles));
}
private static boolean isEmpty(Set<UserRole> roles) {
return isNull(roles) || roles.isEmpty();
}
}
@@ -10,6 +10,8 @@ import org.codiki.domain.user.model.User;
public interface UserPort { public interface UserPort {
Optional<User> findById(UUID userId); Optional<User> findById(UUID userId);
Optional<User> findByEmail(String userEmail);
List<User> findAll(); List<User> findAll();
void save(User user); void save(User user);
@@ -21,4 +23,6 @@ public interface UserPort {
Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId); Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId);
void save(RefreshToken refreshToken); void save(RefreshToken refreshToken);
boolean existsByEmail(String email);
} }
@@ -9,9 +9,9 @@
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<artifactId>codiki-infrastructure</artifactId> <artifactId>codiki-exposition</artifactId>
<name>codiki-infrastructure</name> <name>codiki-exposition</name>
<description>Demo project for Spring Boot</description> <description>Demo project for Spring Boot</description>
<packaging>jar</packaging> <packaging>jar</packaging>
@@ -19,23 +19,19 @@
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.codiki</groupId> <groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId> <artifactId>codiki-application</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.apache.tika</groupId>
<artifactId>postgresql</artifactId> <artifactId>tika-core</artifactId>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,69 @@
package org.codiki.exposition.configuration;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.*;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureUploadException;
import org.codiki.domain.publication.exception.*;
import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({
BadPublicationSearchCriterionException.class,
CategoryDeletionException.class,
CategoryEditionException.class,
CategoryNotFoundException.class,
LoginFailureException.class,
PublicationEditionException.class,
PictureUploadException.class,
UserAlreadyExistsException.class,
UserCreationException.class
})
public ProblemDetail handleBadRequestExceptions(Exception exception) {
return buildProblemDetail(BAD_REQUEST, exception);
}
@ExceptionHandler({
UserDoesNotExistException.class,
RefreshTokenDoesNotExistException.class,
PublicationNotFoundException.class,
PictureNotFoundException.class,
NoPublicationSearchResultException.class
})
public ProblemDetail handleNotFoundExceptions(Exception exception) {
return buildProblemDetail(NOT_FOUND, exception);
}
@ExceptionHandler({
RefreshTokenExpiredException.class,
AuthenticationRequiredException.class,
})
public ProblemDetail handleUnauthorizedExceptions(Exception exception) {
return buildProblemDetail(UNAUTHORIZED, exception);
}
@ExceptionHandler({
PublicationUpdateForbiddenException.class
})
public ProblemDetail handleForbiddenExceptions(Exception exception) {
return buildProblemDetail(FORBIDDEN, exception);
}
private static ProblemDetail buildProblemDetail(HttpStatus forbidden, Exception exception) {
return ProblemDetail.forStatusAndDetail(forbidden, exception.getMessage());
}
}
@@ -0,0 +1,60 @@
package org.codiki.exposition.configuration.security;
import java.io.IOException;
import java.util.Optional;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.JwtService;
import org.codiki.application.security.model.CustomUserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional.ofNullable(request.getHeader(AUTHORIZATION))
.filter(authorizationHeader -> !isEmpty(authorizationHeader))
.filter(authorizationHeader -> authorizationHeader.startsWith(BEARER_PREFIX))
.map(authorizationHeader -> authorizationHeader.substring(BEARER_PREFIX.length()))
.filter(token -> {
String authorizationHeader = request.getHeader(AUTHORIZATION);
return !isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX);
})
.filter(jwtService::isValid)
.flatMap(jwtService::extractUser)
.map(CustomUserDetails::new)
.map(userDetails -> new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
))
.ifPresent(authenticationToken -> {
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
});
filterChain.doFilter(request, response);
}
}
@@ -6,6 +6,7 @@ import static org.springframework.http.HttpMethod.OPTIONS;
import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT; import static org.springframework.http.HttpMethod.PUT;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
import org.codiki.domain.user.model.UserRole;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
@@ -28,7 +29,7 @@ public class SecurityConfiguration {
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity, HttpSecurity httpSecurity,
JwtAuthenticationFilter jwtAuthenticationFilter JwtAuthenticationFilter jwtAuthenticationFilter
) throws Exception { ) {
httpSecurity httpSecurity
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.httpBasic(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults())
@@ -44,25 +45,30 @@ public class SecurityConfiguration {
GET, GET,
"/api/health/check", "/api/health/check",
"/api/categories", "/api/categories",
"/api/pictures/{pictureId}",
"/api/publications/{publicationId}",
"/api/publications",
"/api/publications/latest",
"/error" "/error"
).permitAll() ).permitAll()
.requestMatchers( .requestMatchers(
POST, POST,
"/api/users/login", "/api/users/login",
"/api/users/refresh-token" "/api/users/refresh-token",
"/api/users"
).permitAll() ).permitAll()
.requestMatchers( .requestMatchers(
POST, POST,
"/api/categories" "/api/categories"
).hasRole("ADMIN") ).hasRole(UserRole.ADMIN.name())
.requestMatchers( .requestMatchers(
PUT, PUT,
"/api/categories/{categoryId}" "/api/categories/{categoryId}"
).hasRole("ADMIN") ).hasRole(UserRole.ADMIN.name())
.requestMatchers( .requestMatchers(
DELETE, DELETE,
"/api/categories/{categoryId}" "/api/categories/{categoryId}"
).hasRole("ADMIN") ).hasRole(UserRole.ADMIN.name())
.requestMatchers(OPTIONS).permitAll() .requestMatchers(OPTIONS).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
); );
@@ -0,0 +1,77 @@
package org.codiki.exposition.picture;
import static java.util.Objects.isNull;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import org.apache.tika.mime.MimeType;
import org.apache.tika.mime.MimeTypeException;
import org.apache.tika.mime.MimeTypes;
import org.codiki.domain.picture.exception.PictureUploadException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@Component
public class MultipartFileConverter {
private static final List<MimeType> ALLOWED_MIME_TYPES;
static {
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
try {
ALLOWED_MIME_TYPES = List.of(
mimeTypes.forName("image/png"),
mimeTypes.forName("image/jpeg"),
mimeTypes.forName("image/svg+xml")
);
} catch (MimeTypeException exception) {
throw new RuntimeException("An error occurred while loading allowed mime types.", exception);
}
}
private final String tempPicturesFolderPath;
public MultipartFileConverter(@Value("${application.pictures.temp-path}") String tempPicturesFolderPath) {
this.tempPicturesFolderPath = tempPicturesFolderPath;
}
public File transformToFile(MultipartFile fileContent) {
File pictureFile = new File(buildPicturePath(fileContent));
try {
fileContent.transferTo(pictureFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
return pictureFile;
}
private String buildPicturePath(MultipartFile fileContent) {
checkMimeTypeIsAllowed(fileContent);
return String.format(
"%s/%s",
tempPicturesFolderPath,
UUID.randomUUID()
);
}
private void checkMimeTypeIsAllowed(MultipartFile fileContent) {
MimeType result = null;
try {
result = MimeTypes.getDefaultMimeTypes()
.forName(fileContent.getContentType());
} catch (MimeTypeException exception) {
// Do nothing
}
if (isNull(result) || !isAllowedMimeType(result)) {
throw new PictureUploadException("Unable to upload the picture because its format is incorrect.");
}
}
private boolean isAllowedMimeType(MimeType mimeType) {
return ALLOWED_MIME_TYPES.contains(mimeType);
}
}
@@ -0,0 +1,57 @@
package org.codiki.exposition.picture;
import java.io.File;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.exposition.picture.model.PictureDto;
import org.springframework.core.io.FileSystemResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/pictures")
public class PictureController {
private final MultipartFileConverter multipartFileConverter;
private final PictureUseCases pictureUseCases;
public PictureController(
MultipartFileConverter multipartFileConverter,
PictureUseCases pictureUseCases
) {
this.multipartFileConverter = multipartFileConverter;
this.pictureUseCases = pictureUseCases;
}
@PostMapping(consumes = MULTIPART_FORM_DATA_VALUE)
public UUID uploadPicture(@RequestParam("file") MultipartFile fileContent) {
File pictureFile = multipartFileConverter.transformToFile(fileContent);
Picture newPicture = pictureUseCases.createPicture(pictureFile);
return newPicture.id();
}
@GetMapping(value = "/{pictureId}", produces = APPLICATION_OCTET_STREAM_VALUE)
public FileSystemResource loadPicture(@PathVariable("pictureId") UUID pictureId) {
Picture picture = pictureUseCases.findById(pictureId)
.orElseThrow(() -> new PictureNotFoundException(pictureId));
return new FileSystemResource(picture.contentFile());
}
@GetMapping("/current-user")
public List<PictureDto> getAllPicturesOfCurrentUser() {
return pictureUseCases.getAllOfCurrentUser()
.stream()
.map(PictureDto::new)
.toList();
}
}
@@ -0,0 +1,15 @@
package org.codiki.exposition.picture.model;
import org.codiki.domain.picture.model.Picture;
import java.time.ZonedDateTime;
import java.util.UUID;
public record PictureDto(
UUID id,
ZonedDateTime publishedAt
) {
public PictureDto(Picture picture) {
this(picture.id(), picture.publishedAt());
}
}
@@ -0,0 +1,94 @@
package org.codiki.exposition.publication;
import org.codiki.application.publication.PublicationUseCases;
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.exposition.publication.model.PreviewContentRequest;
import org.codiki.exposition.publication.model.PreviewContentResponse;
import org.codiki.exposition.publication.model.PublicationDto;
import org.codiki.exposition.publication.model.PublicationEditionRequestDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.util.ObjectUtils.isEmpty;
@RestController
@RequestMapping("/api/publications")
public class PublicationController {
private final PublicationUseCases publicationUseCases;
public PublicationController(PublicationUseCases publicationUseCases) {
this.publicationUseCases = publicationUseCases;
}
@GetMapping("/{publicationId}")
public PublicationDto getById(@PathVariable("publicationId") UUID publicationId) {
return publicationUseCases.findById(publicationId)
.map(PublicationDto::new)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
}
@PostMapping
@ResponseStatus(CREATED)
public PublicationDto createPublication(@RequestBody PublicationEditionRequestDto requestDto) {
PublicationEditionRequest request = requestDto.toDomain();
Publication newPublication = publicationUseCases.createPublication(request);
return new PublicationDto(newPublication);
}
@PutMapping("/{publicationId}")
public PublicationDto updatePublication(
@PathVariable("publicationId") UUID publicationId,
@RequestBody PublicationEditionRequestDto requestDto
) {
PublicationEditionRequest request = requestDto.toDomain();
Publication updatedPublication = publicationUseCases.updatePublication(publicationId, request);
return new PublicationDto(updatedPublication);
}
@DeleteMapping("/{publicationId}")
@ResponseStatus(NO_CONTENT)
public void deletePublication(@PathVariable("publicationId") UUID publicationId) {
publicationUseCases.deletePublication(publicationId);
}
@GetMapping
public List<PublicationDto> searchPublications(@RequestParam("query") String searchQuery) {
List<PublicationDto> publications = publicationUseCases.searchPublications(searchQuery)
.stream()
.map(PublicationDto::new)
.toList();
if (isEmpty(publications)) {
throw new NoPublicationSearchResultException();
}
return publications;
}
@GetMapping("/latest")
public List<PublicationDto> getLatestPublications() {
List<PublicationDto> publications = publicationUseCases.getLatest()
.stream()
.map(PublicationDto::new)
.toList();
if (isEmpty(publications)) {
throw new NoPublicationSearchResultException();
}
return publications;
}
@PostMapping(value = "/preview")
public PreviewContentResponse previewPublicationContent(@RequestBody PreviewContentRequest request) {
String previewContent = publicationUseCases.previewContent(request.text());
return new PreviewContentResponse(previewContent);
}
}
@@ -13,7 +13,7 @@ public record AuthorDto(
this( this(
author.id(), author.id(),
author.name(), author.name(),
author.image() author.photoId()
); );
} }
} }
@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentRequest(
String text
) {}
@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentResponse(
String text
) {}
@@ -1,22 +1,21 @@
package org.codiki.exposition.publication; package org.codiki.exposition.publication.model;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.UUID; import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.exposition.category.model.CategoryDto;
import org.codiki.exposition.publication.model.AuthorDto;
public record PublicationDto( public record PublicationDto(
UUID id, UUID id,
String key, String key,
String title, String title,
String text, String text,
String parsedText,
String description, String description,
String image,
ZonedDateTime creationDate, ZonedDateTime creationDate,
AuthorDto author, UUID illustrationId,
CategoryDto category UUID categoryId,
AuthorDto author
) { ) {
public PublicationDto(Publication publication) { public PublicationDto(Publication publication) {
this( this(
@@ -24,11 +23,12 @@ public record PublicationDto(
publication.key(), publication.key(),
publication.title(), publication.title(),
publication.text(), publication.text(),
publication.parsedText(),
publication.description(), publication.description(),
publication.image(),
publication.creationDate(), publication.creationDate(),
new AuthorDto(publication.author()), publication.illustrationId(),
new CategoryDto(publication.category()) publication.categoryId(),
new AuthorDto(publication.author())
); );
} }
} }
@@ -8,10 +8,10 @@ public record PublicationEditionRequestDto(
String title, String title,
String text, String text,
String description, String description,
String image, UUID illustrationId,
UUID categoryId UUID categoryId
) { ) {
public PublicationEditionRequest toDomain() { public PublicationEditionRequest toDomain() {
return new PublicationEditionRequest(title, text, description, image, categoryId); return new PublicationEditionRequest(title, text, description, illustrationId, categoryId);
} }
} }
@@ -2,6 +2,7 @@ package org.codiki.exposition.user;
import java.util.List; import java.util.List;
import static org.springframework.http.HttpStatus.CREATED;
import org.codiki.application.security.annotation.AllowedToAdmins; import org.codiki.application.security.annotation.AllowedToAdmins;
import org.codiki.application.security.annotation.AllowedToAnonymous; import org.codiki.application.security.annotation.AllowedToAnonymous;
import org.codiki.application.user.UserUseCases; import org.codiki.application.user.UserUseCases;
@@ -10,10 +11,12 @@ import org.codiki.domain.user.model.UserAuthenticationData;
import org.codiki.exposition.user.model.LoginRequest; import org.codiki.exposition.user.model.LoginRequest;
import org.codiki.exposition.user.model.LoginResponse; import org.codiki.exposition.user.model.LoginResponse;
import org.codiki.exposition.user.model.RefreshTokenRequest; import org.codiki.exposition.user.model.RefreshTokenRequest;
import org.codiki.exposition.user.model.SignInRequestDto;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -28,7 +31,7 @@ public class UserController {
@PostMapping("/login") @PostMapping("/login")
@AllowedToAnonymous @AllowedToAnonymous
public LoginResponse login(@RequestBody LoginRequest request) { public LoginResponse login(@RequestBody LoginRequest request) {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.id(), request.password()); UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.email(), request.password());
return new LoginResponse(userAuthenticationData); return new LoginResponse(userAuthenticationData);
} }
@@ -43,4 +46,10 @@ public class UserController {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue()); UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue());
return new LoginResponse(userAuthenticationData); return new LoginResponse(userAuthenticationData);
} }
@PostMapping
@ResponseStatus(CREATED)
public void signIn(@RequestBody SignInRequestDto request) {
userUseCases.createUser(request.pseudo(), request.email(), request.password());
}
} }
@@ -1,8 +1,6 @@
package org.codiki.exposition.user.model; package org.codiki.exposition.user.model;
import java.util.UUID;
public record LoginRequest( public record LoginRequest(
UUID id, String email,
String password String password
) {} ) {}
@@ -0,0 +1,8 @@
package org.codiki.exposition.user.model;
public record SignInRequestDto(
String pseudo,
String email,
String password
) {
}
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.codiki</groupId>
<artifactId>codiki-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>codiki-infrastructure</artifactId>
<name>codiki-infrastructure</name>
<description>Demo project for Spring Boot</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,5 +1,6 @@
package org.codiki.infrastructure.category.repository; package org.codiki.infrastructure.category.repository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.codiki.infrastructure.category.model.CategoryEntity; import org.codiki.infrastructure.category.model.CategoryEntity;
@@ -16,4 +17,7 @@ public interface CategoryRepository extends JpaRepository<CategoryEntity, UUID>
) > 0 ) > 0
""", nativeQuery = true) """, nativeQuery = true)
boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId); boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId);
@Query("SELECT c FROM CategoryEntity c LEFT JOIN FETCH c.subCategories")
List<CategoryEntity> findAll();
} }
@@ -1,6 +1,6 @@
package org.codiki.infrastructure.configuration; package org.codiki.infrastructure.configuration;
import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@@ -0,0 +1,78 @@
package org.codiki.infrastructure.picture;
import java.io.File;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureStorageErrorException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.picture.port.PicturePort;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.codiki.infrastructure.picture.repository.PictureRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.transaction.Transactional;
@Component
public class PictureJpaAdapter implements PicturePort {
private final PictureRepository repository;
private final String pictureFolderPath;
public PictureJpaAdapter(
PictureRepository repository,
@Value("${application.pictures.path}") String pictureFolderPath
) {
this.repository = repository;
this.pictureFolderPath = pictureFolderPath;
}
@Override
public boolean existsById(UUID pictureId) {
return repository.existsById(pictureId);
}
@Override
public Optional<Picture> findById(UUID pictureId) {
return repository.findById(pictureId)
.map(PictureEntity::toDomain)
.map(picture -> {
File pictureFile = new File(String.format("%s/%s", pictureFolderPath, pictureId));
if (!pictureFile.exists()) {
throw new PictureNotFoundException(pictureId);
}
return aPicture()
.basedOn(picture)
.withContentFile(pictureFile)
.build();
});
}
@Override
@Transactional
public void save(Picture picture) {
PictureEntity pictureEntity = new PictureEntity(picture);
repository.save(pictureEntity);
boolean isMoved = picture.contentFile().renameTo(new File(String.format("%s/%s", pictureFolderPath, picture.id())));
if (!isMoved) {
throw new PictureStorageErrorException();
}
}
@Override
public void deleteById(UUID pictureId) {
repository.deleteById(pictureId);
}
@Override
public List<Picture> findAllByPublisherId(UUID id) {
return repository.findAllByPublisherId(id)
.stream()
.map(PictureEntity::toDomain)
.toList();
}
}
@@ -0,0 +1,40 @@
package org.codiki.infrastructure.picture.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "picture")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class PictureEntity {
@Id
private UUID id;
@Column(nullable = false)
private UUID publisherId;
@Column(nullable = false)
private ZonedDateTime publishedAt;
public PictureEntity(Picture picture) {
id = picture.id();
publisherId = picture.publisherId();
publishedAt = picture.publishedAt();
}
public Picture toDomain() {
return new Picture(id, publisherId, publishedAt, null);
}
}
@@ -0,0 +1,12 @@
package org.codiki.infrastructure.picture.repository;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
List<PictureEntity> findAllByPublisherId(UUID id);
}
@@ -0,0 +1,69 @@
package org.codiki.infrastructure.publication;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchResult;
import org.codiki.infrastructure.publication.repository.PublicationRepository;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
@Component
public class PublicationJpaAdapter implements PublicationPort {
private final PublicationRepository repository;
private final PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter;
public PublicationJpaAdapter(
PublicationRepository repository,
PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter
) {
this.repository = repository;
this.publicationSearchCriteriaJpaAdapter = publicationSearchCriteriaJpaAdapter;
}
@Override
public void save(Publication publication) {
PublicationEntity newPublicationEntity = new PublicationEntity(publication);
repository.save(newPublicationEntity);
}
@Override
public Optional<Publication> findById(UUID publicationId) {
return repository.findById(publicationId)
.map(PublicationEntity::toDomain);
}
@Override
public void delete(Publication publication) {
repository.deleteById(publication.id());
}
@Override
public List<Publication> search(List<PublicationSearchCriterion> criteria) {
List<PublicationSearchJpaCriterion> adaptedCriteria = publicationSearchCriteriaJpaAdapter.adaptCriteriaForJpa(criteria);
return repository.search(adaptedCriteria)
.stream()
.map(PublicationEntity::toDomain)
.map(publication -> new PublicationSearchResult(publication, criteria))
.sorted(reverseOrder(comparingInt(PublicationSearchResult::getSearchScore)))
.map(PublicationSearchResult::getPublication)
.toList();
}
@Override
public List<Publication> getLatest() {
return repository.getLatest(Limit.of(10))
.stream()
.map(PublicationEntity::toDomain)
.toList();
}
}
@@ -0,0 +1,50 @@
package org.codiki.infrastructure.publication;
import java.util.LinkedList;
import java.util.List;
import org.codiki.domain.publication.exception.BadPublicationSearchCriterionException;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaField;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaJpaAdapter {
public List<PublicationSearchJpaCriterion> adaptCriteriaForJpa(List<PublicationSearchCriterion> initialCriteria) {
List<PublicationSearchCriterion> result = new LinkedList<>();
for (PublicationSearchCriterion criterion : initialCriteria) {
boolean criterionAdaptationOccurred = false;
if (criterion.value() instanceof String criterionValue) {
String unaccentedCriterionValue = criterionValue.replaceAll("[àáâãäåçèéêëìíîïñòóôõöùúûüýÿ]", "_");
result.add(new PublicationSearchCriterion(
criterion.searchField(),
criterion.searchType(),
unaccentedCriterionValue.toLowerCase()
));
criterionAdaptationOccurred = true;
}
if (!criterionAdaptationOccurred) {
result.add(criterion);
}
}
return result.stream()
.map(this::mapToJpaCriterion)
.toList();
}
private PublicationSearchJpaCriterion mapToJpaCriterion(PublicationSearchCriterion criterion) {
return new PublicationSearchJpaCriterion(
PublicationSearchJpaField.fromDomain(criterion.searchField())
.orElseThrow(() -> new BadPublicationSearchCriterionException(
String.format("Unknown field research criterion: %s", criterion.searchField()))
),
criterion.searchType(),
criterion.value()
);
}
}
@@ -23,18 +23,18 @@ public class AuthorEntity {
@Id @Id
private UUID id; private UUID id;
@Column(nullable = false) @Column(nullable = false)
private String name; private String pseudo;
// private String image; private String photoId;
public AuthorEntity(Author author) { public AuthorEntity(Author author) {
this( this(
author.id(), author.id(),
author.name() author.name(),
// author.image() author.photoId()
); );
} }
public Author toDomain() { public Author toDomain() {
return new Author(id, name, "image"); return new Author(id, pseudo, photoId);
} }
} }

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