diff --git a/.env b/.env
index 2c0ee7f..4f83fd2 100644
--- a/.env
+++ b/.env
@@ -1,10 +1,11 @@
-NAME=dc-project
+APP_NAME=dc-project
DATABASE_URL=jdbc:postgresql:dc-project
APP_PORT=8080
OPENAPI_PORT=8181
SONARQUBE_PORT=9002
+SONARQUBE_DB_PORT=5434
ELASTIC_REST=9200
ELASTIC_NODES=9300
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 0dfa848..2ce15a4 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,34 +1,79 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 79ee123..0f7bc51 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
index 3a052ac..2b2a567 100644
--- a/.idea/dataSources.xml
+++ b/.idea/dataSources.xml
@@ -11,7 +11,14 @@
postgresql
true
org.postgresql.Driver
- jdbc:postgresql://localhost:5432/test
+ jdbc:postgresql://localhost:5433/test
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5433/sonar
+ $ProjectFileDir$
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 9377824..592fdc7 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,8 +4,8 @@
-
-
+
+
@@ -13,7 +13,6 @@
-
diff --git a/.idea/misc.xml b/.idea/misc.xml
index ad1f18e..1746d8a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -10,4 +10,7 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml
new file mode 100644
index 0000000..6a6b371
--- /dev/null
+++ b/.idea/runConfigurations/All_Tests.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Article_Tests.xml b/.idea/runConfigurations/Article_Tests.xml
deleted file mode 100644
index c3bf9d4..0000000
--- a/.idea/runConfigurations/Article_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Auth_Tests.xml b/.idea/runConfigurations/Auth_Tests.xml
deleted file mode 100644
index 3b6663b..0000000
--- a/.idea/runConfigurations/Auth_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Build_and_start_all_Docker.xml b/.idea/runConfigurations/Build_and_start_all_Docker.xml
index e3012ae..3248709 100644
--- a/.idea/runConfigurations/Build_and_start_all_Docker.xml
+++ b/.idea/runConfigurations/Build_and_start_all_Docker.xml
@@ -2,6 +2,7 @@
+
@@ -11,6 +12,11 @@
+
+
+
+
+
@@ -23,6 +29,8 @@
+
+
diff --git a/.idea/runConfigurations/Check.xml b/.idea/runConfigurations/Check.xml
deleted file mode 100644
index eb90d35..0000000
--- a/.idea/runConfigurations/Check.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Citizen_Tests.xml b/.idea/runConfigurations/Citizen_Tests.xml
deleted file mode 100644
index fbe19ae..0000000
--- a/.idea/runConfigurations/Citizen_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Constitution_Tests.xml b/.idea/runConfigurations/Constitution_Tests.xml
deleted file mode 100644
index 89e53aa..0000000
--- a/.idea/runConfigurations/Constitution_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Cucumber_Tests.xml b/.idea/runConfigurations/Cucumber_Tests.xml
deleted file mode 100644
index f04a1ea..0000000
--- a/.idea/runConfigurations/Cucumber_Tests.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Cucumber_Tests__offline_.xml b/.idea/runConfigurations/Cucumber_Tests__offline_.xml
deleted file mode 100644
index 5c7d3d1..0000000
--- a/.idea/runConfigurations/Cucumber_Tests__offline_.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Down_Docker.xml b/.idea/runConfigurations/Down_Docker.xml
new file mode 100644
index 0000000..895be77
--- /dev/null
+++ b/.idea/runConfigurations/Down_Docker.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Follow_Tests.xml b/.idea/runConfigurations/Follow_Tests.xml
deleted file mode 100644
index 9e62c70..0000000
--- a/.idea/runConfigurations/Follow_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Unit_Tests__offline_.xml b/.idea/runConfigurations/Functional_Tests.xml
similarity index 71%
rename from .idea/runConfigurations/Unit_Tests__offline_.xml
rename to .idea/runConfigurations/Functional_Tests.xml
index 159a284..511a555 100644
--- a/.idea/runConfigurations/Unit_Tests__offline_.xml
+++ b/.idea/runConfigurations/Functional_Tests.xml
@@ -1,12 +1,12 @@
-
+
-
+
@@ -15,7 +15,7 @@
-
+
diff --git a/.idea/runConfigurations/Voter_Tests.xml b/.idea/runConfigurations/Functional_Tests__offline_.xml
similarity index 69%
rename from .idea/runConfigurations/Voter_Tests.xml
rename to .idea/runConfigurations/Functional_Tests__offline_.xml
index b4f5040..5dae781 100644
--- a/.idea/runConfigurations/Voter_Tests.xml
+++ b/.idea/runConfigurations/Functional_Tests__offline_.xml
@@ -1,13 +1,7 @@
-
+
-
-
-
-
-
-
@@ -21,7 +15,7 @@
-
+
diff --git a/.idea/runConfigurations/Opinion_Tests.xml b/.idea/runConfigurations/Integration_Tests.xml
similarity index 56%
rename from .idea/runConfigurations/Opinion_Tests.xml
rename to .idea/runConfigurations/Integration_Tests.xml
index 2930b4e..487e5a2 100644
--- a/.idea/runConfigurations/Opinion_Tests.xml
+++ b/.idea/runConfigurations/Integration_Tests.xml
@@ -1,13 +1,13 @@
-
+
-
+
-
+
-
-
+
+
@@ -16,7 +16,10 @@
-
+
+
+
+
diff --git a/.idea/runConfigurations/Lint.xml b/.idea/runConfigurations/Lint.xml
deleted file mode 100644
index e320150..0000000
--- a/.idea/runConfigurations/Lint.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- true
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Lint_Format.xml b/.idea/runConfigurations/Lint_Format.xml
new file mode 100644
index 0000000..32ba163
--- /dev/null
+++ b/.idea/runConfigurations/Lint_Format.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Lint_Test_Sonar___Run.xml b/.idea/runConfigurations/Lint_Test_Sonar___Run.xml
deleted file mode 100644
index 9ba45b3..0000000
--- a/.idea/runConfigurations/Lint_Test_Sonar___Run.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- true
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Lint__Test___Run.xml b/.idea/runConfigurations/Lint__Test___Run.xml
deleted file mode 100644
index 71edebc..0000000
--- a/.idea/runConfigurations/Lint__Test___Run.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- true
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Mark_as__error.xml b/.idea/runConfigurations/Mark_as__error.xml
deleted file mode 100644
index b04cef0..0000000
--- a/.idea/runConfigurations/Mark_as__error.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Migration.xml b/.idea/runConfigurations/Migration.xml
new file mode 100644
index 0000000..3159aef
--- /dev/null
+++ b/.idea/runConfigurations/Migration.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Reset_DB_on_DEV.xml b/.idea/runConfigurations/Reset_DB_on_DEV.xml
index b84b8e5..15c96a7 100644
--- a/.idea/runConfigurations/Reset_DB_on_DEV.xml
+++ b/.idea/runConfigurations/Reset_DB_on_DEV.xml
@@ -6,8 +6,10 @@
-
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Run_dependencies.xml b/.idea/runConfigurations/Run_dependencies.xml
index 84ae74c..d920570 100644
--- a/.idea/runConfigurations/Run_dependencies.xml
+++ b/.idea/runConfigurations/Run_dependencies.xml
@@ -2,6 +2,7 @@
+
@@ -11,6 +12,11 @@
+
+
+
+
+
@@ -18,9 +24,13 @@
+
+
+
+
diff --git a/.idea/runConfigurations/Run_for_dev.xml b/.idea/runConfigurations/Run_for_dev.xml
deleted file mode 100644
index 6f3d752..0000000
--- a/.idea/runConfigurations/Run_for_dev.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/SQL_Fixtures_on_DEV.xml b/.idea/runConfigurations/SQL_Fixtures_on_DEV.xml
index 03ce960..9edcdcd 100644
--- a/.idea/runConfigurations/SQL_Fixtures_on_DEV.xml
+++ b/.idea/runConfigurations/SQL_Fixtures_on_DEV.xml
@@ -6,8 +6,10 @@
-
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Sonarqube.xml b/.idea/runConfigurations/Sonarqube.xml
index 108a92a..af093fa 100644
--- a/.idea/runConfigurations/Sonarqube.xml
+++ b/.idea/runConfigurations/Sonarqube.xml
@@ -4,7 +4,7 @@
-
+
@@ -15,7 +15,9 @@
- true
+ true
+ true
+ false
\ No newline at end of file
diff --git a/.idea/runConfigurations/Sonarqube_without_test.xml b/.idea/runConfigurations/Sonarqube_without_test.xml
new file mode 100644
index 0000000..1774008
--- /dev/null
+++ b/.idea/runConfigurations/Sonarqube_without_test.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Start_Db.xml b/.idea/runConfigurations/Start_Db.xml
deleted file mode 100644
index 67a8728..0000000
--- a/.idea/runConfigurations/Start_Db.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Start_Elasticsearch.xml b/.idea/runConfigurations/Start_Elasticsearch.xml
deleted file mode 100644
index 09a1592..0000000
--- a/.idea/runConfigurations/Start_Elasticsearch.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Start_RabbitMQ.xml b/.idea/runConfigurations/Start_RabbitMQ.xml
deleted file mode 100644
index f3501bf..0000000
--- a/.idea/runConfigurations/Start_RabbitMQ.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Start_Redis.xml b/.idea/runConfigurations/Start_Redis.xml
deleted file mode 100644
index 6504ca9..0000000
--- a/.idea/runConfigurations/Start_Redis.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Test.xml b/.idea/runConfigurations/Test.xml
new file mode 100644
index 0000000..5b93d9f
--- /dev/null
+++ b/.idea/runConfigurations/Test.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/TestSql.xml b/.idea/runConfigurations/TestSql.xml
new file mode 100644
index 0000000..8003a29
--- /dev/null
+++ b/.idea/runConfigurations/TestSql.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Test_All_SQL.xml b/.idea/runConfigurations/Test_All_SQL.xml
index b336211..06d1935 100644
--- a/.idea/runConfigurations/Test_All_SQL.xml
+++ b/.idea/runConfigurations/Test_All_SQL.xml
@@ -1,13 +1,17 @@
-
+
+
-
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_Tests.xml
index 300e5de..2b42cda 100644
--- a/.idea/runConfigurations/Unit_Tests.xml
+++ b/.idea/runConfigurations/Unit_Tests.xml
@@ -5,7 +5,7 @@
-
+
@@ -15,6 +15,7 @@
+
diff --git a/.idea/runConfigurations/Unit__functional_and_integration_tests.xml b/.idea/runConfigurations/Unit__functional_and_integration_tests.xml
new file mode 100644
index 0000000..45efb47
--- /dev/null
+++ b/.idea/runConfigurations/Unit__functional_and_integration_tests.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Comment_Tests.xml b/.idea/runConfigurations/Unit_and_functional_tests.xml
similarity index 52%
rename from .idea/runConfigurations/Comment_Tests.xml
rename to .idea/runConfigurations/Unit_and_functional_tests.xml
index a682e5f..697c046 100644
--- a/.idea/runConfigurations/Comment_Tests.xml
+++ b/.idea/runConfigurations/Unit_and_functional_tests.xml
@@ -1,13 +1,13 @@
-
+
-
+
-
+
-
-
+
+
@@ -16,7 +16,11 @@
-
+
+
+
+
+
diff --git a/.idea/runConfigurations/Vote_Tests.xml b/.idea/runConfigurations/Vote_Tests.xml
deleted file mode 100644
index cd00a63..0000000
--- a/.idea/runConfigurations/Vote_Tests.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Workgroup_test.xml b/.idea/runConfigurations/Workgroup_test.xml
deleted file mode 100644
index f2ba85b..0000000
--- a/.idea/runConfigurations/Workgroup_test.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 0d004ac..c5c929a 100644
--- a/Makefile
+++ b/Makefile
@@ -15,12 +15,12 @@ help: ## This help.
bd: build-docker
-build-docker: ## Build the docker image of application
+build-docker: ## Build the docker image of application (alias: bd)
docker build -t dc-project -f docker/app/Dockerfile .
pd: publish-docker
-publish-docker: build-docker ## Publish docker image of application to Github
+publish-docker: build-docker ## Publish docker image of application to Github (alias: pd)
@git diff --quiet --exit-code || (echo "The git is DIRTY !!! You cannot publish this crap!" && exit 1)
@cat ./GH_TOKEN.txt | docker login docker.pkg.github.com -u ${GITHUB_USERNAME} --password-stdin
@docker tag dc-project docker.pkg.github.com/flecomte/dc-project/dc-project:${VERSION}
@@ -28,27 +28,32 @@ publish-docker: build-docker ## Publish docker image of application to Github
rd: run-docker
-run-docker: ## Build and Run all docker services
+run-docker: ## Build and Run all docker services (alias: rd)
docker-compose up -d --build
+rdd: run-docker-dependencies
+
+run-docker-dependencies: ## Build and Run dependencies docker services (alias: rdd)
+ docker-compose up -d --build openapi rabbitmq redis elasticsearch db sonarqube_db sonarqube
+
pm: publish-maven
-publish-maven: ## Publish JAR file to Github
+publish-maven: ## Publish JAR file to Github (alias: pm)
@git diff --quiet --exit-code || (echo "The git is DIRTY !!! You cannot publish this crap!" && exit 1)
gradlew publish
f: fixtures
-fixtures: ## Import fixtures
+fixtures: ## Import fixtures (alias: f)
bash src/main/resources/sql/fixtures/fixtures.sh
-reset-database: ## Import fixtures
+reset-database: ## Reset database !!!
cd src/main/resources/sql/ ; bash resetDB.sh
test-sql: ## Test sql
cd src/test/sql/ ; bash test.sh 1
-v: vertion
+v: version
-vertion: ## Show current version
+version: ## Show current version (alias: v)
@echo ${VERSION}
diff --git a/build.gradle.kts b/build.gradle.kts
index 14a4ff0..4aa8aeb 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,15 +1,20 @@
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import com.typesafe.config.ConfigFactory
+import fr.postgresjson.connexion.Connection
+import fr.postgresjson.connexion.Requester
+import fr.postgresjson.migration.Migrations
+import io.gitlab.arturbosch.detekt.Detekt
+import org.gradle.internal.os.OperatingSystem
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.owasp.dependencycheck.reporting.ReportGenerator
-import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.slf4j.LoggerFactory
-val ktor_version: String by project
-val kotlin_version: String by project
-val coroutinesVersion: String by project
-val logback_version: String by project
-val koinVersion: String by project
-val jackson_version: String by project
-val cucumber_version: String by project
+val ktorVersion = "1.5.0"
+val kotlinVersion = "1.4.30"
+val coroutinesVersion = "1.4.3"
+val logbackVersion = "1.2.3"
+val koinVersion = "2.0.1"
+val jacksonVersion = "2.12.1"
group = "com.github.flecomte"
version = versioning.info.run {
@@ -23,26 +28,133 @@ version = versioning.info.run {
plugins {
jacoco
application
+ maven
id("maven-publish")
- id("org.jetbrains.kotlin.jvm") version "1.3.50"
+ kotlin("jvm") version "1.4.30"
+ kotlin("plugin.serialization") version "1.4.30"
id("com.github.johnrengelman.shadow") version "5.2.0"
- id("org.jlleitschuh.gradle.ktlint") version "8.2.0"
- id("org.owasp.dependencycheck") version "5.1.0"
- id("org.sonarqube") version "2.7"
- id("net.nemerosa.versioning") version "2.13.1"
+ id("org.jlleitschuh.gradle.ktlint") version "9.4.1"
+ id("org.owasp.dependencycheck") version "6.1.1"
+ id("org.sonarqube") version "3.1.1"
+ id("net.nemerosa.versioning") version "2.14.0"
+ id("io.gitlab.arturbosch.detekt") version "1.16.0-RC1"
+ id("com.avast.gradle.docker-compose") version "0.14.0"
}
application {
mainClassName = "io.ktor.server.jetty.EngineMain"
}
+buildscript {
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ jcenter()
+ maven { url = uri("https://jitpack.io") }
+ }
+ dependencies {
+ classpath("com.typesafe:config:1.4.1")
+ classpath("com.github.flecomte:postgres-json:2.1.1")
+ }
+}
+
tasks.withType {
kotlinOptions {
jvmTarget = "11"
}
}
+
+val migration by tasks.registering {
+ group = "application"
+ dependsOn(tasks.named("composeUp"))
+
+ doLast {
+ val config = ConfigFactory.parseFile(file("$buildDir/../src/main/resources/application.conf")).resolve()
+ val connection = Connection(
+ host = config.getString("db.host"),
+ port = config.getInt("db.port"),
+ database = config.getString("db.database"),
+ username = config.getString("db.username"),
+ password = config.getString("db.password")
+ )
+ Migrations(
+ connection,
+ file("$buildDir/../src/main/resources/sql/migrations").toURI(),
+ file("$buildDir/../src/main/resources/sql/functions").toURI()
+ ).run {
+ run()
+ }
+ }
+}
+
+val migrationTest by tasks.registering {
+ group = "verification"
+ dependsOn(tasks.named("testComposeUp"))
+ finalizedBy(tasks.named("testComposeDown"))
+ doLast {
+ val config = ConfigFactory.parseFile(file("$buildDir/../src/test/resources/application-test.conf")).resolve()
+ val connection = Connection(
+ host = config.getString("db.host"),
+ port = config.getInt("db.port"),
+ database = config.getString("db.database"),
+ username = config.getString("db.username"),
+ password = config.getString("db.password")
+ )
+ Migrations(
+ connection,
+ file("$buildDir/../src/main/resources/sql/migrations").toURI(),
+ file("$buildDir/../src/main/resources/sql/functions").toURI()
+ ).run {
+ run()
+ connection.disconnect()
+ }
+ }
+}
+
+val testSql by tasks.registering {
+ group = "verification"
+ dependsOn(tasks.named("testComposeUp"))
+ finalizedBy(tasks.named("testComposeDown"))
+
+ doLast {
+ val config = ConfigFactory.parseFile(file("$buildDir/../src/test/resources/application-test.conf")).resolve()
+
+ val connection = Connection(
+ host = config.getString("db.host"),
+ port = config.getInt("db.port"),
+ database = config.getString("db.database"),
+ username = config.getString("db.username"),
+ password = config.getString("db.password")
+ )
+
+ Migrations(
+ connection,
+ file("$buildDir/../src/main/resources/sql/migrations").toURI(),
+ file("$buildDir/../src/main/resources/sql/functions").toURI(),
+ file("$buildDir/../src/test/sql/fixtures").toURI()
+ ).run {
+ run()
+ }
+
+ Requester.RequesterFactory(
+ connection = connection,
+ queriesDirectory = file("$buildDir/../src/test/sql").toURI()
+ ).createRequester().run {
+ getQueries().map {
+ try {
+ it.sendQuery() == 0
+ } catch (e: Exception) {
+ false
+ }
+ }
+ }
+
+ connection.disconnect()
+ }
+}
+
tasks.withType {
manifest {
attributes(
@@ -53,23 +165,62 @@ tasks.withType {
}
}
-tasks {
- named("shadowJar") {
- mergeServiceFiles("META-INF/services")
- archiveFileName.set("${archiveBaseName.get()}-latest-all.${archiveExtension.get()}")
+tasks.withType {
+ kotlinOptions {
+ jvmTarget = "11"
+ sourceCompatibility = "11"
+ targetCompatibility = "11"
}
}
-val sourcesJar by tasks.creating(Jar::class) {
+tasks.named("shadowJar") {
+ mergeServiceFiles("META-INF/services")
+ archiveFileName.set("${archiveBaseName.get()}-latest-all.${archiveExtension.get()}")
+}
+
+tasks.sonarqube.configure { dependsOn(tasks.jacocoTestReport) }
+
+val sourcesJar by tasks.registering(Jar::class) {
+ group = "build"
archiveClassifier.set("sources")
from(sourceSets.getByName("main").allSource)
}
+
tasks.test {
useJUnit()
useJUnitPlatform()
-// maxHeapSize = "1G"
+ systemProperty("junit.jupiter.execution.parallel.enabled", true)
+ dependsOn(testSql)
+ finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run
}
+apply(plugin = "docker-compose")
+dockerCompose {
+ projectName = "dc-project"
+ useComposeFiles = listOf("docker-compose.yml")
+ startedServices = listOf("db", "elasticsearch", "rabbitmq", "redis")
+ stopContainers = false
+ removeVolumes = false
+ removeContainers = false
+ isRequiredBy(project.tasks.run)
+ createNested("test").apply {
+ projectName = "dc-project_test"
+ useComposeFiles = listOf("docker-compose-test.yml")
+ stopContainers = false
+ isRequiredBy(project.tasks.test)
+ isRequiredBy(project.tasks.named("testSql"))
+ }
+ createNested("sonarqube").apply {
+ projectName = "dc-project"
+ useComposeFiles = listOf("docker-compose-sonar.yml")
+ stopContainers = false
+ removeVolumes = false
+ removeContainers = false
+// isRequiredBy(project.tasks.sonarqube)
+ }
+}
+tasks.sonarqube.configure { dependsOn(tasks.named("sonarqubeComposeUp")) }
+
publishing {
if (versioning.info.dirty == false) {
repositories {
@@ -97,15 +248,53 @@ publishing {
}
jacoco {
- toolVersion = "0.8.3"
+ toolVersion = "0.8.6"
+ applyTo(tasks.run.get())
+}
+
+tasks.register("applicationCodeCoverageReport") {
+ executionData(tasks.run.get())
+ sourceSets(sourceSets.main.get())
}
tasks.jacocoTestReport {
+ dependsOn(tasks.test)
reports {
xml.isEnabled = true
+ html.isEnabled = true
}
}
+detekt {
+ buildUponDefaultConfig = true // preconfigure defaults
+// config = files("$projectDir/config/detekt.yml") // point to your custom config defining rules to run, overwriting default behavior
+// baseline = file("$projectDir/config/baseline.xml") // a way of suppressing issues before introducing detekt
+
+ reports {
+ html.enabled = true // observe findings in your browser with structure and code snippets
+ xml.enabled = true // checkstyle like format mainly for integrations like Jenkins
+ txt.enabled = true // similar to the console output, contains issue signature to manually edit baseline files
+ sarif.enabled = true // standardized SARIF format (https://sarifweb.azurewebsites.net/) to support integrations with Github Code Scanning
+ }
+}
+
+tasks.withType {
+ // Target version of the generated JVM bytecode. It is used for type resolution.
+ this.jvmTarget = "11"
+}
+
+val setMaxMapCount = tasks.create("setMaxMapCount") {
+ group = "docker"
+ doFirst {
+ if (OperatingSystem.current().isWindows) {
+ commandLine("cmd", "/c", "Powershell -ExecutionPolicy Bypass; wsl -d docker-desktop sysctl -w vm.max_map_count=262144")
+ } else if (OperatingSystem.current().isLinux) {
+ commandLine("sysctl -w vm.max_map_count=262144")
+ }
+ }
+}
+tasks.named("testComposeUp").configure { dependsOn(setMaxMapCount) }
+
dependencyCheck {
formats = listOf(ReportGenerator.Format.HTML, ReportGenerator.Format.XML)
}
@@ -118,41 +307,46 @@ repositories {
}
dependencies {
- implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
+ implementation(gradleApi())
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$coroutinesVersion")
- implementation("io.ktor:ktor-server-jetty:$ktor_version")
- implementation("io.ktor:ktor-client-jetty:$ktor_version")
- implementation("ch.qos.logback:logback-classic:$logback_version")
- implementation("io.ktor:ktor-server-core:$ktor_version")
- implementation("io.ktor:ktor-locations:$ktor_version")
- implementation("io.ktor:ktor-auth:$ktor_version")
- implementation("io.ktor:ktor-auth-jwt:$ktor_version")
- implementation("io.ktor:ktor-gson:$ktor_version")
- implementation("io.ktor:ktor-auth-jwt:$ktor_version")
- implementation("io.ktor:ktor-websockets:$ktor_version")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
+ implementation("io.ktor:ktor-server-jetty:$ktorVersion")
+ implementation("io.ktor:ktor-client-jetty:$ktorVersion")
+ implementation("ch.qos.logback:logback-classic:$logbackVersion")
+ implementation("io.ktor:ktor-server-core:$ktorVersion")
+ implementation("io.ktor:ktor-locations:$ktorVersion")
+ implementation("io.ktor:ktor-auth:$ktorVersion")
+ implementation("io.ktor:ktor-auth-jwt:$ktorVersion")
+ implementation("io.ktor:ktor-websockets:$ktorVersion")
implementation("org.koin:koin-ktor:$koinVersion")
- implementation("io.ktor:ktor-jackson:$ktor_version")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jackson_version")
- implementation("com.fasterxml.jackson.datatype:jackson-datatype-joda:$jackson_version")
- implementation("net.pearx.kasechange:kasechange-jvm:1.1.0")
- implementation("com.auth0:java-jwt:3.8.2")
- implementation("com.github.jasync-sql:jasync-postgresql:1.0.7")
- implementation("com.github.flecomte:postgres-json:1.2.1")
- implementation("com.github.flecomte:ktor-voter:1.0.1")
- implementation("com.sendgrid:sendgrid-java:4.4.1")
- implementation("io.lettuce:lettuce-core:5.2.2.RELEASE")
- implementation("com.rabbitmq:amqp-client:5.8.0")
+ implementation("io.ktor:ktor-jackson:$ktorVersion")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-joda:$jacksonVersion")
+ implementation("net.pearx.kasechange:kasechange-jvm:1.3.0")
+ implementation("com.auth0:java-jwt:3.12.0")
+ implementation("com.github.jasync-sql:jasync-postgresql:1.1.6")
+ implementation("com.github.flecomte:postgres-json:2.1.1")
+ implementation("com.sendgrid:sendgrid-java:4.7.1")
+ implementation("io.lettuce:lettuce-core:5.3.6.RELEASE") // TODO update to 6.0.2
+ implementation("com.rabbitmq:amqp-client:5.10.0")
implementation("org.elasticsearch.client:elasticsearch-rest-client:6.7.1")
- implementation("com.jayway.jsonpath:json-path:2.4.0")
+ implementation("com.jayway.jsonpath:json-path:2.5.0")
+ implementation("com.avast.gradle:gradle-docker-compose-plugin:0.14.0")
- testImplementation("io.ktor:ktor-server-tests:$ktor_version")
- testImplementation("io.ktor:ktor-client-mock:$ktor_version")
- testImplementation("io.ktor:ktor-client-mock-jvm:$ktor_version")
+ testImplementation("io.ktor:ktor-server-tests:$ktorVersion")
+ testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
+ testImplementation("io.ktor:ktor-client-mock-jvm:$ktorVersion")
testImplementation("org.koin:koin-test:$koinVersion")
- testImplementation("io.mockk:mockk:1.9.3")
- testImplementation("org.junit.jupiter:junit-jupiter:5.5.0")
- testImplementation("org.amshove.kluent:kluent:1.4")
- testImplementation("io.cucumber:cucumber-java8:$cucumber_version")
- testImplementation("io.cucumber:cucumber-junit:$cucumber_version")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
+ testImplementation("io.mockk:mockk:1.10.6")
+ testImplementation("org.junit.jupiter:junit-jupiter:5.7.0")
+ testImplementation("org.amshove.kluent:kluent:1.61")
+ testImplementation("io.mockk:mockk-agent-api:1.10.6")
+ testImplementation("io.mockk:mockk-agent-jvm:1.10.6")
+ testImplementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
+ testImplementation("com.thedeanda:lorem:2.1")
+ testImplementation("org.openapi4j:openapi-operation-validator:1.0.6")
+ testImplementation("org.openapi4j:openapi-parser:1.0.6")
}
diff --git a/doc/schema/Article.puml b/doc/schema/Article.puml
new file mode 100644
index 0000000..e6c6df7
--- /dev/null
+++ b/doc/schema/Article.puml
@@ -0,0 +1,98 @@
+@startuml
+
+title Search / Get articles
+
+actor Front
+box Article API
+ control Controller
+ control Repository
+ entity Article
+ database Postgres
+endbox
+box View System
+ control ArticleViewManager
+ database Elasticsearch
+endbox
+box Notification System
+ control EventNotification
+ database RabbitMQ
+ database Redis
+endbox
+
+Front -> Controller++: GET /articles?page=1
+ Controller -> Repository++: find
+ Repository -> Postgres++: find_articles()
+ return
+ return
+return: 200, Articles
+
+newpage Create / Update Article
+
+Front -> Controller: POST /article
+ activate Controller
+ Controller -> Controller: Convert dto to Entity
+ Controller -> Controller: Check Authorization
+ alt Authorize
+ Controller -> Repository++: upsert(entity)
+ Repository -> Postgres++: upsert_article
+ return
+ return
+ Controller -> Controller: Convert to dto
+ Front <-- Controller: 200, New Article
+ else not authorize
+ Front <-- Controller: 403, "Forbidden"
+ end
+ Controller -> EventNotification: raiseEvent(ArticleUpdate)
+ deactivate Controller
+ activate EventNotification
+ EventNotification ->> RabbitMQ
+ deactivate EventNotification
+ ...
+RabbitMQ -->> EventNotification++
+ EventNotification ->> : Send Email
+ EventNotification ->> Redis : Push Event Notification
+return <>
+
+newpage get one article by id
+
+Front -> Controller: GET /article/{article}
+ activate Controller
+ Controller -> Repository++: findById()
+ Repository -> Postgres++: find_article_by_id()
+ return
+ return
+ Controller -> Controller: Check Authorization
+
+ alt Authorize
+ Controller -> ArticleViewManager++: getViewsCount(Article)
+ ArticleViewManager -> Elasticsearch++
+ return
+ return
+ Controller -> Controller: Convert Article and Views to dto
+ Front <<-- Controller: 200, Article
+ else not authorize
+ Front <<-- Controller: 403, "Forbidden"
+ end
+ Controller -> ArticleViewManager++: increment the view counter
+ ArticleViewManager -> Elasticsearch++
+ return
+ return
+deactivate Controller
+
+newpage get article versions by id
+
+Front -> Controller: GET /articles/{article}/versions
+ activate Controller
+ Controller -> Controller: Check Authorization
+ alt Authorize
+ Controller -> Repository++: findVersionsByVersionId
+ Repository -> Postgres++: find_articles_versions_by_version_id
+ return
+ return
+ Controller -> Controller: Convert to dto
+ Front <-- Controller: 200, Articles versions
+ else not authorize
+ Front <-- Controller: 403, "Forbidden"
+ end
+deactivate Controller
+@enduml
\ No newline at end of file
diff --git a/doc/schema/Notification.puml b/doc/schema/Notification.puml
new file mode 100644
index 0000000..f2b691b
--- /dev/null
+++ b/doc/schema/Notification.puml
@@ -0,0 +1,66 @@
+@startuml
+title Notification
+|Server|
+partition Event {
+ start
+ :Article is modified;
+ :Send message to "notification" exchange (RabbitMQ);
+ :RabbitMQ send message to "push" and "email" queue;
+ stop
+}
+split
+ partition Email {
+ -[hidden]->
+ :Consume "email" queue<
+ repeat :get next notification;
+ :Get followers of article from DB;
+ while (loop on followers)
+ :Send email to the citizen>
+ endwhile
+ :ACK>
+ repeat while()
+ detach
+ }
+splitagain
+ partition Push {
+ -[hidden]->
+ :Consume "email" queue<
+ repeat :get next notification;
+ :Get followers of article from DB;
+ while (loop on followers)
+ :Send notification message to redis>
+ endwhile
+ :ACK>
+ repeat while()
+ detach
+ }
+splitagain
+ partition "Notification direct" {
+ -[hidden]->
+ |Client|
+ start
+ :Client arrive on the web site;
+ :Connect to the websocket;
+ |Server|
+ :Get citizen notification
+ from redis;
+ while (on each notifications)
+ :Send notification to websocket>
+ endwhile(no notification left)
+ |Client|
+ :show notification;
+ |Server|
+ :Subscribe to redis event;
+ repeat :On new notification;
+ :Get new notification from redis;
+ :Send notification to websocket>
+ |Client|
+ :show notification;
+ |Server|
+ repeat while (wait notification)
+ detach
+ }
+endsplit
+
+
+@enduml
diff --git a/docker-compose-sonar.yml b/docker-compose-sonar.yml
new file mode 100644
index 0000000..978c9c9
--- /dev/null
+++ b/docker-compose-sonar.yml
@@ -0,0 +1,48 @@
+version: '3.8'
+services:
+ sonarqube:
+ container_name: ${APP_NAME}_sonarqube
+ image: sonarqube:community
+ depends_on:
+ - sonarqube_db
+ ports:
+ - ${SONARQUBE_PORT}:9000
+ networks:
+ - sonarnet
+ environment:
+ SONAR_JDBC_URL: jdbc:postgresql://sonarqube_db:5432/sonar
+ SONAR_JDBC_USERNAME: sonar
+ SONAR_JDBC_PASSWORD: sonar
+ volumes:
+ - sonarqube_data:/opt/sonarqube/data
+ - sonarqube_extensions:/opt/sonarqube/extensions
+ - sonarqube_logs:/opt/sonarqube/logs
+ - sonarqube_temp:/opt/sonarqube/temp
+
+ sonarqube_db:
+ container_name: ${APP_NAME}_sonarqube_db
+ image: postgres:alpine
+ networks:
+ - sonarnet
+ environment:
+ POSTGRES_USER: sonar
+ POSTGRES_PASSWORD: sonar
+ ports:
+ - ${SONARQUBE_DB_PORT}:5432
+ volumes:
+ - sonarqube_postgresql:/var/lib/postgresql
+ # This needs explicit mapping due to https://github.com/docker-library/postgres/blob/4e48e3228a30763913ece952c611e5e9b95c8759/Dockerfile.template#L52
+ - sonarqube_postgresql_data:/var/lib/postgresql/data
+
+
+networks:
+ sonarnet:
+ driver: bridge
+
+volumes:
+ sonarqube_data:
+ sonarqube_extensions:
+ sonarqube_logs:
+ sonarqube_temp:
+ sonarqube_postgresql:
+ sonarqube_postgresql_data:
\ No newline at end of file
diff --git a/docker-compose-test.yml b/docker-compose-test.yml
new file mode 100644
index 0000000..e66057b
--- /dev/null
+++ b/docker-compose-test.yml
@@ -0,0 +1,44 @@
+version: '3.8'
+services:
+ rabbitmq:
+ container_name: ${APP_NAME}_rabbitmq_test
+ image: rabbitmq:management-alpine
+ ports:
+ - 5673:5672
+ - 15673:15672
+
+ redis:
+ container_name: ${APP_NAME}_redis_test
+ image: redis:6-alpine
+ ports:
+ - 6380:6379
+
+ elasticsearch:
+ container_name: ${APP_NAME}_elasticsearch_test
+ image: elasticsearch:6.7.1
+ ports:
+ - 9201:9200
+ - 9301:9300
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://elasticsearch:9200"]
+ interval: 3s
+ timeout: 2s
+ retries: 20
+
+ db:
+ container_name: ${APP_NAME}_postgresql_test
+ build:
+ context: docker/postgresql
+ ports:
+ - 15432:5432
+ environment:
+ POSTGRES_PASSWORD: ${DB_NAME}
+ POSTGRES_USER: ${DB_USER}
+ POSTGRES_DB: ${DB_PWD}
+ depends_on:
+ - elasticsearch
+ healthcheck:
+ test: [ "CMD", "pg_isready", "-q", "-d", "${DB_NAME}", "-U", "${DB_USER}" ]
+ interval: 3s
+ timeout: 2s
+ retries: 20
diff --git a/docker-compose.yml b/docker-compose.yml
index fcac966..4d93bf9 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,15 +1,9 @@
# To execute this docker-compose yml file use docker-compose -f up
# Add the "-d" flag at the end for detached execution
-version: '3.7'
+version: '3.8'
services:
- sonarqube:
- container_name: sonarqube_${NAME}
- image: sonarqube
- ports:
- - ${SONARQUBE_PORT}:9000
-
openapi:
- container_name: openapi_${NAME}
+ container_name: ${APP_NAME}_openapi
image: swaggerapi/swagger-ui
ports:
- ${OPENAPI_PORT}:8080
@@ -17,22 +11,22 @@ services:
URL: "http://localhost:8080"
rabbitmq:
- container_name: rabbitmq_${NAME}
+ container_name: ${APP_NAME}_rabbitmq
image: rabbitmq:management-alpine
ports:
- ${RABBITMQ_PORT}:5672
- ${RABBITMQ_MANAGEMENT_PORT}:15672
redis:
- container_name: redis_${NAME}
- image: redis:6.0-rc-alpine
+ container_name: ${APP_NAME}_redis
+ image: redis:6-alpine
ports:
- ${REDIS_PORT}:6379
volumes:
- redis-data:/var/lib/redis:rw
app:
- container_name: app_${NAME}
+ container_name: ${APP_NAME}_app
build:
context: .
dockerfile: docker/app/Dockerfile
@@ -51,7 +45,7 @@ services:
- rabbitmq
elasticsearch:
- container_name: elasticsearch_${NAME}
+ container_name: ${APP_NAME}_elasticsearch
image: elasticsearch:6.7.1
ports:
- ${ELASTIC_REST}:9200
@@ -63,7 +57,7 @@ services:
retries: 20
db:
- container_name: postgresql_${NAME}
+ container_name: ${APP_NAME}_postgresql
build:
context: docker/postgresql
ports:
diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile
index 0c9401f..8d8fb06 100644
--- a/docker/app/Dockerfile
+++ b/docker/app/Dockerfile
@@ -1,12 +1,13 @@
#### BUILD ####
-FROM gradle:5.6.4-jdk11 AS build
+FROM gradle:6.8-jdk11 AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle build -x test -x ktlintKotlinScriptCheck -x ktlintTestSourceSetCheck -x ktlintMainSourceSetCheck --no-daemon
+RUN gradle shadowJar
#### RUN ####
-FROM adoptopenjdk/openjdk11:jre-11.0.4_11-alpine
+FROM amazoncorretto:11-alpine as run
ENV APPLICATION_USER ktor
RUN adduser -D -g '' $APPLICATION_USER
diff --git a/gradle.properties b/gradle.properties
index 23699b1..c799b29 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,10 +1,9 @@
-ktor_version=1.2.2
kotlin.code.style=official
-kotlin_version=1.3.40
-coroutinesVersion=1.3.3
-logback_version=1.2.1
-koinVersion=2.0.1
-jackson_version=2.9.9
-cucumber_version=5.1.3
-systemProp.sonar.host.url=http://localhost:9000
-systemProp.sonar.login=1196e8015c20035f1aa91e881b95ce9d6e879c8a
+systemProp.sonar.host.url=http://localhost:9002
+systemProp.sonar.login=admin
+systemProp.sonar.password=sonar
+systemProp.sonar.projectKey=dc-project
+systemProp.sonar.projectName=DC Project
+systemProp.sonar.java.coveragePlugin=jacoco
+systemProp.sonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml
+systemProp.sonar.kotlin.detekt.reportPaths=build/reports/detekt/detekt.xml
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 5c2d1cf..e708b1c 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 7c4388a..da9702f 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 83f2acf..4f906e0 100755
--- a/gradlew
+++ b/gradlew
@@ -82,6 +82,7 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
@@ -129,6 +130,7 @@ fi
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
@@ -154,19 +156,19 @@ if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
else
eval `echo args$i`="\"$arg\""
fi
- i=$((i+1))
+ i=`expr $i + 1`
done
case $i in
- (0) set -- ;;
- (1) set -- "$args0" ;;
- (2) set -- "$args0" "$args1" ;;
- (3) set -- "$args0" "$args1" "$args2" ;;
- (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
@@ -175,14 +177,9 @@ save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
-APP_ARGS=$(save "$@")
+APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
-# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
-if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
- cd "$(dirname "$0")"
-fi
-
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index 9618d8d..107acd3 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -29,6 +29,9 @@ if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@@ -37,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
+if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -51,7 +54,7 @@ goto fail
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-if exist "%JAVA_EXE%" goto init
+if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@@ -61,28 +64,14 @@ echo location of your Java installation.
goto fail
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt
deleted file mode 100644
index 4b459ff..0000000
--- a/src/main/kotlin/Application.kt
+++ /dev/null
@@ -1,314 +0,0 @@
-package fr.dcproject
-
-import com.fasterxml.jackson.core.util.DefaultIndenter
-import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.PropertyNamingStrategy
-import com.fasterxml.jackson.databind.SerializationFeature
-import com.fasterxml.jackson.datatype.joda.JodaModule
-import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
-import fr.dcproject.Env.PROD
-import fr.dcproject.elasticsearch.configElasticIndexes
-import fr.dcproject.entity.*
-import fr.dcproject.event.EventNotification
-import fr.dcproject.event.EventSubscriber
-import fr.dcproject.routes.*
-import fr.dcproject.security.voter.*
-import fr.ktorVoter.AuthorizationVoter
-import fr.ktorVoter.ForbiddenException
-import fr.postgresjson.migration.Migrations
-import io.ktor.application.Application
-import io.ktor.application.ApplicationCall
-import io.ktor.application.call
-import io.ktor.application.install
-import io.ktor.auth.Authentication
-import io.ktor.auth.authenticate
-import io.ktor.auth.jwt.jwt
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.jetty.Jetty
-import io.ktor.features.*
-import io.ktor.http.HttpHeaders
-import io.ktor.http.HttpMethod
-import io.ktor.http.HttpStatusCode
-import io.ktor.http.auth.HttpAuthHeader
-import io.ktor.jackson.jackson
-import io.ktor.locations.KtorExperimentalLocationsAPI
-import io.ktor.locations.Locations
-import io.ktor.response.respond
-import io.ktor.routing.Routing
-import io.ktor.util.KtorExperimentalAPI
-import io.ktor.websocket.WebSockets
-import org.eclipse.jetty.util.log.Slf4jLog
-import org.koin.core.qualifier.named
-import org.koin.ktor.ext.Koin
-import org.koin.ktor.ext.get
-import org.slf4j.event.Level
-import java.time.Duration
-import java.util.*
-import java.util.concurrent.CompletionException
-import fr.dcproject.entity.Workgroup as WorkgroupEntity
-import fr.dcproject.repository.Article as RepositoryArticle
-import fr.dcproject.repository.Citizen as RepositoryCitizen
-import fr.dcproject.repository.Constitution as RepositoryConstitution
-import fr.dcproject.repository.OpinionChoice as OpinionChoiceRepository
-import fr.dcproject.repository.User as UserRepository
-import fr.dcproject.repository.Workgroup as WorkgroupRepository
-
-fun main(args: Array): Unit = io.ktor.server.jetty.EngineMain.main(args)
-
-enum class Env { PROD, TEST, CUCUMBER }
-
-@KtorExperimentalAPI
-@KtorExperimentalLocationsAPI
-@Suppress("unused") // Referenced in application.conf
-fun Application.module(env: Env = PROD) {
- install(Koin) {
- Slf4jLog()
- modules(Module)
- }
-
- install(CallLogging) {
- level = Level.INFO
- }
-
- install(DataConversion) {
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let { UUID.fromString(it) }
- }
-
- encode { value ->
- when (value) {
- null -> listOf()
- is UUID -> listOf(value.toString())
- else -> throw InternalError("Cannot convert $value as UUID")
- }
- }
- }
-
- // TODO: create generic convert for entityI
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- get().findById(UUID.fromString(it))
- ?: throw NotFoundException("Article $values not found")
- } ?: throw NotFoundException("Article $values not found")
- }
- }
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- ArticleRef(UUID.fromString(it))
- } ?: throw NotFoundException("""UUID "$values" is not valid for Article""")
- }
- }
-
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- CommentRef(UUID.fromString(it))
- } ?: throw NotFoundException("""UUID "$values" is not valid for Comment""")
- }
- }
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- ConstitutionRef(UUID.fromString(it))
- } ?: throw NotFoundException("""UUID "$values" is not valid for Constitution""")
- }
- }
-
- convert {
- decode { values, _ ->
- val id = values.singleOrNull()?.let { UUID.fromString(it) }
- ?: throw InternalError("Cannot convert $values to UUID")
- get().findById(id) ?: throw NotFoundException("Constitution $values not found")
- }
- }
-
- convert {
- decode { values, _ ->
- val id = values.singleOrNull()?.let { UUID.fromString(it) }
- ?: throw InternalError("Cannot convert $values to UUID")
- get().findById(id) ?: throw NotFoundException("Citizen $values not found")
- }
- }
-
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- CitizenRef(UUID.fromString(it))
- } ?: throw NotFoundException("""UUID "$values" is not valid for Citizen""")
- }
- }
-
- convert {
- decode { values, _ ->
- val id = values.singleOrNull()?.let { UUID.fromString(it) }
- ?: throw InternalError("Cannot convert $values to UUID")
- get().findOpinionChoiceById(id)
- ?: throw NotFoundException("OpinionChoice $values not found")
- }
- }
-
- convert {
- decode { values, _ ->
- values.singleOrNull()?.let {
- WorkgroupRef(UUID.fromString(it))
- } ?: throw NotFoundException("""UUID "$values" is not valid for Workgroup""")
- }
- }
-
- convert {
- decode { values, _ ->
- val id = values.singleOrNull()?.let { UUID.fromString(it) }
- ?: throw InternalError("Cannot convert $values to UUID")
- get().findById(id)
- ?: throw NotFoundException("Workgroup $values not found")
- }
- }
- }
-
- install(Locations) {
- }
-
- install(AuthorizationVoter) {
- voters = mutableListOf(
- ArticleVoter(),
- ConstitutionVoter(),
- CitizenVoter(),
- CommentVoter(),
- VoteVoter(),
- FollowVoter(),
- OpinionVoter(),
- OpinionChoiceVoter(),
- WorkgroupVoter()
- )
- }
-
- HttpClient(Jetty) {
- engine {
- }
- }
-
- configElasticIndexes(get())
-
- install(WebSockets) {
- pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
- timeout = Duration.ofSeconds(15)
- maxFrameSize = Long.MAX_VALUE // Disabled (max value). The connection will be closed if surpassed this length.
- masking = false
- }
-
- install(EventSubscriber) {
- EventNotification(this, get(), get(), get(), get(), get()).config()
- }
-
- install(Authentication) {
- /**
- * Setup the JWT authentication to be used in [Routing].
- * If the token is valid, the corresponding [User] is fetched from the database.
- * The [User] can then be accessed in each [ApplicationCall].
- */
- jwt {
- verifier(JwtConfig.verifier)
- realm = "dc-project.fr"
- validate {
- it.payload.getClaim("id").asString()?.let { id ->
- get().findById(UUID.fromString(id))
- }
- }
- }
-
- jwt("url") {
- verifier(JwtConfig.verifier)
- realm = "dc-project.fr"
- authHeader { call ->
- call.request.queryParameters.get("token")?.let {
- HttpAuthHeader.Single("Bearer", it)
- }
- }
- validate {
- it.payload.getClaim("id").asString()?.let { id ->
- get().findById(UUID.fromString(id))
- }
- }
- }
- }
-
- install(AutoHeadResponse)
-
- install(ContentNegotiation) {
- jackson {
- propertyNamingStrategy = PropertyNamingStrategy.SNAKE_CASE
-
- registerModule(JodaModule())
- disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
- configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
- configure(SerializationFeature.INDENT_OUTPUT, true)
- setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
- indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
- indentObjectsWith(DefaultIndenter(" ", "\n"))
- })
- }
- }
-
- install(Routing) {
- // trace { application.log.trace(it.buildText()) }
- authenticate(optional = true) {
- article(get(), get())
- auth(get(), get(), get())
- citizen(get(), get())
- constitution(get())
- followArticle(get())
- followConstitution(get())
- comment(get())
- commentArticle(get())
- commentConstitution(get())
- voteArticle(get(), get(), get())
- voteConstitution(get())
- opinionArticle(get())
- opinionChoice(get())
- workgroup(get())
- definition()
- }
-
- authenticate("url") {
- notificationArticle(get(), get(named("ws")))
- }
- }
-
- install(StatusPages) {
- // TODO move to postgresJson lib
- exception { e ->
- val parent = e.cause?.cause
- if (parent is GenericDatabaseException) {
- call.respond(HttpStatusCode.BadRequest, parent.errorMessage.message!!)
- } else {
- throw e
- }
- }
- exception { e ->
- call.respond(HttpStatusCode.NotFound, e.message!!)
- }
- exception {
- call.respond(HttpStatusCode.Forbidden)
- }
- }
-
- install(CORS) {
- method(HttpMethod.Options)
- method(HttpMethod.Put)
- method(HttpMethod.Delete)
- header(HttpHeaders.Authorization)
- anyHost()
- // host("localhost:4200", schemes = listOf("http", "https"))
- allowCredentials = true
- allowSameOrigin = true
- maxAge = Duration.ofDays(1)
- }
-
- if (env == PROD) {
- get().run()
- }
-}
diff --git a/src/main/kotlin/Configuration.kt b/src/main/kotlin/Configuration.kt
deleted file mode 100644
index d4a0a7c..0000000
--- a/src/main/kotlin/Configuration.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package fr.dcproject
-
-import com.auth0.jwt.JWT
-import com.auth0.jwt.JWTVerifier
-import com.auth0.jwt.algorithms.Algorithm
-import com.typesafe.config.ConfigFactory
-import fr.dcproject.entity.UserI
-import java.util.*
-import java.net.URI
-
-object Config {
- private var config = ConfigFactory.load()
-
- object Sql {
- val migrationFiles: URI = this::class.java.getResource("/sql/migrations").toURI()
- val functionFiles: URI = this::class.java.getResource("/sql/functions").toURI()
- val fixtureFiles: URI = this::class.java.getResource("/sql/fixtures").toURI()
- }
-
- val envName: String = config.getString("app.envName")
- val domain: String = config.getString("app.domain")
-
- val host: String = config.getString("db.host")
- var database: String = config.getString("db.database")
- var username: String = config.getString("db.username")
- var password: String = config.getString("db.password")
- val port: Int = config.getInt("db.port")
- val redis: String = config.getString("redis.connection")
- val elasticsearch: String = config.getString("elasticsearch.connection")
- val rabbitmq: String = config.getString("rabbitmq.connection")
- val exchangeNotificationName = "notification"
- val sendGridKey: String = config.getString("mail.sendGrid.key")
-}
-
-object JwtConfig {
- private const val secret = "zAP5MBA4B4Ijz0MZaS48"
- const val issuer = "dc-project.fr"
- private const val validityInMs = 3_600_000 * 10 // 10 hours
-
- // TODO change to RSA512
- val algorithm = Algorithm.HMAC512(secret)
-
- val verifier: JWTVerifier = JWT
- .require(algorithm)
- .withIssuer(issuer)
- .build()
-
- /**
- * Produce a token for this combination of User and Account
- */
- fun makeToken(user: UserI): String = JWT.create()
- .withSubject("Authentication")
- .withIssuer(issuer)
- .withClaim("id", user.id.toString())
- .withExpiresAt(getExpiration())
- .sign(algorithm)
-
- /**
- * Calculate the expiration Date based on current time + the given validity
- */
- private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
-}
\ No newline at end of file
diff --git a/src/main/kotlin/Module.kt b/src/main/kotlin/Module.kt
deleted file mode 100644
index c107cea..0000000
--- a/src/main/kotlin/Module.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package fr.dcproject
-
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.PropertyNamingStrategy
-import com.fasterxml.jackson.databind.SerializationFeature
-import com.fasterxml.jackson.databind.module.SimpleModule
-import com.fasterxml.jackson.datatype.joda.JodaModule
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.rabbitmq.client.ConnectionFactory
-import fr.dcproject.event.publisher.Publisher
-import fr.dcproject.messages.Mailer
-import fr.dcproject.messages.NotificationEmailSender
-import fr.dcproject.messages.SsoManager
-import fr.dcproject.views.ArticleViewManager
-import fr.postgresjson.connexion.Connection
-import fr.postgresjson.connexion.Requester
-import fr.postgresjson.migration.Migrations
-import io.ktor.client.HttpClient
-import io.ktor.client.features.websocket.WebSockets
-import io.ktor.util.KtorExperimentalAPI
-import io.lettuce.core.RedisClient
-import io.lettuce.core.api.async.RedisAsyncCommands
-import org.apache.http.HttpHost
-import org.elasticsearch.client.RestClient
-import org.koin.core.qualifier.named
-import org.koin.dsl.module
-import fr.dcproject.repository.Article as ArticleRepository
-import fr.dcproject.repository.Citizen as CitizenRepository
-import fr.dcproject.repository.CommentArticle as CommentArticleRepository
-import fr.dcproject.repository.CommentConstitution as CommentConstitutionRepository
-import fr.dcproject.repository.CommentGeneric as CommentGenericRepository
-import fr.dcproject.repository.Constitution as ConstitutionRepository
-import fr.dcproject.repository.FollowArticle as FollowArticleRepository
-import fr.dcproject.repository.FollowConstitution as FollowConstitutionRepository
-import fr.dcproject.repository.OpinionArticle as OpinionArticleRepository
-import fr.dcproject.repository.OpinionChoice as OpinionChoiceRepository
-import fr.dcproject.repository.User as UserRepository
-import fr.dcproject.repository.VoteArticle as VoteArticleRepository
-import fr.dcproject.repository.VoteComment as VoteCommentRepository
-import fr.dcproject.repository.VoteConstitution as VoteConstitutionRepository
-import fr.dcproject.repository.Workgroup as WorkgroupRepository
-
-@KtorExperimentalAPI
-val Module = module {
-
- single { Config }
-
- // SQL connection
- single {
- Connection(
- host = Config.host,
- port = Config.port,
- database = Config.database,
- username = Config.username,
- password = Config.password
- )
- }
-
- // Launch Database migration
- single { Migrations(get(), Config.Sql.migrationFiles, Config.Sql.functionFiles) }
-
- // Redis client
- single> {
- RedisClient.create(Config.redis).connect()?.async() ?: error("Unable to connect to redis")
- }
-
- // RabbitMQ
- single {
- ConnectionFactory().apply { setUri(Config.rabbitmq) }
- }
-
- // JsonSerializer
- single {
- jacksonObjectMapper().apply {
- registerModule(SimpleModule())
- propertyNamingStrategy = PropertyNamingStrategy.SNAKE_CASE
-
- registerModule(JodaModule())
- disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
- configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
- }
- }
-
- // Client HTTP for WebSockets
- single(named("ws")) {
- HttpClient {
- install(WebSockets)
- }
- }
-
- // SQL Requester (postgresJson)
- single {
- Requester.RequesterFactory(
- connection = get(),
- functionsDirectory = Config.Sql.functionFiles
- ).createRequester()
- }
-
- // Repositories
- single { UserRepository(get()) }
- single { ArticleRepository(get()) }
- single { CitizenRepository(get()) }
- single { ConstitutionRepository(get()) }
- single { FollowArticleRepository(get()) }
- single { FollowConstitutionRepository(get()) }
- single { CommentGenericRepository(get()) }
- single { CommentArticleRepository(get()) }
- single { CommentConstitutionRepository(get()) }
- single { VoteArticleRepository(get()) }
- single { VoteConstitutionRepository(get()) }
- single { VoteCommentRepository(get()) }
- single { OpinionChoiceRepository(get()) }
- single { OpinionArticleRepository(get()) }
- single { WorkgroupRepository(get()) }
-
- // Elasticsearch Client
- single {
- RestClient.builder(
- HttpHost.create(Config.elasticsearch)
- ).build()
- }
-
- single { ArticleViewManager(get()) }
-
- // Mailler
- single { Mailer(Config.sendGridKey) }
-
- // SSO Manager for connection
- single { SsoManager(get(), Config.domain, get()) }
-
- single { Publisher(get(), get()) }
-
- single { NotificationEmailSender(get(), Config.domain, get(), get()) }
-}
diff --git a/src/main/kotlin/elasticsearch/Config.kt b/src/main/kotlin/elasticsearch/Config.kt
deleted file mode 100644
index ecc2abf..0000000
--- a/src/main/kotlin/elasticsearch/Config.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package fr.dcproject.elasticsearch
-
-import org.elasticsearch.client.Request
-import org.elasticsearch.client.RestClient
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-
-fun waitElasticsearchIsUp(client: RestClient) {
- val logger: Logger = LoggerFactory.getLogger("fr.dcproject.elasticsearch")
- val request = Request("GET", "/_cluster/health")
- repeat(40) {
- runCatching {
- client.performRequest(request).statusLine.statusCode
- }.onSuccess {
- if (it == 200) {
- logger.debug("Elasticsearch is Ready! Continue...")
- return
- } else {
- logger.debug("sleep 2s and retry...")
- Thread.sleep(2000)
- }
- }.onFailure {
- logger.debug("${it.message}, sleep 2s and retry...")
- Thread.sleep(2000)
- }
- }
- error("Elasticsearch is not ready")
-}
-
-fun configElasticIndexes(client: RestClient) {
- waitElasticsearchIsUp(client)
-
- /* Create index if not exist */
- client.run {
- if (performRequest(Request("HEAD", "/views?include_type_name=false")).statusLine.statusCode == 404) {
- Request(
- "PUT",
- "/views?include_type_name=false"
- ).apply {
- //language=JSON
- setJsonEntity(
- """
- {
- "settings": {
- "number_of_shards": 5
- },
- "mappings": {
- "properties": {
- "logged": {
- "type": "boolean"
- },
- "type": {
- "type": "keyword"
- },
- "user_ref": {
- "type": "keyword"
- },
- "id": {
- "type": "keyword"
- },
- "version_id": {
- "type": "keyword"
- },
- "ip": {
- "type": "keyword"
- },
- "citizen_id": {
- "type": "keyword"
- },
- "view_at": {
- "type": "date"
- }
- }
- }
- }
- """.trimIndent()
- )
- }.let {
- performRequest(it)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Article.kt b/src/main/kotlin/entity/Article.kt
deleted file mode 100644
index 335ec1a..0000000
--- a/src/main/kotlin/entity/Article.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.*
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import fr.postgresjson.entity.mutable.EntityVersioning
-import fr.postgresjson.entity.mutable.UuidEntityVersioning
-import java.util.*
-
-class Article(
- id: UUID? = null,
- title: String,
- override var anonymous: Boolean = true,
- override var content: String,
- override var description: String,
- override var tags: List = emptyList(),
- draft: Boolean = false,
- override var lastVersion: Boolean = false,
- override val createdBy: CitizenBasic,
- workgroup: WorkgroupSimple? = null
-) : ArticleFull,
- ArticleAuthI,
- ArticleSimple(id, title, createdBy, draft, workgroup),
- Viewable by ViewableImp() {
- init {
- tags = tags.distinct()
- }
-}
-
-class ArticleForUpdate(
- id: UUID?,
- val title: String,
- val anonymous: Boolean = true,
- val content: String,
- val description: String,
- tags: List = emptyList(),
- val draft: Boolean = false,
- val createdBy: CitizenRef,
- val workgroup: WorkgroupRef? = null
-) : ArticleRefVersioning(id) {
- val tags: List = tags.distinct()
-}
-
-open class ArticleSimple(
- id: UUID? = null,
- override var title: String,
- override val createdBy: CitizenBasic,
- override var draft: Boolean = false,
- override var workgroup: WorkgroupSimple? = null
-) : ArticleSimpleI,
- ArticleAuthI,
- ArticleRefVersioning(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy),
- EntityDeletedAt by EntityDeletedAtImp(),
- Votable by VotableImp(),
- Opinionable by OpinionableImp()
-
-open class ArticleRefVersioning(
- id: UUID? = null,
- versionNumber: Int? = null,
- versionId: UUID = UUID.randomUUID()
-) : ArticleRef(id),
- EntityVersioning by UuidEntityVersioning(versionNumber, versionId)
-
-open class ArticleRef(
- id: UUID? = null
-) : ArticleI, TargetRef(id)
-
-interface ArticleI : UuidEntityI, TargetI
-
-interface ArticleSimpleI :
- ArticleI,
- EntityVersioning,
- EntityCreatedBy,
- EntityCreatedAt,
- EntityDeletedAt,
- Votable {
- var title: String
- var workgroup: WorkgroupSimple?
-}
-
-interface ArticleBasicI :
- ArticleSimpleI {
- var anonymous: Boolean
- var content: String
- var description: String
- var tags: List
-}
-
-interface ArticleFull :
- ArticleBasicI {
- var draft: Boolean
- var lastVersion: Boolean
-}
-
-interface ArticleAuthI :
- ArticleI,
- EntityCreatedBy,
- EntityDeletedAt {
- var draft: Boolean
-}
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Citizen.kt b/src/main/kotlin/entity/Citizen.kt
deleted file mode 100644
index af86096..0000000
--- a/src/main/kotlin/entity/Citizen.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-package fr.dcproject.entity
-
-import fr.dcproject.entity.CitizenI.Name
-import fr.postgresjson.entity.immutable.EntityCreatedAt
-import fr.postgresjson.entity.immutable.EntityCreatedAtImp
-import fr.postgresjson.entity.immutable.UuidEntity
-import fr.postgresjson.entity.immutable.UuidEntityI
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import org.joda.time.DateTime
-import java.util.*
-
-class Citizen(
- id: UUID = UUID.randomUUID(),
- name: Name,
- email: String,
- birthday: DateTime,
- voteAnonymous: Boolean = true,
- followAnonymous: Boolean = true,
- override val user: User
-) : CitizenFull,
- CitizenBasic(id, name, email, birthday, voteAnonymous, followAnonymous, user),
- EntityCreatedAt by EntityCreatedAtImp() {
- var workgroups: List = emptyList()
-
- class WorkgroupAndRoles(
- val roles: List,
- val workgroup: WorkgroupSimple
- )
-}
-
-open class CitizenBasic(
- id: UUID = UUID.randomUUID(),
- name: Name,
- override var email: String,
- override var birthday: DateTime,
- override var voteAnonymous: Boolean = true,
- override var followAnonymous: Boolean = true,
- override val user: User
-) : CitizenBasicI,
- CitizenSimple(id, name, user)
-
-open class CitizenSimple(
- id: UUID = UUID.randomUUID(),
- var name: Name,
- user: UserRef
-) : CitizenRefWithUser(id, user)
-
-open class CitizenRefWithUser(
- id: UUID = UUID.randomUUID(),
- override val user: UserRef
-) : CitizenWithUserI,
- CitizenRef(id),
- EntityDeletedAt by EntityDeletedAtImp()
-
-open class CitizenRef(
- id: UUID = UUID.randomUUID()
-) : UuidEntity(id),
- CitizenI
-
-interface CitizenI : UuidEntityI {
- data class Name(
- var firstName: String,
- var lastName: String,
- var civility: String? = null
- ) {
- fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim()
- }
-}
-
-interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt {
- var name: Name
- var email: String
- var birthday: DateTime
- var voteAnonymous: Boolean
- var followAnonymous: Boolean
-}
-
-interface CitizenFull : CitizenBasicI {
- override val user: User
-}
-
-interface CitizenWithUserI : CitizenI {
- val user: UserI
-}
diff --git a/src/main/kotlin/entity/Comment.kt b/src/main/kotlin/entity/Comment.kt
deleted file mode 100644
index cb3c1e0..0000000
--- a/src/main/kotlin/entity/Comment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.*
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import java.util.*
-
-open class Comment(
- id: UUID = UUID.randomUUID(),
- override val createdBy: CitizenBasic,
- override var target: T,
- var content: String,
- val responses: List>? = null,
- var parent: Comment? = null,
- val parentsIds: List? = null,
- val childrenCount: Int? = null
-) : ExtraI,
- CommentRef(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy),
- EntityUpdatedAt by EntityUpdatedAtImp(),
- EntityDeletedAt by EntityDeletedAtImp(),
- Votable by VotableImp(),
- TargetI {
- constructor(
- createdBy: CitizenBasic,
- parent: Comment,
- content: String
- ) : this(
- createdBy = createdBy,
- parent = parent,
- target = parent.target,
- content = content
- )
-}
-
-open class CommentRef(id: UUID = UUID.randomUUID()) : CommentS(id)
-
-sealed class CommentS(id: UUID) : TargetRef(id)
diff --git a/src/main/kotlin/entity/Constitution.kt b/src/main/kotlin/entity/Constitution.kt
deleted file mode 100644
index 66ddc2a..0000000
--- a/src/main/kotlin/entity/Constitution.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.*
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import fr.postgresjson.entity.mutable.EntityVersioning
-import fr.postgresjson.entity.mutable.UuidEntityVersioning
-import java.util.*
-
-class Constitution(
- id: UUID = UUID.randomUUID(),
- title: String,
- anonymous: Boolean = true,
- titles: MutableList> = mutableListOf(),
- draft: Boolean = false,
- lastVersion: Boolean = false,
- override val createdBy: CitizenSimple
-) : ConstitutionSimple>(
- id,
- title = title,
- anonymous = anonymous,
- titles = titles,
- draft = draft,
- lastVersion = lastVersion,
- createdBy = createdBy
-) {
-
- class Title(
- id: UUID = UUID.randomUUID(),
- name: String,
- rank: Int? = null,
- override val articles: MutableList = mutableListOf()
- ) : ConstitutionSimple.TitleSimple(id, name, rank)
-}
-
-open class ConstitutionSimple>(
- id: UUID = UUID.randomUUID(),
- var title: String,
- var anonymous: Boolean = true,
- open var titles: MutableList = mutableListOf(),
- var draft: Boolean = false,
- var lastVersion: Boolean = false,
- override val createdBy: Cr,
- versionId: UUID = UUID.randomUUID()
-) : ConstitutionRef(id),
- EntityVersioning by UuidEntityVersioning(versionId = versionId),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy),
- EntityDeletedAt by EntityDeletedAtImp() {
-
- init {
- titles.forEachIndexed { index, title ->
- title.rank = index
- }
- }
-
- open class TitleSimple(
- id: UUID = UUID.randomUUID(),
- var name: String,
- var rank: Int? = null,
- open val articles: MutableList = mutableListOf()
- ) : TitleRef(id)
-}
-
-open class ConstitutionRef(id: UUID = UUID.randomUUID()) : ConstitutionS(id) {
- open class TitleRef(
- id: UUID = UUID.randomUUID()
- ) : UuidEntity(id)
-}
-
-sealed class ConstitutionS(id: UUID = UUID.randomUUID()) : TargetRef(id), TargetI
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Follow.kt b/src/main/kotlin/entity/Follow.kt
deleted file mode 100644
index 5ac948f..0000000
--- a/src/main/kotlin/entity/Follow.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.*
-import java.util.*
-
-class Follow(
- id: UUID = UUID.randomUUID(),
- override val createdBy: CitizenBasic,
- override var target: T
-) : ExtraI,
- FollowSimple(id, createdBy, target)
-
-open class FollowSimple(
- id: UUID = UUID.randomUUID(),
- override val createdBy: C,
- override var target: T
-) : ExtraI,
- UuidEntity(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy)
diff --git a/src/main/kotlin/entity/Opinion.kt b/src/main/kotlin/entity/Opinion.kt
deleted file mode 100644
index cffc61d..0000000
--- a/src/main/kotlin/entity/Opinion.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.EntityCreatedAt
-import fr.postgresjson.entity.immutable.EntityCreatedAtImp
-import fr.postgresjson.entity.immutable.EntityCreatedBy
-import fr.postgresjson.entity.immutable.EntityCreatedByImp
-import java.util.*
-
-open class Opinion(
- id: UUID = UUID.randomUUID(),
- override val createdBy: CitizenBasic,
- override val target: T,
- val choice: OpinionChoice
-) : ExtraI,
- TargetRef(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy) {
-
- fun getName(): String = choice.name
-}
-
-class OpinionArticle(
- id: UUID = UUID.randomUUID(),
- createdBy: CitizenBasic,
- target: ArticleRef,
- choice: OpinionChoice
-) : Opinion(id, createdBy, target, choice)
\ No newline at end of file
diff --git a/src/main/kotlin/entity/OpinionChoice.kt b/src/main/kotlin/entity/OpinionChoice.kt
deleted file mode 100644
index c25632c..0000000
--- a/src/main/kotlin/entity/OpinionChoice.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.EntityCreatedAt
-import fr.postgresjson.entity.immutable.EntityCreatedAtImp
-import fr.postgresjson.entity.immutable.UuidEntity
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import java.util.*
-
-class OpinionChoice(
- id: UUID? = null,
- val name: String,
- val target: List?
-) : OpinionChoiceRef(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityDeletedAt by EntityDeletedAtImp()
-
-open class OpinionChoiceRef(
- id: UUID?
-) : UuidEntity(id ?: UUID.randomUUID())
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Opinionable.kt b/src/main/kotlin/entity/Opinionable.kt
deleted file mode 100644
index ba84451..0000000
--- a/src/main/kotlin/entity/Opinionable.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.EntityI
-
-class OpinionAggregation(
- private val underlying: MutableMap = mutableMapOf()
-) : MutableMap by underlying, EntityI
-
-interface Opinionable {
- var opinions: MutableMap
-}
-
-class OpinionableImp : Opinionable {
- override var opinions: MutableMap = mutableMapOf()
-}
\ No newline at end of file
diff --git a/src/main/kotlin/entity/User.kt b/src/main/kotlin/entity/User.kt
deleted file mode 100644
index e7a5317..0000000
--- a/src/main/kotlin/entity/User.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package fr.dcproject.entity
-
-import fr.dcproject.entity.UserI.Roles
-import fr.postgresjson.entity.immutable.*
-import io.ktor.auth.Principal
-import org.joda.time.DateTime
-import java.util.*
-
-class User(
- id: UUID = UUID.randomUUID(),
- username: String,
- blockedAt: DateTime? = null,
- override var plainPassword: String? = null,
- override var roles: List = emptyList()
-) : UserFull, UserBasic(id, username, blockedAt),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityUpdatedAt by EntityUpdatedAtImp()
-
-open class UserBasic(
- id: UUID = UUID.randomUUID(),
- override var username: String,
- override var blockedAt: DateTime? = null
-) : UserBasicI, UserRef(id)
-
-open class UserRef(
- id: UUID = UUID.randomUUID()
-) : UserI, UuidEntity(id)
-
-interface UserI : UuidEntityI, Principal {
- enum class Roles { ROLE_USER, ROLE_ADMIN }
-}
-
-interface UserBasicI : UserI {
- var username: String
- var blockedAt: DateTime?
-}
-
-interface UserFull : UserBasicI, EntityCreatedAt, EntityUpdatedAt {
- var plainPassword: String?
- var roles: List
-}
diff --git a/src/main/kotlin/entity/ViewAggregation.kt b/src/main/kotlin/entity/ViewAggregation.kt
deleted file mode 100644
index 9b810e7..0000000
--- a/src/main/kotlin/entity/ViewAggregation.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.EntityI
-import fr.postgresjson.entity.immutable.EntityUpdatedAt
-import fr.postgresjson.entity.immutable.EntityUpdatedAtImp
-
-open class ViewAggregation(
- val total: Int,
- val unique: Int
-) : EntityI,
- EntityUpdatedAt by EntityUpdatedAtImp() {
- constructor() : this(0, 0)
-}
diff --git a/src/main/kotlin/entity/Viewable.kt b/src/main/kotlin/entity/Viewable.kt
deleted file mode 100644
index ddbb8b0..0000000
--- a/src/main/kotlin/entity/Viewable.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package fr.dcproject.entity
-
-interface Viewable {
- var views: ViewAggregation
-}
-
-class ViewableImp : Viewable {
- override var views: ViewAggregation = ViewAggregation()
-}
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Votable.kt b/src/main/kotlin/entity/Votable.kt
deleted file mode 100644
index c2725f7..0000000
--- a/src/main/kotlin/entity/Votable.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package fr.dcproject.entity
-
-interface Votable {
- var votes: VoteAggregation
-}
-
-class VotableImp : Votable {
- override var votes: VoteAggregation = VoteAggregation()
-}
\ No newline at end of file
diff --git a/src/main/kotlin/entity/Vote.kt b/src/main/kotlin/entity/Vote.kt
deleted file mode 100644
index 644e451..0000000
--- a/src/main/kotlin/entity/Vote.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.immutable.*
-import java.util.*
-
-open class Vote(
- id: UUID = UUID.randomUUID(),
- override val createdBy: CitizenBasic,
- override var target: T,
- var note: Int,
- var anonymous: Boolean = true
-) : ExtraI,
- UuidEntity(id),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityCreatedBy by EntityCreatedByImp(createdBy),
- EntityUpdatedAt by EntityUpdatedAtImp() {
- init {
- if (note > 1 && note < -1) {
- error("note must be 1, 0 or -1")
- }
- }
-}
diff --git a/src/main/kotlin/entity/VoteAggregation.kt b/src/main/kotlin/entity/VoteAggregation.kt
deleted file mode 100644
index f30bb43..0000000
--- a/src/main/kotlin/entity/VoteAggregation.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package fr.dcproject.entity
-
-import fr.postgresjson.entity.EntityI
-import fr.postgresjson.entity.mutable.EntityUpdatedAt
-import fr.postgresjson.entity.mutable.EntityUpdatedAtImp
-
-open class VoteAggregation(
- val up: Int,
- val neutral: Int,
- val down: Int,
- val total: Int,
- val score: Int
-) : EntityI,
- EntityUpdatedAt by EntityUpdatedAtImp() {
- constructor() : this(0, 0, 0, 0, 0)
-}
diff --git a/src/main/kotlin/entity/Workgroup.kt b/src/main/kotlin/entity/Workgroup.kt
deleted file mode 100644
index 2b105bd..0000000
--- a/src/main/kotlin/entity/Workgroup.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-package fr.dcproject.entity
-
-import fr.dcproject.entity.WorkgroupWithMembersI.Member
-import fr.dcproject.entity.WorkgroupWithMembersI.Member.Role
-import fr.postgresjson.entity.EntityI
-import fr.postgresjson.entity.immutable.*
-import fr.postgresjson.entity.mutable.EntityDeletedAt
-import fr.postgresjson.entity.mutable.EntityDeletedAtImp
-import java.util.*
-
-class Workgroup(
- id: UUID? = null,
- name: String,
- description: String,
- logo: String? = null,
- anonymous: Boolean = true,
- createdBy: CitizenBasic,
- override var members: List> = emptyList()
-) : WorkgroupWithAuthI,
- WorkgroupSimple(
- id,
- name,
- description,
- logo,
- anonymous,
- createdBy
- ),
- EntityCreatedAt by EntityCreatedAtImp(),
- EntityUpdatedAt by EntityUpdatedAtImp()
-
-open class WorkgroupSimple(
- id: UUID? = null,
- var name: String,
- var description: String,
- var logo: String? = null,
- var anonymous: Boolean = true,
- createdBy: Z
-) : WorkgroupRef(id),
- EntityCreatedBy by EntityCreatedByImp(createdBy),
- EntityDeletedAt by EntityDeletedAtImp()
-
-open class WorkgroupRef(
- id: UUID? = null
-) : UuidEntity(id ?: UUID.randomUUID()), WorkgroupI
-
-interface WorkgroupWithAuthI : WorkgroupWithMembersI, EntityCreatedBy, EntityDeletedAt {
- val anonymous: Boolean
-
- fun isMember(user: UserI): Boolean = members.isMember(user)
- fun isMember(citizen: CitizenWithUserI): Boolean = members.isMember(citizen)
-
- fun hasRole(expectedRole: Role, user: UserI): Boolean = members.hasRole(expectedRole, user)
- fun hasRole(expectedRole: Role, citizen: CitizenI): Boolean = members.hasRole(expectedRole, citizen)
-
- fun getRoles(user: UserI): List = members.getRoles(user)
- fun getRoles(citizen: CitizenI): List = members.getRoles(citizen)
-}
-
-interface WorkgroupWithMembersI : WorkgroupI {
- var members: List>
-
- class Member (
- val citizen: C,
- val roles: List = emptyList()
- ) : EntityI {
- enum class Role {
- MASTER,
- MANAGER,
- EDITOR,
- REPORTER
- }
- }
-}
-
-fun List.hasCitizen(citizen: CitizenI): Boolean = this.map { it.id }.contains(citizen.id)
-
-fun List>.isMember(user: UserI): Boolean =
- map { it.citizen.user.id }.contains(user.id)
-
-fun List>.isMember(citizen: CitizenI): Boolean =
- map { it.citizen.id }.contains(citizen.id)
-
-fun List>.hasRole(expectedRole: Role, citizen: CitizenI): Boolean =
- any { member -> member.citizen.id == citizen.id && member.roles.any { it == expectedRole } }
-
-fun List>.hasRole(expectedRole: Role, user: UserI): Boolean =
- any { member -> member.citizen.user.id == user.id && member.roles.any { it == expectedRole } }
-
-fun List>.getRoles(user: UserI): List =
- firstOrNull { it.citizen.user.id == user.id }?.roles ?: emptyList()
-
-fun List>.getRoles(citizen: CitizenI): List =
- firstOrNull { it.citizen.id == citizen.id }?.roles ?: emptyList()
-
-interface WorkgroupI : UuidEntityI
\ No newline at end of file
diff --git a/src/main/kotlin/event/EventNotification.kt b/src/main/kotlin/event/EventNotification.kt
deleted file mode 100644
index 0e92412..0000000
--- a/src/main/kotlin/event/EventNotification.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-package fr.dcproject.event
-
-import com.rabbitmq.client.*
-import com.rabbitmq.client.BuiltinExchangeType.DIRECT
-import fr.dcproject.Config
-import fr.dcproject.entity.Article
-import fr.dcproject.entity.CitizenRef
-import fr.dcproject.entity.FollowSimple
-import fr.dcproject.entity.TargetRef
-import fr.dcproject.event.publisher.Publisher
-import fr.dcproject.messages.NotificationEmailSender
-import fr.dcproject.repository.Follow
-import fr.postgresjson.serializer.deserialize
-import io.ktor.application.ApplicationCall
-import io.ktor.application.EventDefinition
-import io.ktor.application.application
-import io.ktor.util.pipeline.PipelineContext
-import io.lettuce.core.api.async.RedisAsyncCommands
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.io.errors.IOException
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import fr.dcproject.repository.FollowArticle as FollowArticleRepository
-
-class ArticleUpdate(
- target: Article
-) : EntityEvent(target, "article", "update") {
- companion object {
- val event = EventDefinition()
- }
-}
-
-fun PipelineContext.raiseEvent(definition: EventDefinition, value: T) =
- application.environment.monitor.raise(definition, value)
-
-class EventNotification(
- private val config: EventSubscriber.Configuration,
- private val rabbitFactory: ConnectionFactory,
- private val redis: RedisAsyncCommands,
- private val followRepo: FollowArticleRepository,
- private val publisher: Publisher,
- private val notificationEmailSender: NotificationEmailSender
-) {
- private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName)
-
- fun config() {
- /* Config Rabbit */
- val exchangeName = Config.exchangeNotificationName
- rabbitFactory.newConnection().use { connection ->
- connection.createChannel().use { channel ->
- channel.queueDeclare("push", true, false, false, null)
- channel.queueDeclare("email", true, false, false, null)
- channel.exchangeDeclare(exchangeName, DIRECT, true)
- channel.queueBind("push", exchangeName, "")
- channel.queueBind("email", exchangeName, "")
- }
- }
-
- /* Declare publisher on event */
- config.subscribe(ArticleUpdate.event) {
- publisher.publish(it)
- }
-
- /* Launch Consumer */
- GlobalScope.launch {
- val rabbitChannel = rabbitFactory.newConnection().createChannel()
-
- val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) {
- @Throws(IOException::class)
- override fun handleDelivery(
- consumerTag: String,
- envelope: Envelope,
- properties: AMQP.BasicProperties,
- body: ByteArray
- ) = runBlocking {
- decodeEvent(body) {
- redis.zadd(
- "notification:${follow.createdBy.id}",
- event.id,
- rawEvent
- )
- }
-
- rabbitChannel.basicAck(envelope.deliveryTag, false)
- }
- }
-
- val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) {
- @Throws(IOException::class)
- override fun handleDelivery(
- consumerTag: String,
- envelope: Envelope,
- properties: AMQP.BasicProperties,
- body: ByteArray
- ) {
- runBlocking {
- decodeEvent(body) {
- logger.debug("EmailSend to: ${follow.createdBy.id}")
- notificationEmailSender.sendEmail(follow)
- }
- }
- rabbitChannel.basicAck(envelope.deliveryTag, false)
- }
- }
- rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket
- rabbitChannel.basicConsume("email", false, consumerEmail)
- }
- }
-
- private suspend fun decodeEvent(body: ByteArray, action: suspend Msg.() -> Unit) {
- val rawEvent = body.toString(Charsets.UTF_8)
- val event = rawEvent.deserialize() ?: error("Unable to unserialise event message from rabbit")
- val repo = when (event.type) {
- "article" -> followRepo
- else -> error("event '${event.type}' not implemented")
- } as Follow<*, *>
-
- repo
- .findFollowsByTarget(event.target)
- .collect {
- Msg(event, rawEvent, it).action()
- }
- }
-
- private class Msg(
- val event: EntityEvent,
- val rawEvent: String,
- val follow: FollowSimple
- )
-}
diff --git a/src/main/kotlin/event/EventSubscriber.kt b/src/main/kotlin/event/EventSubscriber.kt
deleted file mode 100644
index 23f553d..0000000
--- a/src/main/kotlin/event/EventSubscriber.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-package fr.dcproject.event
-
-import fr.postgresjson.entity.Serializable
-import fr.postgresjson.entity.immutable.UuidEntity
-import io.ktor.application.*
-import io.ktor.util.AttributeKey
-import io.ktor.util.KtorExperimentalAPI
-import kotlinx.coroutines.DisposableHandle
-import org.joda.time.DateTime
-import kotlin.random.Random.Default.nextInt
-
-open class Event(
- val type: String,
- val createdAt: DateTime = DateTime.now()
-) : Serializable {
- val id: Double = randId(createdAt.millis)
-
- private fun randId(time: Long): Double {
- return (time.toString() + nextInt(1000, 9999).toString()).toDouble()
- }
-}
-
-open class EntityEvent(
- val target: UuidEntity,
- type: String,
- val action: String
-) : Event(type)
-
-/**
- * Installation Class
- */
-class EventSubscriber {
- class Configuration(private val monitor: ApplicationEvents) {
- private val subscribers = mutableListOf()
- fun subscribe(definition: EventDefinition, handler: EventHandler): DisposableHandle {
- return monitor.subscribe(definition, handler).also {
- subscribers.add(it)
- }
- }
- }
-
- companion object Feature : ApplicationFeature {
- override val key = AttributeKey("EventSubscriber")
-
- @KtorExperimentalAPI
- override fun install(
- pipeline: Application,
- configure: Configuration.() -> Unit
- ): EventSubscriber {
- Configuration(pipeline.environment.monitor).apply(configure)
- return EventSubscriber()
- }
- }
-}
diff --git a/src/main/kotlin/event/publisher/Publisher.kt b/src/main/kotlin/event/publisher/Publisher.kt
deleted file mode 100644
index 89bd9a5..0000000
--- a/src/main/kotlin/event/publisher/Publisher.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package fr.dcproject.event.publisher
-
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.rabbitmq.client.ConnectionFactory
-import fr.dcproject.Config
-import fr.dcproject.event.EntityEvent
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-
-class Publisher(
- private val mapper: ObjectMapper,
- private val factory: ConnectionFactory,
- private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName)
-) {
- fun publish(it: T): Job {
- return GlobalScope.launch {
- factory.newConnection().use { connection ->
- connection.createChannel().use { channel ->
- channel.basicPublish(Config.exchangeNotificationName, "", null, it.serialize().toByteArray())
- logger.debug("Publish message ${it.target.id}")
- }
- }
- }
- }
-
- private fun EntityEvent.serialize(): String {
- return mapper.writeValueAsString(this) ?: error("Unable tu serialize message")
- }
-}
diff --git a/src/main/kotlin/fr/dcproject/application/Application.kt b/src/main/kotlin/fr/dcproject/application/Application.kt
new file mode 100644
index 0000000..eb55b1d
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/application/Application.kt
@@ -0,0 +1,213 @@
+package fr.dcproject.application
+
+import com.fasterxml.jackson.core.util.DefaultIndenter
+import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.PropertyNamingStrategies
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.datatype.joda.JodaModule
+import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
+import fr.dcproject.application.Env.PROD
+import fr.dcproject.application.Env.TEST
+import fr.dcproject.common.security.AccessDeniedException
+import fr.dcproject.component.article.articleKoinModule
+import fr.dcproject.component.article.routes.installArticleRoutes
+import fr.dcproject.component.auth.ForbiddenException
+import fr.dcproject.component.auth.authKoinModule
+import fr.dcproject.component.auth.jwt.jwtInstallation
+import fr.dcproject.component.auth.routes.installAuthRoutes
+import fr.dcproject.component.auth.user
+import fr.dcproject.component.citizen.citizenKoinModule
+import fr.dcproject.component.citizen.routes.installCitizenRoutes
+import fr.dcproject.component.comment.article.routes.installCommentArticleRoutes
+import fr.dcproject.component.comment.commentKoinModule
+import fr.dcproject.component.comment.constitution.routes.installCommentConstitutionRoutes
+import fr.dcproject.component.comment.generic.routes.installCommentRoutes
+import fr.dcproject.component.constitution.constitutionKoinModule
+import fr.dcproject.component.constitution.routes.installConstitutionRoutes
+import fr.dcproject.component.doc.routes.installDocRoutes
+import fr.dcproject.component.follow.followKoinModule
+import fr.dcproject.component.follow.routes.article.installFollowArticleRoutes
+import fr.dcproject.component.follow.routes.constitution.installFollowConstitutionRoutes
+import fr.dcproject.component.notification.NotificationConsumer
+import fr.dcproject.component.notification.routes.installNotificationsRoutes
+import fr.dcproject.component.opinion.opinionKoinModule
+import fr.dcproject.component.opinion.routes.installOpinionRoutes
+import fr.dcproject.component.views.viewKoinModule
+import fr.dcproject.component.vote.routes.installVoteRoutes
+import fr.dcproject.component.vote.voteKoinModule
+import fr.dcproject.component.workgroup.routes.installWorkgroupRoutes
+import fr.dcproject.component.workgroup.workgroupKoinModule
+import fr.postgresjson.migration.Migrations
+import io.ktor.application.Application
+import io.ktor.application.ApplicationStopped
+import io.ktor.application.call
+import io.ktor.application.install
+import io.ktor.auth.Authentication
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.jetty.Jetty
+import io.ktor.features.AutoHeadResponse
+import io.ktor.features.CORS
+import io.ktor.features.CallLogging
+import io.ktor.features.ContentNegotiation
+import io.ktor.features.DataConversion
+import io.ktor.features.NotFoundException
+import io.ktor.features.StatusPages
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.cio.websocket.pingPeriod
+import io.ktor.http.cio.websocket.timeout
+import io.ktor.jackson.jackson
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Locations
+import io.ktor.response.respond
+import io.ktor.routing.Routing
+import io.ktor.server.jetty.EngineMain
+import io.ktor.util.KtorExperimentalAPI
+import io.ktor.websocket.WebSockets
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.eclipse.jetty.util.log.Slf4jLog
+import org.koin.dsl.module
+import org.koin.ktor.ext.Koin
+import org.koin.ktor.ext.get
+import org.slf4j.event.Level
+import java.time.Duration
+import java.util.concurrent.CompletionException
+
+fun main(args: Array): Unit = EngineMain.main(args)
+
+enum class Env { PROD, TEST }
+
+@ExperimentalCoroutinesApi
+@KtorExperimentalAPI
+@KtorExperimentalLocationsAPI
+@Suppress("unused") // Referenced in application.conf
+fun Application.module(env: Env = PROD) {
+ install(Koin) {
+ Slf4jLog()
+ modules(
+ listOf(
+ if (env == TEST) module { single { Configuration("application-test.conf") } }
+ else module { single { Configuration() } },
+ KoinModule,
+ articleKoinModule,
+ authKoinModule,
+ citizenKoinModule,
+ commentKoinModule,
+ constitutionKoinModule,
+ followKoinModule,
+ opinionKoinModule,
+ viewKoinModule,
+ voteKoinModule,
+ workgroupKoinModule,
+ )
+ )
+ }
+
+ install(CallLogging) {
+ level = Level.INFO
+ }
+
+ install(DataConversion, converters)
+
+ install(Locations)
+
+ HttpClient(Jetty) {
+ engine {
+ }
+ }
+
+ install(WebSockets) {
+ pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
+ timeout = Duration.ofSeconds(15)
+ maxFrameSize = Long.MAX_VALUE // Disabled (max value). The connection will be closed if surpassed this length.
+ masking = false
+ }
+
+ get().run {
+ start()
+ environment.monitor.subscribe(ApplicationStopped) {
+ close()
+ }
+ }
+
+ install(Authentication, jwtInstallation(get()))
+
+ install(AutoHeadResponse)
+
+ install(ContentNegotiation) {
+ jackson {
+ propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE
+
+ registerModule(JodaModule())
+ disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
+ configure(SerializationFeature.INDENT_OUTPUT, true)
+ setDefaultPrettyPrinter(
+ DefaultPrettyPrinter().apply {
+ indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
+ indentObjectsWith(DefaultIndenter(" ", "\n"))
+ }
+ )
+ }
+ }
+
+ install(Routing.Feature) {
+ // trace { application.log.trace(it.buildText()) }
+ installArticleRoutes()
+ installAuthRoutes()
+ installCitizenRoutes()
+ installCommentArticleRoutes()
+ installCommentRoutes()
+ installFollowArticleRoutes()
+ installFollowConstitutionRoutes()
+ installWorkgroupRoutes()
+ installOpinionRoutes()
+ installVoteRoutes()
+ installConstitutionRoutes()
+ installCommentConstitutionRoutes()
+ installNotificationsRoutes()
+ installDocRoutes()
+ }
+
+ install(StatusPages) {
+ exception { e ->
+ val parent = e.cause?.cause
+ if (parent is GenericDatabaseException) {
+ call.respond(HttpStatusCode.BadRequest, parent.errorMessage.message!!)
+ } else {
+ throw e
+ }
+ }
+ exception { e ->
+ call.respond(HttpStatusCode.NotFound, e.message!!)
+ }
+ exception {
+ if (call.user == null) call.respond(HttpStatusCode.Unauthorized)
+ else call.respond(HttpStatusCode.Forbidden)
+ }
+ exception {
+ call.respond(HttpStatusCode.Forbidden)
+ }
+ }
+
+ install(CORS) {
+ method(HttpMethod.Options)
+ method(HttpMethod.Put)
+ method(HttpMethod.Delete)
+ header(HttpHeaders.Authorization)
+ if (env == PROD) {
+ host("localhost:4200", schemes = listOf("http", "https"))
+ } else {
+ anyHost()
+ }
+ allowCredentials = true
+ allowSameOrigin = true
+ maxAgeInSeconds = Duration.ofDays(1).seconds
+ }
+
+ if (env == PROD) {
+ get().run()
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/application/Configuration.kt b/src/main/kotlin/fr/dcproject/application/Configuration.kt
new file mode 100644
index 0000000..922e199
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/application/Configuration.kt
@@ -0,0 +1,46 @@
+package fr.dcproject.application
+
+import com.typesafe.config.Config
+import com.typesafe.config.ConfigFactory
+import java.net.URI
+
+class Configuration(val config: Config) {
+ constructor(resourceBasename: String? = null) : this(if (resourceBasename == null) ConfigFactory.load() else ConfigFactory.load(resourceBasename))
+
+ interface Sql {
+ val migrationFiles: URI
+ val functionFiles: URI
+ val fixtureFiles: URI
+ }
+ val sql
+ get() = object : Sql {
+ override val migrationFiles: URI = this::class.java.getResource("/sql/migrations")?.toURI() ?: error("No migrations found")
+ override val functionFiles: URI = this::class.java.getResource("/sql/functions")?.toURI() ?: error("No sql function found")
+ override val fixtureFiles: URI = this::class.java.getResource("/sql/fixtures")?.toURI() ?: error("No sql fixture found")
+ }
+
+ interface Database {
+ val host: String
+ val port: Int
+ var database: String
+ var username: String
+ var password: String
+ }
+ val database
+ get() = object : Database {
+ override val host: String = config.getString("db.host")
+ override val port: Int = config.getInt("db.port")
+ override var database: String = config.getString("db.database")
+ override var username: String = config.getString("db.username")
+ override var password: String = config.getString("db.password")
+ }
+
+ val envName: String = config.getString("app.envName")
+ val domain: String = config.getString("app.domain")
+
+ val redis: String = config.getString("redis.connection")
+ val elasticsearch: String = config.getString("elasticsearch.connection")
+ val rabbitmq: String = config.getString("rabbitmq.connection")
+ val exchangeNotificationName = "notification"
+ val sendGridKey: String = config.getString("mail.sendGrid.key")
+}
diff --git a/src/main/kotlin/fr/dcproject/application/Converters.kt b/src/main/kotlin/fr/dcproject/application/Converters.kt
new file mode 100644
index 0000000..a88ba3b
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/application/Converters.kt
@@ -0,0 +1,31 @@
+package fr.dcproject.application
+
+import io.ktor.features.DataConversion
+import io.ktor.util.KtorExperimentalAPI
+import org.koin.core.context.GlobalContext
+import org.koin.core.parameter.ParametersDefinition
+import org.koin.core.qualifier.Qualifier
+import java.util.UUID
+
+private typealias ConverterDeclaration = DataConversion.Configuration.() -> Unit
+private inline fun DataConversion.Configuration.get(
+ qualifier: Qualifier? = null,
+ noinline parameters: ParametersDefinition? = null
+): T = GlobalContext.get().koin.rootScope.get(qualifier, parameters)
+
+@KtorExperimentalAPI
+val converters: ConverterDeclaration = {
+ convert {
+ decode { values, _ ->
+ values.singleOrNull()?.let { UUID.fromString(it) }
+ }
+
+ encode { value ->
+ when (value) {
+ null -> listOf()
+ is UUID -> listOf(value.toString())
+ else -> throw InternalError("Cannot convert $value as UUID")
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/application/KoinModule.kt b/src/main/kotlin/fr/dcproject/application/KoinModule.kt
new file mode 100644
index 0000000..949b9d5
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/application/KoinModule.kt
@@ -0,0 +1,110 @@
+package fr.dcproject.application
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.PropertyNamingStrategies
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.databind.module.SimpleModule
+import com.fasterxml.jackson.datatype.joda.JodaModule
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.rabbitmq.client.ConnectionFactory
+import fr.dcproject.common.email.Mailer
+import fr.dcproject.component.notification.NotificationConsumer
+import fr.dcproject.component.notification.NotificationEmailSender
+import fr.dcproject.component.notification.NotificationsPush
+import fr.dcproject.component.notification.Publisher
+import fr.postgresjson.connexion.Connection
+import fr.postgresjson.connexion.Requester
+import fr.postgresjson.migration.Migrations
+import io.ktor.client.HttpClient
+import io.ktor.client.features.websocket.WebSockets
+import io.ktor.util.KtorExperimentalAPI
+import io.lettuce.core.RedisClient
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+@KtorExperimentalAPI
+val KoinModule = module {
+ // SQL connection
+ single {
+ val config: Configuration = get()
+ Connection(
+ host = config.database.host,
+ port = config.database.port,
+ database = config.database.database,
+ username = config.database.username,
+ password = config.database.password
+ )
+ }
+
+ // Launch Database migration
+ single {
+ val config: Configuration = get()
+ Migrations(get(), config.sql.migrationFiles, config.sql.functionFiles)
+ }
+
+ // Redis client
+ single {
+ val config: Configuration = get()
+ RedisClient.create(config.redis).apply {
+ connect().sync().configSet("notify-keyspace-events", "KEA")
+ }
+ }
+
+ single { NotificationsPush.Builder(get()) }
+
+ single {
+ val config: Configuration = get()
+ NotificationConsumer(get(), get(), get(), get(), get(), config.exchangeNotificationName)
+ }
+
+ // RabbitMQ
+ single {
+ val config: Configuration = get()
+ ConnectionFactory().apply { setUri(config.rabbitmq) }
+ }
+
+ // JsonSerializer
+ single {
+ jacksonObjectMapper().apply {
+ registerModule(SimpleModule())
+ propertyNamingStrategy = PropertyNamingStrategies.LOWER_CAMEL_CASE
+
+ registerModule(JodaModule())
+ disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+ configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
+ }
+ }
+
+ // Client HTTP for WebSockets
+ single(named("ws")) {
+ HttpClient {
+ install(WebSockets)
+ }
+ }
+
+ // SQL Requester (postgresJson)
+ single {
+ val config: Configuration = get()
+ Requester.RequesterFactory(
+ connection = get(),
+ functionsDirectory = config.sql.functionFiles
+ ).createRequester()
+ }
+
+ // Mailer
+ single {
+ val config: Configuration = get()
+ Mailer(config.sendGridKey)
+ }
+
+ single {
+ val config: Configuration = get()
+ Publisher(factory = get(), exchangeName = config.exchangeNotificationName)
+ }
+
+ single {
+ val config: Configuration = get()
+ NotificationEmailSender(get(), config.domain, get(), get())
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/common/BitMaskEnum.kt b/src/main/kotlin/fr/dcproject/common/BitMaskEnum.kt
new file mode 100644
index 0000000..bad07bb
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/BitMaskEnum.kt
@@ -0,0 +1,11 @@
+package fr.dcproject.common
+
+interface BitMaskI {
+ val bit: Long
+
+ infix operator fun contains(which: BitMaskI): Boolean = bit and which.bit == which.bit
+ infix operator fun plus(mask: BitMaskI): BitMaskI = BitMask(mask.bit and this.bit)
+ infix operator fun minus(mask: BitMaskI): BitMaskI = BitMask(this.bit - mask.bit)
+}
+
+class BitMask(override val bit: Long) : BitMaskI
diff --git a/src/main/kotlin/messages/Mailer.kt b/src/main/kotlin/fr/dcproject/common/email/Mailer.kt
similarity index 94%
rename from src/main/kotlin/messages/Mailer.kt
rename to src/main/kotlin/fr/dcproject/common/email/Mailer.kt
index 4ffa10b..334a283 100644
--- a/src/main/kotlin/messages/Mailer.kt
+++ b/src/main/kotlin/fr/dcproject/common/email/Mailer.kt
@@ -1,4 +1,4 @@
-package fr.dcproject.messages
+package fr.dcproject.common.email
import com.sendgrid.Method
import com.sendgrid.Request
@@ -24,4 +24,4 @@ class Mailer(
throw ex
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/fr/dcproject/common/entity/Action.kt b/src/main/kotlin/fr/dcproject/common/entity/Action.kt
new file mode 100644
index 0000000..6b36054
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/entity/Action.kt
@@ -0,0 +1,28 @@
+package fr.dcproject.common.entity
+
+import fr.dcproject.component.citizen.database.CitizenI
+
+interface Created : CreatedAt, CreatedBy {
+ class Imp(createdBy: C) :
+ Created,
+ CreatedBy by CreatedBy.Imp(createdBy),
+ CreatedAt by CreatedAt.Imp()
+}
+
+interface Updated : UpdatedAt, UpdatedBy {
+ class Imp(updatedAt: C) :
+ Updated,
+ UpdatedBy by UpdatedBy.Imp(updatedAt),
+ UpdatedAt by UpdatedAt.Imp()
+}
+
+interface Deleted : DeletedAt, DeletedBy {
+ override fun isDeleted(): Boolean = (this as DeletedAt).isDeleted()
+
+ class Imp(deletedAt: C) :
+ Deleted,
+ DeletedBy by DeletedBy.Imp(deletedAt),
+ DeletedAt by DeletedAt.Imp() {
+ override fun isDeleted(): Boolean = (this as Deleted).isDeleted()
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/common/entity/ActionBy.kt b/src/main/kotlin/fr/dcproject/common/entity/ActionBy.kt
new file mode 100644
index 0000000..d8f82c7
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/entity/ActionBy.kt
@@ -0,0 +1,25 @@
+package fr.dcproject.common.entity
+
+import fr.dcproject.component.citizen.database.CitizenI
+
+interface CreatedBy {
+ val createdBy: T
+
+ class Imp(override val createdBy: T) : CreatedBy
+}
+
+interface UpdatedBy {
+ val updatedBy: T
+
+ class Imp(override val updatedBy: T) : UpdatedBy
+}
+
+interface DeletedBy {
+ val deletedBy: T?
+
+ fun isDeleted(): Boolean {
+ return deletedBy?.let { true } ?: false
+ }
+
+ class Imp(override val deletedBy: T?) : DeletedBy
+}
diff --git a/src/main/kotlin/fr/dcproject/common/entity/Date.kt b/src/main/kotlin/fr/dcproject/common/entity/Date.kt
new file mode 100644
index 0000000..12fd3ac
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/entity/Date.kt
@@ -0,0 +1,30 @@
+package fr.dcproject.common.entity
+
+import org.joda.time.DateTime
+
+/* Interface */
+interface CreatedAt {
+ val createdAt: DateTime
+ class Imp(
+ override val createdAt: DateTime = DateTime.now()
+ ) : CreatedAt
+}
+interface UpdatedAt {
+ val updatedAt: DateTime
+ class Imp(
+ override val updatedAt: DateTime = DateTime.now()
+ ) : UpdatedAt
+}
+
+interface DeletedAt {
+ val deletedAt: DateTime?
+ fun isDeleted(): Boolean {
+ return deletedAt?.let {
+ it < DateTime.now()
+ } ?: false
+ }
+
+ class Imp(
+ override val deletedAt: DateTime? = null
+ ) : DeletedAt
+}
diff --git a/src/main/kotlin/fr/dcproject/common/entity/EntityI.kt b/src/main/kotlin/fr/dcproject/common/entity/EntityI.kt
new file mode 100644
index 0000000..fa87a5f
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/entity/EntityI.kt
@@ -0,0 +1,12 @@
+package fr.dcproject.common.entity
+
+import fr.postgresjson.entity.UuidEntityI
+import java.util.UUID
+
+interface EntityI : UuidEntityI {
+ override val id: UUID
+}
+
+open class Entity(id: UUID? = null) : EntityI {
+ override val id: UUID = id ?: UUID.randomUUID()
+}
diff --git a/src/main/kotlin/entity/Extra.kt b/src/main/kotlin/fr/dcproject/common/entity/Extra.kt
similarity index 69%
rename from src/main/kotlin/entity/Extra.kt
rename to src/main/kotlin/fr/dcproject/common/entity/Extra.kt
index ae435f2..228235f 100644
--- a/src/main/kotlin/entity/Extra.kt
+++ b/src/main/kotlin/fr/dcproject/common/entity/Extra.kt
@@ -1,21 +1,25 @@
-package fr.dcproject.entity
+package fr.dcproject.common.entity
-import fr.postgresjson.entity.immutable.EntityCreatedAt
-import fr.postgresjson.entity.immutable.EntityCreatedBy
-import fr.postgresjson.entity.immutable.UuidEntity
-import fr.postgresjson.entity.immutable.UuidEntityI
-import java.util.*
+import fr.dcproject.component.article.database.ArticleRef
+import fr.dcproject.component.citizen.database.CitizenI
+import fr.dcproject.component.comment.generic.database.CommentRef
+import fr.dcproject.component.constitution.database.ConstitutionRef
+import fr.dcproject.component.opinion.database.OpinionRef
+import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf
interface ExtraI :
- UuidEntityI,
- EntityCreatedAt,
- EntityCreatedBy {
+ EntityI,
+ HasTarget,
+ CreatedAt,
+ CreatedBy
+
+interface HasTarget {
val target: T
}
-open class TargetRef(id: UUID? = null, reference: String = "") : TargetI, UuidEntity(id) {
+open class TargetRef(id: UUID? = null, reference: String = "") : TargetI, Entity(id) {
final override val reference: String
get() = if (field != "") field else TargetI.getReference(this)
@@ -25,7 +29,7 @@ open class TargetRef(id: UUID? = null, reference: String = "") : TargetI, UuidEn
}
}
-interface TargetI : UuidEntityI {
+interface TargetI : EntityI {
enum class TargetName(val targetReference: String) {
Article("article"),
Constitution("constitution"),
@@ -39,7 +43,7 @@ interface TargetI : UuidEntityI {
t.isSubclassOf(ArticleRef::class) -> TargetName.Article.targetReference
t.isSubclassOf(ConstitutionRef::class) -> TargetName.Constitution.targetReference
t.isSubclassOf(CommentRef::class) -> TargetName.Comment.targetReference
- t.isSubclassOf(Opinion::class) -> TargetName.Opinion.targetReference
+ t.isSubclassOf(OpinionRef::class) -> TargetName.Opinion.targetReference
else -> throw error("target not implemented: ${t.qualifiedName} \nImplement it or return 'reference' from SQL")
}
}
@@ -55,4 +59,4 @@ interface TargetI : UuidEntityI {
}
val reference: String
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/fr/dcproject/common/entity/Versionable.kt b/src/main/kotlin/fr/dcproject/common/entity/Versionable.kt
new file mode 100644
index 0000000..33c493e
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/entity/Versionable.kt
@@ -0,0 +1,25 @@
+package fr.dcproject.common.entity
+
+import java.util.UUID
+
+interface VersionableId {
+ val versionId: UUID
+
+ class Imp(
+ versionId: UUID? = null,
+ ) : VersionableId {
+ override val versionId: UUID = versionId ?: UUID.randomUUID()
+ }
+}
+
+interface Versionable : VersionableId {
+ override val versionId: UUID
+ val versionNumber: Int
+
+ class Imp(
+ override val versionNumber: Int,
+ versionId: UUID? = null,
+ ) : Versionable {
+ override val versionId: UUID = versionId ?: UUID.randomUUID()
+ }
+}
diff --git a/src/main/kotlin/routes/PaginatedRequest.kt b/src/main/kotlin/fr/dcproject/common/request/PaginatedRequest.kt
similarity index 99%
rename from src/main/kotlin/routes/PaginatedRequest.kt
rename to src/main/kotlin/fr/dcproject/common/request/PaginatedRequest.kt
index de27da5..063c961 100644
--- a/src/main/kotlin/routes/PaginatedRequest.kt
+++ b/src/main/kotlin/fr/dcproject/common/request/PaginatedRequest.kt
@@ -11,4 +11,4 @@ open class PaginatedRequest(
) : PaginatedRequestI {
override val page: Int = if (page < 1) 1 else page
override val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/fr/dcproject/common/response/Paginated.kt b/src/main/kotlin/fr/dcproject/common/response/Paginated.kt
new file mode 100644
index 0000000..a3416cb
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/response/Paginated.kt
@@ -0,0 +1,16 @@
+package fr.dcproject.common.response
+
+import fr.dcproject.common.entity.EntityI
+import fr.postgresjson.connexion.Paginated
+
+fun Paginated.toOutput(setup: (E) -> Any): Any {
+ return object {
+ val count = this@toOutput.count
+ val currentPage = this@toOutput.count
+ val limit = this@toOutput.limit
+ val offset = this@toOutput.offset
+ val total = this@toOutput.total
+ val totalPages = this@toOutput.totalPages
+ val result = this@toOutput.result.map { setup(it) }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/common/response/createdBy.kt b/src/main/kotlin/fr/dcproject/common/response/createdBy.kt
new file mode 100644
index 0000000..9a1f3b0
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/response/createdBy.kt
@@ -0,0 +1,21 @@
+package fr.dcproject.common.response
+
+import fr.dcproject.component.citizen.database.CitizenCreatorI
+import java.util.UUID
+
+fun CitizenCreatorI.toOutput(): Any = this.let { c ->
+ object {
+ val id: UUID = c.id
+ val name: Any = c.name.let { n ->
+ object {
+ val firstName: String = n.firstName
+ val lastName: String = n.lastName
+ }
+ }
+ val user: Any = c.user.let { u ->
+ object {
+ val username: String = u.username
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/common/security/AccessControlModule.kt b/src/main/kotlin/fr/dcproject/common/security/AccessControlModule.kt
new file mode 100644
index 0000000..1fe2956
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/security/AccessControlModule.kt
@@ -0,0 +1,134 @@
+package fr.dcproject.common.security
+
+/** Responses of AccessControl */
+enum class AccessDecision {
+ GRANTED,
+ DENIED;
+
+ /**
+ * Convert decision to boolean
+ */
+ fun toBoolean(): Boolean = when (this) {
+ GRANTED -> true
+ DENIED -> false
+ }
+}
+
+abstract class AccessControl {
+ /**
+ * A Shortcut for return a GrantedResponse
+ */
+ protected fun granted(message: String? = null, code: String? = null): GrantedResponse = GrantedResponse(this, message, code)
+ /**
+ * A Shortcut for return a DeniedResponse
+ */
+ protected fun denied(message: String, code: String): DeniedResponse = DeniedResponse(this, message, code)
+
+ /**
+ * Check all responses and return DENIED if one is DENIED
+ *
+ * If the list of responses is empty, return GRANTED
+ */
+ private fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: granted()
+
+ /**
+ * An helper to convert a list of subject into one response
+ */
+ protected fun , T> canAll(items: S, action: (T) -> AccessResponse): AccessResponse = items
+ .map { action(it) }
+ .getOneResponse()
+}
+
+/**
+ * Throw an Exception if AccessControl return a DENIED response
+ */
+fun T.assert(action: T.() -> AccessResponse) {
+ action().assert()
+}
+
+/**
+ * Check all responses and return DENIED if one is DENIED
+ *
+ * If the list of responses is empty, return GRANTED
+ */
+fun AccessResponses.getOneResponse(): AccessResponse = this.firstOrNull { it.decision == AccessDecision.DENIED } ?: GrantedResponse(first().accessControl)
+
+/**
+ * Throw an Exception if one response is DENIED
+ */
+fun AccessResponses.assert() = this.getOneResponse().assert()
+
+class AccessDeniedException(private val accessResponses: AccessResponses) : Throwable(accessResponses.first().message) {
+ constructor(accessResponse: AccessResponse) : this(listOf(accessResponse))
+
+ /**
+ * Get first response
+ */
+ fun first(): AccessResponse = accessResponses.first()
+
+ /**
+ * Check if the error code is present into the responses
+ */
+ fun hasErrorCode(code: String): Boolean = accessResponses
+ .filter { it.decision == AccessDecision.DENIED }
+ .any { it.code == code }
+
+ /**
+ * Find and return the response than match with the error code
+ */
+ fun getErrorCode(code: String): AccessResponse? = accessResponses
+ .firstOrNull { it.decision == AccessDecision.DENIED && it.code == code }
+
+ /**
+ * Get a list of messages of all responses
+ */
+ fun getMessages(): List = accessResponses
+ .mapNotNull { it.message }
+
+ /**
+ * Get the first message
+ */
+ fun getFirstMessage(): String? = accessResponses
+ .first()
+ .message
+}
+
+/**
+ * The response that all AccessControl method return
+ * @see GrantedResponse
+ * @see DeniedResponse
+ */
+sealed class AccessResponse(
+ val decision: AccessDecision,
+ val accessControl: AccessControl,
+ val message: String?,
+ val code: String?
+) {
+ /**
+ * Convert response as boolean
+ */
+ fun toBoolean(): Boolean = decision.toBoolean()
+
+ /**
+ * Throw Exception if response if DENIED
+ */
+ fun assert() {
+ if (this.decision == AccessDecision.DENIED) {
+ throw AccessDeniedException(this)
+ }
+ }
+}
+
+class GrantedResponse(
+ accessControl: AccessControl,
+ message: String? = null,
+ code: String? = null
+) : AccessResponse(AccessDecision.GRANTED, accessControl, message, code)
+
+class DeniedResponse(
+ accessControl: AccessControl,
+ message: String,
+ code: String
+) : AccessResponse(AccessDecision.DENIED, accessControl, message, code)
+
+typealias AccessResponses = List
diff --git a/src/main/kotlin/fr/dcproject/common/utils/DateTime.kt b/src/main/kotlin/fr/dcproject/common/utils/DateTime.kt
new file mode 100644
index 0000000..74ee9d2
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/utils/DateTime.kt
@@ -0,0 +1,6 @@
+package fr.dcproject.common.utils
+
+import org.joda.time.DateTime
+import org.joda.time.format.ISODateTimeFormat
+
+fun DateTime.toIso(): String = ISODateTimeFormat.dateTime().print(this)
diff --git a/src/main/kotlin/utils/Elastic.kt b/src/main/kotlin/fr/dcproject/common/utils/Elastic.kt
similarity index 95%
rename from src/main/kotlin/utils/Elastic.kt
rename to src/main/kotlin/fr/dcproject/common/utils/Elastic.kt
index 3962a2c..b5f98b7 100644
--- a/src/main/kotlin/utils/Elastic.kt
+++ b/src/main/kotlin/fr/dcproject/common/utils/Elastic.kt
@@ -1,4 +1,4 @@
-package fr.dcproject.utils
+package fr.dcproject.common.utils
import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException
@@ -26,4 +26,4 @@ fun String.getJsonField(jsonPath: String): Int? {
.warn("No value for Json path ${JsonPath.compile(jsonPath).path}")
null
}
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/utils/LoggerDelegate.kt b/src/main/kotlin/fr/dcproject/common/utils/LoggerDelegate.kt
similarity index 55%
rename from src/main/kotlin/utils/LoggerDelegate.kt
rename to src/main/kotlin/fr/dcproject/common/utils/LoggerDelegate.kt
index 6475e10..d6392ee 100644
--- a/src/main/kotlin/utils/LoggerDelegate.kt
+++ b/src/main/kotlin/fr/dcproject/common/utils/LoggerDelegate.kt
@@ -1,4 +1,4 @@
-package fr.dcproject.utils
+package fr.dcproject.common.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -6,5 +6,5 @@ import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
internal class LoggerDelegate : ReadOnlyProperty {
- override fun getValue(thisRef: R, property: KProperty<*>) = LoggerFactory.getLogger(thisRef.javaClass.packageName)
-}
\ No newline at end of file
+ override fun getValue(thisRef: R, property: KProperty<*>): Logger = LoggerFactory.getLogger(thisRef.javaClass.packageName)
+}
diff --git a/src/main/kotlin/fr/dcproject/common/utils/Request.kt b/src/main/kotlin/fr/dcproject/common/utils/Request.kt
new file mode 100644
index 0000000..39d8894
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/utils/Request.kt
@@ -0,0 +1,27 @@
+package fr.dcproject.common.utils
+
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
+import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
+import io.ktor.application.ApplicationCall
+import io.ktor.application.log
+import io.ktor.features.BadRequestException
+import io.ktor.request.receive
+import kotlin.reflect.typeOf
+
+/**
+ * Receives content for this request.
+ * @param type instance of `KClass` specifying type to be received.
+ * @return instance of [T] received from this call, or `null` if content cannot be transformed to the requested type..
+ */
+@OptIn(ExperimentalStdlibApi::class)
+public suspend inline fun ApplicationCall.receiveOrBadRequest(message: String = "Bad Request, wrong body request"): T {
+ return try {
+ receive(typeOf())
+ } catch (cause: MissingKotlinParameterException) {
+ application.log.debug("Conversion failed, throw bad exception", cause)
+ throw BadRequestException(message, cause)
+ } catch (cause: UnrecognizedPropertyException) {
+ application.log.debug("Conversion failed, throw bad exception", cause)
+ throw BadRequestException(message, cause)
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/common/utils/Resources.kt b/src/main/kotlin/fr/dcproject/common/utils/Resources.kt
new file mode 100644
index 0000000..9d1e4df
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/utils/Resources.kt
@@ -0,0 +1,15 @@
+package fr.dcproject.common.utils
+
+import java.net.URL
+
+fun String.readResource(callback: (String) -> Unit = {}): String {
+ val content = callback::class.java.getResource(this)?.readText() ?: error("File not found")
+ callback(content)
+ return content
+}
+
+fun String.getResource(callback: (URL) -> Unit = {}): URL {
+ val content = callback::class.java.getResource(this) ?: error("File not found")
+ callback(content)
+ return content
+}
diff --git a/src/main/kotlin/utils/Uuid.kt b/src/main/kotlin/fr/dcproject/common/utils/Uuid.kt
similarity index 67%
rename from src/main/kotlin/utils/Uuid.kt
rename to src/main/kotlin/fr/dcproject/common/utils/Uuid.kt
index 2824ed1..a820612 100644
--- a/src/main/kotlin/utils/Uuid.kt
+++ b/src/main/kotlin/fr/dcproject/common/utils/Uuid.kt
@@ -1,6 +1,6 @@
-package fr.dcproject.utils
+package fr.dcproject.common.utils
-import java.util.*
+import java.util.UUID
fun String.toUUID(): UUID = UUID.fromString(this.trim())
@@ -8,4 +8,4 @@ fun List.toUUID(): List = this
.filterNotNull()
.map { it.trim() }
.filter { it.isNotBlank() }
- .map { UUID.fromString(it) }
\ No newline at end of file
+ .map { UUID.fromString(it) }
diff --git a/src/main/kotlin/fr/dcproject/common/utils/waitElastic.kt b/src/main/kotlin/fr/dcproject/common/utils/waitElastic.kt
new file mode 100644
index 0000000..5419797
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/common/utils/waitElastic.kt
@@ -0,0 +1,28 @@
+package fr.dcproject.common.utils
+
+import org.elasticsearch.client.Request
+import org.elasticsearch.client.RestClient
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+fun RestClient.waitElasticsearchIsUp() {
+ val logger: Logger = LoggerFactory.getLogger("fr.dcproject.elasticsearch")
+ val request = Request("GET", "/_cluster/health")
+ repeat(5 * 60 / 2) { // 5 minutes
+ runCatching {
+ performRequest(request).statusLine.statusCode
+ }.onSuccess {
+ if (it == 200) {
+ logger.debug("Elasticsearch is Ready! Continue...")
+ return
+ } else {
+ logger.debug("sleep 2s and retry...")
+ Thread.sleep(2000)
+ }
+ }.onFailure {
+ logger.debug("${it.message}, sleep 2s and retry...")
+ Thread.sleep(2000)
+ }
+ }
+ error("Elasticsearch is not ready")
+}
diff --git a/src/main/kotlin/fr/dcproject/component/article/ArticleAccessControl.kt b/src/main/kotlin/fr/dcproject/component/article/ArticleAccessControl.kt
new file mode 100644
index 0000000..90604a0
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/ArticleAccessControl.kt
@@ -0,0 +1,52 @@
+package fr.dcproject.component.article
+
+import fr.dcproject.common.entity.CreatedBy
+import fr.dcproject.common.entity.VersionableId
+import fr.dcproject.common.security.AccessControl
+import fr.dcproject.common.security.AccessResponse
+import fr.dcproject.component.article.database.ArticleAuthI
+import fr.dcproject.component.article.database.ArticleI
+import fr.dcproject.component.article.database.ArticleRepository
+import fr.dcproject.component.citizen.database.CitizenI
+
+class ArticleAccessControl(private val articleRepo: ArticleRepository) : AccessControl() {
+ fun > canView(subjects: List, citizen: CitizenI?): AccessResponse =
+ canAll(subjects) { canView(it, citizen) }
+
+ fun > canView(subject: S, citizen: CitizenI?): AccessResponse {
+ return if (subject.isDeleted()) denied("Article is deleted", "article.deleted")
+ else if (subject.draft && (citizen == null || subject.createdBy.id != citizen.id)) denied("Article is draft, but it's not yours", "article.draft.not.yours")
+ else granted()
+ }
+
+ fun > canDelete(subject: S, citizen: CitizenI?): AccessResponse {
+ if (citizen == null) return denied("You must be connected to create article", "article.create.notConnected")
+ return if (subject.createdBy.id == citizen.id) {
+ granted()
+ } else {
+ denied("Cannot delete article if is not yours", "article.delete.notYours")
+ }
+ }
+
+ fun canUpsert(subject: S, citizen: CitizenI?): AccessResponse
+ where S : ArticleI,
+ S : CreatedBy<*>,
+ S : VersionableId {
+ if (citizen == null) return denied("You must be connected to create article", "article.create.notConnected")
+ /* The new Article must by created by the same citizen of the connected citizen */
+ if (subject.createdBy.id == citizen.id) {
+ /* The creator must be the same of the creator of preview version of article */
+ val lastVersionId = articleRepo
+ .findVersionsByVersionId(1, 1, subject.versionId)
+ .result
+ .firstOrNull()?.createdBy?.id
+
+ return when (lastVersionId) {
+ null -> granted("You can create a new Article")
+ citizen.id -> granted("Last version is yours")
+ else -> denied("Last version is not yours", "article.lastVersion.notYours")
+ }
+ }
+ return denied("This article must be yours for update it", "article.update.notYours")
+ }
+}
diff --git a/src/main/kotlin/views/ArticleViewManager.kt b/src/main/kotlin/fr/dcproject/component/article/ArticleViewManager.kt
similarity index 59%
rename from src/main/kotlin/views/ArticleViewManager.kt
rename to src/main/kotlin/fr/dcproject/component/article/ArticleViewManager.kt
index 6de186e..7150216 100644
--- a/src/main/kotlin/views/ArticleViewManager.kt
+++ b/src/main/kotlin/fr/dcproject/component/article/ArticleViewManager.kt
@@ -1,17 +1,27 @@
-package fr.dcproject.views
+package fr.dcproject.component.article
-import fr.dcproject.entity.*
-import fr.dcproject.utils.contentToString
-import fr.dcproject.utils.getJsonField
-import fr.dcproject.utils.toIso
+import fr.dcproject.common.entity.VersionableId
+import fr.dcproject.common.utils.contentToString
+import fr.dcproject.common.utils.getJsonField
+import fr.dcproject.common.utils.toIso
+import fr.dcproject.component.article.database.ArticleI
+import fr.dcproject.component.citizen.database.CitizenI
+import fr.dcproject.component.views.ViewManager
+import fr.dcproject.component.views.entity.ViewAggregation
import org.elasticsearch.client.Request
import org.elasticsearch.client.Response
import org.elasticsearch.client.RestClient
import org.joda.time.DateTime
-import java.util.*
+import java.util.UUID
-class ArticleViewManager(private val restClient: RestClient) : ViewManager {
- override fun addView(ip: String, article: ArticleRefVersioning, citizen: CitizenRef?, dateTime: DateTime): Response? {
+/**
+ * Wrapper for manage views with elasticsearch
+ */
+class ArticleViewManager (private val restClient: RestClient) : ViewManager where A : VersionableId, A : ArticleI {
+ /**
+ * Add view on article to elasticsearch
+ */
+ override fun addView(ip: String, entity: A, citizen: CitizenI?, dateTime: DateTime): Response? {
val isLogged = (citizen != null).toString()
val ref = citizen?.id ?: UUID.nameUUIDFromBytes(ip.toByteArray())!!
val request = Request(
@@ -19,37 +29,43 @@ class ArticleViewManager(private val restClient: RestClient) : ViewManager = emptyList(),
+ override val createdBy: CitizenCreator,
+ override val versionNumber: Int = 0,
+ override val versionId: UUID = UUID.randomUUID(),
+ val workgroup: WorkgroupCart? = null,
+ override val opinions: Opinions = emptyMap(),
+ override val draft: Boolean = false,
+ override val deletedAt: DateTime? = null
+) : ArticleRef(id),
+ ArticleAuthI,
+ ArticleWithTitleI,
+ Versionable,
+ CreatedAt by CreatedAt.Imp(),
+ DeletedAt by DeletedAt.Imp(deletedAt),
+ VersionableId,
+ Opinionable,
+ Votable by VotableImp() {
+ val lastVersion: Boolean = false
+}
+
+interface ArticleForUpdateI : ArticleI, ArticleWithTitleI, VersionableId, TargetI, CreatedBy {
+ val anonymous: Boolean
+ val content: String
+ val description: String
+ val draft: Boolean
+ val workgroup: WorkgroupRef?
+}
+
+class ArticleForUpdate(
+ override val id: UUID = UUID.randomUUID(),
+ override val title: String,
+ override val anonymous: Boolean = true,
+ override val content: String,
+ override val description: String,
+ tags: List = emptyList(),
+ override val draft: Boolean = false,
+ override val createdBy: CitizenRef,
+ override val workgroup: WorkgroupRef? = null,
+ override val versionId: UUID = UUID.randomUUID(),
+ override val deletedAt: DateTime? = null,
+) : ArticleRef(id),
+ ArticleForUpdateI,
+ ArticleAuthI,
+ VersionableId {
+ val tags: List = tags.distinct()
+}
+
+class ArticleForListing(
+ id: UUID? = null,
+ override val title: String,
+ override val createdBy: CitizenCreator,
+ override val workgroup: WorkgroupCart? = null,
+ override val deletedAt: DateTime? = null,
+ override val draft: Boolean = false,
+ val lastVersion: Boolean = false
+) : ArticleForListingI,
+ ArticleRef(id),
+ ArticleAuthI,
+ Votable by VotableImp(),
+ CreatedBy
+
+interface ArticleForListingI : ArticleWithTitleI, CreatedBy {
+ val workgroup: WorkgroupCartI?
+}
+
+open class ArticleRef(
+ id: UUID? = null
+) : ArticleI, TargetRef(id)
+
+interface ArticleI : EntityI, TargetI
+
+interface ArticleWithTitleI : ArticleI {
+ val title: String
+}
+
+interface ArticleAuthI :
+ ArticleI,
+ CreatedBy,
+ DeletedAt {
+ val draft: Boolean
+}
diff --git a/src/main/kotlin/repository/Article.kt b/src/main/kotlin/fr/dcproject/component/article/database/ArticleRepository.kt
similarity index 61%
rename from src/main/kotlin/repository/Article.kt
rename to src/main/kotlin/fr/dcproject/component/article/database/ArticleRepository.kt
index 26dfdab..37d54eb 100644
--- a/src/main/kotlin/repository/Article.kt
+++ b/src/main/kotlin/fr/dcproject/component/article/database/ArticleRepository.kt
@@ -1,23 +1,25 @@
-package fr.dcproject.repository
+package fr.dcproject.component.article.database
-import fr.dcproject.entity.ArticleForUpdate
-import fr.dcproject.entity.ArticleSimple
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.Parameter
import fr.postgresjson.repository.RepositoryI
-import fr.postgresjson.repository.RepositoryI.Direction
import net.pearx.kasechange.toSnakeCase
-import java.util.*
-import fr.dcproject.entity.Article as ArticleEntity
+import java.util.UUID
-class Article(override var requester: Requester) : RepositoryI {
- fun findById(id: UUID): ArticleEntity? {
+class ArticleRepository(override var requester: Requester) : RepositoryI {
+ fun findById(id: UUID): ArticleForView? {
val function = requester.getFunction("find_article_by_id")
return function.selectOne("id" to id)
}
- fun findVerionsByVersionsId(page: Int = 1, limit: Int = 50, versionId: UUID): Paginated {
+ fun findVersionsById(page: Int = 1, limit: Int = 50, id: UUID): Paginated {
+ return requester
+ .getFunction("find_articles_versions_by_id")
+ .select(page, limit, "id" to id)
+ }
+
+ fun findVersionsByVersionId(page: Int = 1, limit: Int = 50, versionId: UUID): Paginated {
return requester
.getFunction("find_articles_versions_by_version_id")
.select(page, limit, "version_id" to versionId)
@@ -27,14 +29,15 @@ class Article(override var requester: Requester) : RepositoryI {
page: Int = 1,
limit: Int = 50,
sort: String? = null,
- direction: Direction? = null,
+ direction: RepositoryI.Direction? = null,
search: String? = null,
filter: Filter = Filter()
- ): Paginated {
+ ): Paginated {
return requester
.getFunction("find_articles")
.select(
- page, limit,
+ page,
+ limit,
"sort" to sort?.toSnakeCase(),
"direction" to direction,
"search" to search,
@@ -42,7 +45,7 @@ class Article(override var requester: Requester) : RepositoryI {
)
}
- fun upsert(article: ArticleForUpdate): ArticleEntity? {
+ fun upsert(article: ArticleForUpdate): ArticleForView? {
return requester
.getFunction("upsert_article")
.selectOne("resource" to article)
diff --git a/src/main/kotlin/fr/dcproject/component/article/routes/FindArticleVersions.kt b/src/main/kotlin/fr/dcproject/component/article/routes/FindArticleVersions.kt
new file mode 100644
index 0000000..d9deb0d
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/routes/FindArticleVersions.kt
@@ -0,0 +1,72 @@
+package fr.dcproject.component.article.routes
+
+import fr.dcproject.common.response.toOutput
+import fr.dcproject.common.security.assert
+import fr.dcproject.component.article.ArticleAccessControl
+import fr.dcproject.component.article.database.ArticleForListing
+import fr.dcproject.component.article.database.ArticleRef
+import fr.dcproject.component.article.database.ArticleRepository
+import fr.dcproject.component.auth.citizenOrNull
+import fr.postgresjson.repository.RepositoryI
+import io.ktor.application.call
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.get
+import io.ktor.response.respond
+import io.ktor.routing.Route
+import java.util.UUID
+
+@KtorExperimentalLocationsAPI
+object FindArticleVersions {
+ @Location("/articles/{article}/versions")
+ class ArticleVersionsRequest(
+ article: UUID,
+ page: Int = 1,
+ limit: Int = 50,
+ val sort: String? = null,
+ val direction: RepositoryI.Direction? = null,
+ val search: String? = null
+ ) {
+ val page: Int = if (page < 1) 1 else page
+ val limit: Int = if (limit > 50) 50 else if (limit < 1) 1 else limit
+ val article = ArticleRef(article)
+ }
+
+ private fun ArticleRepository.findVersions(request: ArticleVersionsRequest) =
+ findVersionsById(request.page, request.limit, request.article.id)
+
+ fun Route.findArticleVersions(repo: ArticleRepository, ac: ArticleAccessControl) {
+ get {
+ repo.findVersions(it)
+ .apply { ac.assert { canView(result, citizenOrNull) } }
+ .run {
+ call.respond(
+ toOutput { a: ArticleForListing ->
+ object {
+ val id = a.id
+ val title = a.title
+ val createdBy = object {
+ val id = a.createdBy.id
+ val name = a.createdBy.name.let { n ->
+ object {
+ val firstName = n.firstName
+ val lastName = n.lastName
+ }
+ }
+ val email = a.createdBy.email
+ }
+ val workgroup = a.workgroup?.let { w ->
+ object {
+ val id = w.id
+ val name = w.name
+ }
+ }
+ val draft = a.draft
+ val lastVersion = a.lastVersion
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/article/routes/FindArticles.kt b/src/main/kotlin/fr/dcproject/component/article/routes/FindArticles.kt
new file mode 100644
index 0000000..b0b5abd
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/routes/FindArticles.kt
@@ -0,0 +1,68 @@
+package fr.dcproject.component.article.routes
+
+import fr.dcproject.common.response.toOutput
+import fr.dcproject.common.security.assert
+import fr.dcproject.component.article.ArticleAccessControl
+import fr.dcproject.component.article.database.ArticleForListing
+import fr.dcproject.component.article.database.ArticleRepository
+import fr.dcproject.component.auth.citizenOrNull
+import fr.dcproject.routes.PaginatedRequest
+import fr.dcproject.routes.PaginatedRequestI
+import fr.postgresjson.connexion.Paginated
+import fr.postgresjson.repository.RepositoryI
+import io.ktor.application.call
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.get
+import io.ktor.response.respond
+import io.ktor.routing.Route
+
+@KtorExperimentalLocationsAPI
+object FindArticles {
+ @Location("/articles")
+ class ArticlesRequest(
+ page: Int = 1,
+ limit: Int = 50,
+ val sort: String? = null,
+ val direction: RepositoryI.Direction? = null,
+ val search: String? = null,
+ val createdBy: String? = null,
+ val workgroup: String? = null
+ ) : PaginatedRequestI by PaginatedRequest(page, limit)
+
+ private fun ArticleRepository.findArticles(request: ArticlesRequest): Paginated {
+ return find(
+ request.page,
+ request.limit,
+ request.sort,
+ request.direction,
+ request.search,
+ ArticleRepository.Filter(createdById = request.createdBy, workgroupId = request.workgroup)
+ )
+ }
+
+ fun Route.findArticles(repo: ArticleRepository, ac: ArticleAccessControl) {
+ get {
+ repo.findArticles(it)
+ .apply { ac.assert { canView(result, citizenOrNull) } }
+ .let {
+ call.respond(
+ it.toOutput {
+ object {
+ val id = it.id
+ val title = it.title
+ val createdBy: Any = it.createdBy.toOutput()
+ val workgroup = it.workgroup?.let {
+ object {
+ val id = it.id
+ val name = it.name
+ }
+ }
+ val draft = it.draft
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/article/routes/GetOneArticle.kt b/src/main/kotlin/fr/dcproject/component/article/routes/GetOneArticle.kt
new file mode 100644
index 0000000..41b4e58
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/routes/GetOneArticle.kt
@@ -0,0 +1,83 @@
+package fr.dcproject.component.article.routes
+
+import fr.dcproject.common.security.assert
+import fr.dcproject.component.article.ArticleAccessControl
+import fr.dcproject.component.article.ArticleViewManager
+import fr.dcproject.component.article.database.ArticleForView
+import fr.dcproject.component.article.database.ArticleRef
+import fr.dcproject.component.article.database.ArticleRepository
+import fr.dcproject.component.auth.citizenOrNull
+import io.ktor.application.call
+import io.ktor.features.NotFoundException
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.get
+import io.ktor.response.respond
+import io.ktor.routing.Route
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+@KtorExperimentalLocationsAPI
+object GetOneArticle {
+ @Location("/articles/{article}")
+ class ArticleRequest(article: UUID) {
+ val article = ArticleRef(article)
+ }
+
+ fun Route.getOneArticle(viewManager: ArticleViewManager, ac: ArticleAccessControl, repo: ArticleRepository) {
+ get {
+ val article: ArticleForView = repo.findById(it.article.id) ?: throw NotFoundException("Article ${it.article.id} not found")
+ ac.assert { canView(article, citizenOrNull) }
+
+ call.respond(
+ article.let { a ->
+ object {
+ val id = a.id
+ val versionId = a.versionId
+ val versionNumber = a.versionNumber
+ val title = a.title
+ val anonymous = a.anonymous
+ val content = a.content
+ val description = a.description
+ val tags = a.tags
+ val draft = a.draft
+ val lastVersion = a.lastVersion
+ val createdAt = a.createdAt
+ val createdBy: Any = object {
+ val id: UUID = a.createdBy.id
+ val name: Any = object {
+ val firstName: String = a.createdBy.name.firstName
+ val lastName: String = a.createdBy.name.lastName
+ }
+ val email: String = a.createdBy.email
+ }
+ val workgroup: Any? = a.workgroup?.let { w ->
+ object {
+ val id: UUID = w.id
+ val name: String = w.name
+ }
+ }
+ val votes: Any = object {
+ val up: Int = a.votes.up
+ val neutral: Int = a.votes.neutral
+ val down: Int = a.votes.down
+ val total: Int = a.votes.total
+ val score: Int = a.votes.score
+ }
+ val views: Any = viewManager.getViewsCount(article).let { v ->
+ object {
+ val total = v.total
+ val unique = v.unique
+ }
+ }
+ val opinions: Map = a.opinions
+ }
+ }
+ )
+
+ launch {
+ viewManager.addView(call.request.local.remoteHost, article, citizenOrNull)
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/article/routes/UpsertArticle.kt b/src/main/kotlin/fr/dcproject/component/article/routes/UpsertArticle.kt
new file mode 100644
index 0000000..4d408ad
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/routes/UpsertArticle.kt
@@ -0,0 +1,71 @@
+package fr.dcproject.component.article.routes
+
+import fr.dcproject.common.security.assert
+import fr.dcproject.common.utils.receiveOrBadRequest
+import fr.dcproject.component.article.ArticleAccessControl
+import fr.dcproject.component.article.database.ArticleForUpdate
+import fr.dcproject.component.article.database.ArticleRepository
+import fr.dcproject.component.article.routes.UpsertArticle.UpsertArticleRequest.Input
+import fr.dcproject.component.auth.citizen
+import fr.dcproject.component.auth.citizenOrNull
+import fr.dcproject.component.notification.ArticleUpdateNotification
+import fr.dcproject.component.notification.Publisher
+import fr.dcproject.component.workgroup.database.WorkgroupRef
+import io.ktor.application.ApplicationCall
+import io.ktor.application.call
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.post
+import io.ktor.response.respond
+import io.ktor.routing.Route
+import java.util.UUID
+
+@KtorExperimentalLocationsAPI
+object UpsertArticle {
+ @Location("/articles")
+ class UpsertArticleRequest {
+ class Input(
+ val id: UUID?,
+ val title: String,
+ val anonymous: Boolean = true,
+ val content: String,
+ val description: String,
+ val tags: List = emptyList(),
+ val draft: Boolean = false,
+ val versionId: UUID,
+ val workgroup: WorkgroupRef? = null,
+ )
+ }
+
+ fun Route.upsertArticle(repo: ArticleRepository, publisher: Publisher, ac: ArticleAccessControl) {
+ suspend fun ApplicationCall.convertRequestToEntity(): ArticleForUpdate = receiveOrBadRequest ().run {
+ ArticleForUpdate(
+ id = id ?: UUID.randomUUID(),
+ title = title,
+ anonymous = anonymous,
+ content = content,
+ description = description,
+ tags = tags,
+ draft = draft,
+ createdBy = citizen,
+ workgroup = workgroup,
+ versionId = versionId
+ )
+ }
+
+ post {
+ val article = call.convertRequestToEntity()
+ ac.assert { canUpsert(article, citizenOrNull) }
+ repo.upsert(article)?.let { a ->
+ call.respond(
+ object {
+ val id: UUID = a.id
+ val versionId = a.versionId
+ val versionNumber = a.versionNumber
+ }
+ )
+ publisher.publish(ArticleUpdateNotification(a))
+ } ?: error("Article not updated")
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/article/routes/install.kt b/src/main/kotlin/fr/dcproject/component/article/routes/install.kt
new file mode 100644
index 0000000..89943bf
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/article/routes/install.kt
@@ -0,0 +1,20 @@
+package fr.dcproject.component.article.routes
+
+import fr.dcproject.component.article.routes.FindArticleVersions.findArticleVersions
+import fr.dcproject.component.article.routes.FindArticles.findArticles
+import fr.dcproject.component.article.routes.GetOneArticle.getOneArticle
+import fr.dcproject.component.article.routes.UpsertArticle.upsertArticle
+import io.ktor.auth.authenticate
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.routing.Routing
+import org.koin.ktor.ext.get
+
+@KtorExperimentalLocationsAPI
+fun Routing.installArticleRoutes() {
+ authenticate(optional = true) {
+ findArticles(get(), get())
+ findArticleVersions(get(), get())
+ getOneArticle(get(), get(), get())
+ upsertArticle(get(), get(), get())
+ }
+}
diff --git a/src/main/kotlin/ApplicationContext.kt b/src/main/kotlin/fr/dcproject/component/auth/CitizenContext.kt
similarity index 73%
rename from src/main/kotlin/ApplicationContext.kt
rename to src/main/kotlin/fr/dcproject/component/auth/CitizenContext.kt
index f57ca38..466998a 100644
--- a/src/main/kotlin/ApplicationContext.kt
+++ b/src/main/kotlin/fr/dcproject/component/auth/CitizenContext.kt
@@ -1,21 +1,22 @@
-package fr.dcproject
+package fr.dcproject.component.auth
-import fr.dcproject.entity.User
-import fr.dcproject.entity.UserI
-import fr.ktorVoter.ForbiddenException
+import fr.dcproject.component.auth.database.User
+import fr.dcproject.component.auth.database.UserI
+import fr.dcproject.component.citizen.database.CitizenRepository
import io.ktor.application.ApplicationCall
import io.ktor.auth.authentication
import io.ktor.util.AttributeKey
import io.ktor.util.pipeline.PipelineContext
import org.koin.core.context.GlobalContext
-import fr.dcproject.entity.Citizen as CitizenEntity
-import fr.dcproject.repository.Citizen as CitizenRepository
+import fr.dcproject.component.citizen.database.Citizen as CitizenEntity
+
+class ForbiddenException(message: String) : Exception(message)
private val citizenAttributeKey = AttributeKey("CitizenContext")
val ApplicationCall.citizen: CitizenEntity
get() = attributes.computeIfAbsent(citizenAttributeKey) {
- val user = authentication.principal() ?: throw ForbiddenException()
+ val user = authentication.principal() ?: throw ForbiddenException("No User Connected")
GlobalContext.get().koin.get().findByUser(user)
?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt b/src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
new file mode 100644
index 0000000..ca05e85
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/KoinModule.kt
@@ -0,0 +1,15 @@
+package fr.dcproject.component.auth
+
+import fr.dcproject.application.Configuration
+import fr.dcproject.common.email.Mailer
+import fr.dcproject.component.auth.database.UserRepository
+import org.koin.dsl.module
+
+val authKoinModule = module {
+ single { UserRepository(get()) }
+ // Used to send a connexion link by email
+ single {
+ val config: Configuration = get()
+ PasswordlessAuth(get(), config.domain, get())
+ }
+}
diff --git a/src/main/kotlin/messages/SsoManager.kt b/src/main/kotlin/fr/dcproject/component/auth/PasswordlessAuth.kt
similarity index 56%
rename from src/main/kotlin/messages/SsoManager.kt
rename to src/main/kotlin/fr/dcproject/component/auth/PasswordlessAuth.kt
index 109b23f..f75b594 100644
--- a/src/main/kotlin/messages/SsoManager.kt
+++ b/src/main/kotlin/fr/dcproject/component/auth/PasswordlessAuth.kt
@@ -1,14 +1,19 @@
-package fr.dcproject.messages
+package fr.dcproject.component.auth
import com.sendgrid.helpers.mail.Mail
import com.sendgrid.helpers.mail.objects.Content
import com.sendgrid.helpers.mail.objects.Email
-import fr.dcproject.JwtConfig
-import fr.dcproject.entity.CitizenBasicI
+import fr.dcproject.common.email.Mailer
+import fr.dcproject.component.auth.jwt.makeToken
+import fr.dcproject.component.citizen.database.CitizenRepository
+import fr.dcproject.component.citizen.database.CitizenWithEmail
+import fr.dcproject.component.citizen.database.CitizenWithUserI
import io.ktor.http.URLBuilder
-import fr.dcproject.repository.Citizen as CitizenRepository
-class SsoManager(
+/**
+ * Send a connexion link by email
+ */
+class PasswordlessAuth(
private val mailer: Mailer,
private val domain: String,
private val citizenRepo: CitizenRepository
@@ -18,28 +23,29 @@ class SsoManager(
sendEmail(citizen, url)
}
- fun sendEmail(citizen: CitizenBasicI, url: String) {
+ fun sendEmail(citizen: C, url: String) where C : CitizenWithEmail, C : CitizenWithUserI {
mailer.sendEmail {
+ val token = citizen.user.makeToken()
Mail(
- Email("sso@$domain"),
+ Email("passwordless-auth@$domain"),
"Connection",
Email(citizen.email),
- Content("text/plain", generateContent(citizen, url))
+ Content("text/plain", generateContent(token, url))
).apply {
- addContent(Content("text/html", generateHtmlContent(citizen, url)))
+ addContent(Content("text/html", generateHtmlContent(token, url)))
}
}
}
- private fun generateHtmlContent(citizen: CitizenBasicI, url: String): String? {
+ private fun generateHtmlContent(token: String, url: String): String? {
val urlObject = URLBuilder(url)
- urlObject.parameters.append("token", JwtConfig.makeToken(citizen.user))
+ urlObject.parameters.append("token", token)
return "Click here for connect to $domain"
}
- private fun generateContent(citizen: CitizenBasicI, url: String): String {
+ private fun generateContent(token: String, url: String): String {
val urlObject = URLBuilder(url)
- urlObject.parameters.append("token", JwtConfig.makeToken(citizen.user))
+ urlObject.parameters.append("token", token)
return "Copy this link into your browser for connect to $domain: \n${urlObject.buildString()}"
}
@@ -48,4 +54,4 @@ class SsoManager(
}
private fun noEmail(email: String): Nothing = throw EmailNotFound(email)
-}
\ No newline at end of file
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/database/User.kt b/src/main/kotlin/fr/dcproject/component/auth/database/User.kt
new file mode 100644
index 0000000..fe43d14
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/database/User.kt
@@ -0,0 +1,61 @@
+package fr.dcproject.component.auth.database
+
+import fr.dcproject.common.entity.CreatedAt
+import fr.dcproject.common.entity.Entity
+import fr.dcproject.common.entity.EntityI
+import fr.dcproject.common.entity.UpdatedAt
+import fr.dcproject.component.auth.database.UserI.Roles
+import io.ktor.auth.Principal
+import org.joda.time.DateTime
+import java.util.UUID
+
+class UserForCreate(
+ id: UUID = UUID.randomUUID(),
+ username: String,
+ override val password: String,
+ blockedAt: DateTime? = null,
+ roles: List = emptyList()
+) : User(id, username, blockedAt, roles),
+ UserWithPasswordI
+
+open class User(
+ id: UUID = UUID.randomUUID(),
+ override var username: String,
+ var blockedAt: DateTime? = null,
+ var roles: List = emptyList()
+) : UserRef(id),
+ UserWithUsername,
+ CreatedAt by CreatedAt.Imp(),
+ UpdatedAt by UpdatedAt.Imp()
+
+class UserCreator(
+ id: UUID = UUID.randomUUID(),
+ override val username: String,
+) : UserRef(id), UserWithUsername
+
+interface UserWithUsername : UserI {
+ val username: String
+}
+
+interface UserWithPasswordI : UserI {
+ val password: String
+}
+
+class UserWithPassword(
+ id: UUID,
+ override val password: String,
+) : UserWithPasswordI,
+ UserRef(id)
+
+open class UserRef(
+ id: UUID = UUID.randomUUID()
+) : UserI, Entity(id)
+
+interface UserI : EntityI, Principal {
+ enum class Roles { ROLE_USER, ROLE_ADMIN }
+}
+
+interface UserForAuthI : UserI {
+ var roles: List
+ var blockedAt: DateTime?
+}
diff --git a/src/main/kotlin/repository/User.kt b/src/main/kotlin/fr/dcproject/component/auth/database/UserRepository.kt
similarity index 70%
rename from src/main/kotlin/repository/User.kt
rename to src/main/kotlin/fr/dcproject/component/auth/database/UserRepository.kt
index b76129d..c09959b 100644
--- a/src/main/kotlin/repository/User.kt
+++ b/src/main/kotlin/fr/dcproject/component/auth/database/UserRepository.kt
@@ -1,23 +1,21 @@
-package fr.dcproject.repository
+package fr.dcproject.component.auth.database
-import fr.dcproject.entity.UserFull
import fr.postgresjson.connexion.Requester
import fr.postgresjson.repository.RepositoryI
import io.ktor.auth.UserPasswordCredential
-import java.util.*
-import fr.dcproject.entity.User as UserEntity
+import java.util.UUID
-class User(override var requester: Requester) : RepositoryI {
- fun findByCredentials(credentials: UserPasswordCredential): UserEntity? {
+class UserRepository(override var requester: Requester) : RepositoryI {
+ fun findByCredentials(credentials: UserPasswordCredential): User? {
return requester
.getFunction("check_user")
.selectOne(
"username" to credentials.name,
- "plain_password" to credentials.password
+ "password" to credentials.password
)
}
- fun findById(id: UUID): UserEntity {
+ fun findById(id: UUID): User {
return requester
.getFunction("find_user_by_id")
.selectOne(
@@ -25,13 +23,13 @@ class User(override var requester: Requester) : RepositoryI {
) ?: throw UserNotFound(id)
}
- fun insert(user: UserEntity): UserEntity? {
+ fun insert(user: User): User? {
return requester
.getFunction("insert_user")
.selectOne("resource" to user)
}
- fun changePassword(user: UserFull) {
+ fun changePassword(user: UserWithPassword) {
requester
.getFunction("change_user_password")
.sendQuery("resource" to user)
diff --git a/src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt b/src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
new file mode 100644
index 0000000..349e621
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/jwt/JWTMaker.kt
@@ -0,0 +1,14 @@
+package fr.dcproject.component.auth.jwt
+
+import com.auth0.jwt.JWT
+import fr.dcproject.component.auth.database.UserI
+
+/**
+ * Produce a token for this combination of User and Account
+ */
+fun UserI.makeToken(): String = JWT.create()
+ .withSubject("Authentication")
+ .withIssuer(JwtConfig.issuer)
+ .withClaim("id", id.toString())
+ .withExpiresAt(JwtConfig.getExpiration())
+ .sign(JwtConfig.algorithm)
diff --git a/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt b/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
new file mode 100644
index 0000000..237cedc
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtConfig.kt
@@ -0,0 +1,25 @@
+package fr.dcproject.component.auth.jwt
+
+import com.auth0.jwt.JWT
+import com.auth0.jwt.JWTVerifier
+import com.auth0.jwt.algorithms.Algorithm
+import java.util.Date
+
+object JwtConfig {
+ private const val secret = "zAP5MBA4B4Ijz0MZaS48"
+ const val issuer = "dc-project.fr"
+ private const val validityInMs = 3_600_000 * 10 // 10 hours
+
+ // TODO change to RSA512
+ val algorithm: Algorithm = Algorithm.HMAC512(secret)
+
+ val verifier: JWTVerifier = JWT
+ .require(algorithm)
+ .withIssuer(issuer)
+ .build()
+
+ /**
+ * Calculate the expiration Date based on current time + the given validity
+ */
+ fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtInstallation.kt b/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtInstallation.kt
new file mode 100644
index 0000000..608138b
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/jwt/JwtInstallation.kt
@@ -0,0 +1,43 @@
+package fr.dcproject.component.auth.jwt
+
+import fr.dcproject.component.auth.database.User
+import fr.dcproject.component.auth.database.UserRepository
+import io.ktor.application.ApplicationCall
+import io.ktor.auth.Authentication
+import io.ktor.auth.jwt.jwt
+import io.ktor.http.auth.HttpAuthHeader
+import io.ktor.routing.Routing
+import java.util.UUID
+
+fun jwtInstallation(userRepo: UserRepository): Authentication.Configuration.() -> Unit = {
+ /**
+ * Setup the JWT authentication to be used in [Routing].
+ * If the token is valid, the corresponding [User] is fetched from the database.
+ * The [User] can then be accessed in each [ApplicationCall].
+ */
+ jwt {
+ verifier(JwtConfig.verifier)
+ realm = "dc-project.fr"
+ validate {
+ it.payload.getClaim("id").asString()?.let { id ->
+ userRepo.findById(UUID.fromString(id))
+ }
+ }
+ }
+
+ /* Token in URL */
+ jwt("url") {
+ verifier(JwtConfig.verifier)
+ realm = "dc-project.fr"
+ authHeader { call ->
+ call.request.queryParameters["token"]?.let {
+ HttpAuthHeader.Single("Bearer", it)
+ }
+ }
+ validate {
+ it.payload.getClaim("id").asString()?.let { id ->
+ userRepo.findById(UUID.fromString(id))
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt b/src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
new file mode 100644
index 0000000..6f0b10d
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/routes/Login.kt
@@ -0,0 +1,53 @@
+package fr.dcproject.component.auth.routes
+
+import com.fasterxml.jackson.databind.exc.MismatchedInputException
+import fr.dcproject.common.utils.receiveOrBadRequest
+import fr.dcproject.component.auth.database.UserRepository
+import fr.dcproject.component.auth.jwt.makeToken
+import fr.dcproject.component.auth.routes.Login.LoginRequest.Input
+import io.ktor.application.call
+import io.ktor.auth.UserPasswordCredential
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.post
+import io.ktor.request.accept
+import io.ktor.response.respond
+import io.ktor.response.respondText
+import io.ktor.routing.Route
+
+@KtorExperimentalLocationsAPI
+object Login {
+ @Location("/login")
+ class LoginRequest {
+ data class Input(
+ val username: String,
+ val password: String,
+ )
+ }
+
+ fun Route.authLogin(userRepo: UserRepository) {
+ post {
+ try {
+ val credentials = call.receiveOrBadRequest ().run {
+ UserPasswordCredential(username, password)
+ }
+
+ userRepo.findByCredentials(credentials)?.makeToken()?.let { token ->
+ if (call.request.accept() == ContentType.Application.Json.toString()) {
+ call.respond(
+ object {
+ val token: String = token
+ }
+ )
+ } else {
+ call.respondText(token)
+ }
+ } ?: call.respond(HttpStatusCode.BadRequest, "Username not exist or password is wrong")
+ } catch (e: MismatchedInputException) {
+ call.respond(HttpStatusCode.BadRequest, "You must be send name and password to the request")
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/routes/Register.kt b/src/main/kotlin/fr/dcproject/component/auth/routes/Register.kt
new file mode 100644
index 0000000..08d1d67
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/routes/Register.kt
@@ -0,0 +1,82 @@
+package fr.dcproject.component.auth.routes
+
+import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
+import fr.dcproject.common.utils.receiveOrBadRequest
+import fr.dcproject.component.auth.database.UserForCreate
+import fr.dcproject.component.auth.database.UserI
+import fr.dcproject.component.auth.jwt.makeToken
+import fr.dcproject.component.auth.routes.Register.RegisterRequest.Input
+import fr.dcproject.component.citizen.database.CitizenForCreate
+import fr.dcproject.component.citizen.database.CitizenI
+import fr.dcproject.component.citizen.database.CitizenRepository
+import io.ktor.application.call
+import io.ktor.features.BadRequestException
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.post
+import io.ktor.request.accept
+import io.ktor.response.respond
+import io.ktor.response.respondText
+import io.ktor.routing.Route
+import org.joda.time.DateTime
+
+@KtorExperimentalLocationsAPI
+object Register {
+ @Location("/register")
+ class RegisterRequest {
+ data class Input(
+ val name: Name,
+ val email: String,
+ val birthday: DateTime,
+ val voteAnonymous: Boolean = true,
+ val followAnonymous: Boolean = true,
+ val user: User
+ ) {
+ data class Name(
+ val firstName: String,
+ val lastName: String,
+ val civility: String? = null
+ )
+ data class User(
+ val username: String,
+ val password: String
+ )
+ }
+ }
+
+ fun Route.authRegister(citizenRepo: CitizenRepository) {
+ fun Input.toCitizen(): CitizenForCreate = CitizenForCreate(
+ name = CitizenI.Name(name.firstName, name.lastName, name.civility),
+ birthday = birthday,
+ email = email,
+ followAnonymous = followAnonymous,
+ voteAnonymous = voteAnonymous,
+ user = UserForCreate(
+ username = user.username,
+ password = user.password,
+ roles = listOf(UserI.Roles.ROLE_USER)
+ )
+ )
+
+ post {
+ try {
+ val citizen = call.receiveOrBadRequest ().toCitizen()
+ citizenRepo.insertWithUser(citizen)?.user?.makeToken()?.let { token ->
+ if (call.request.accept() == ContentType.Application.Json.toString()) {
+ call.respond(
+ object {
+ val token: String = token
+ }
+ )
+ } else {
+ call.respondText(token)
+ }
+ } ?: throw BadRequestException("Bad request")
+ } catch (e: MissingKotlinParameterException) {
+ call.respond(HttpStatusCode.BadRequest)
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt b/src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
new file mode 100644
index 0000000..26b4155
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/routes/Sso.kt
@@ -0,0 +1,36 @@
+package fr.dcproject.component.auth.routes
+
+import fr.dcproject.common.utils.receiveOrBadRequest
+import fr.dcproject.component.auth.PasswordlessAuth
+import fr.dcproject.component.auth.routes.Sso.PasswordlessRequest.Input
+import io.ktor.application.call
+import io.ktor.http.HttpStatusCode
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.post
+import io.ktor.response.respond
+import io.ktor.routing.Route
+
+@KtorExperimentalLocationsAPI
+object Sso {
+ @Location("/auth/passwordless")
+ class PasswordlessRequest {
+ data class Input(val email: String, val url: String)
+ }
+
+ /**
+ * Send an email to the citizen with a link to automatically connect
+ */
+ fun Route.authPasswordless(passwordlessAuth: PasswordlessAuth) {
+ post {
+ call.receiveOrBadRequest ().run {
+ try {
+ passwordlessAuth.sendEmail(email, url)
+ } catch (e: PasswordlessAuth.EmailNotFound) {
+ call.respond(HttpStatusCode.NotFound)
+ }
+ call.respond(HttpStatusCode.NoContent)
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/auth/routes/install.kt b/src/main/kotlin/fr/dcproject/component/auth/routes/install.kt
new file mode 100644
index 0000000..f6ea265
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/auth/routes/install.kt
@@ -0,0 +1,18 @@
+package fr.dcproject.component.auth.routes
+
+import fr.dcproject.component.auth.routes.Login.authLogin
+import fr.dcproject.component.auth.routes.Register.authRegister
+import fr.dcproject.component.auth.routes.Sso.authPasswordless
+import io.ktor.auth.authenticate
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.routing.Routing
+import org.koin.ktor.ext.get
+
+@KtorExperimentalLocationsAPI
+fun Routing.installAuthRoutes() {
+ authenticate(optional = true) {
+ authLogin(get())
+ authRegister(get())
+ authPasswordless(get())
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/CitizenAccessControl.kt b/src/main/kotlin/fr/dcproject/component/citizen/CitizenAccessControl.kt
new file mode 100644
index 0000000..a6c4f62
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/CitizenAccessControl.kt
@@ -0,0 +1,27 @@
+package fr.dcproject.component.citizen
+
+import fr.dcproject.common.entity.DeletedAt
+import fr.dcproject.common.security.AccessControl
+import fr.dcproject.common.security.AccessResponse
+import fr.dcproject.component.citizen.database.CitizenI
+
+class CitizenAccessControl : AccessControl() {
+ fun canView(subjects: List, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : DeletedAt =
+ canAll(subjects) { canView(it, connectedCitizen) }
+
+ fun canView(subject: S, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : DeletedAt {
+ if (connectedCitizen == null) return denied("You must be connected to view citizen", "citizen.view.connected")
+ return if (subject.isDeleted()) denied("You cannot view a deleted citizen", "citizen.view.deleted")
+ else granted()
+ }
+
+ fun canUpdate(subject: S, connectedCitizen: CitizenI?): AccessResponse {
+ if (connectedCitizen == null) return denied("You must be connected to update Citizen", "citizen.update.notConnected")
+ return if (subject.id == connectedCitizen.id) granted() else denied("You can only update your citizen", "citizen.update.notYours")
+ }
+
+ fun canChangePassword(subject: S, connectedCitizen: CitizenI?): AccessResponse {
+ if (connectedCitizen == null) return denied("You must be connected to change your password", "citizen.changePassword.notConnected")
+ return if (subject.id == connectedCitizen.id) granted() else denied("You can only change your password", "citizen.password.notYours")
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/KoinModule.kt b/src/main/kotlin/fr/dcproject/component/citizen/KoinModule.kt
new file mode 100644
index 0000000..1443ef2
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/KoinModule.kt
@@ -0,0 +1,9 @@
+package fr.dcproject.component.citizen
+
+import fr.dcproject.component.citizen.database.CitizenRepository
+import org.koin.dsl.module
+
+val citizenKoinModule = module {
+ single { CitizenRepository(get()) }
+ single { CitizenAccessControl() }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/database/Citizen.kt b/src/main/kotlin/fr/dcproject/component/citizen/database/Citizen.kt
new file mode 100644
index 0000000..2b1d251
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/database/Citizen.kt
@@ -0,0 +1,122 @@
+package fr.dcproject.component.citizen.database
+
+import fr.dcproject.common.entity.CreatedAt
+import fr.dcproject.common.entity.DeletedAt
+import fr.dcproject.common.entity.Entity
+import fr.dcproject.common.entity.EntityI
+import fr.dcproject.component.auth.database.User
+import fr.dcproject.component.auth.database.UserCreator
+import fr.dcproject.component.auth.database.UserForCreate
+import fr.dcproject.component.auth.database.UserI
+import fr.dcproject.component.auth.database.UserRef
+import fr.dcproject.component.auth.database.UserWithUsername
+import fr.dcproject.component.citizen.database.CitizenI.Name
+import fr.dcproject.component.workgroup.database.WorkgroupRef
+import fr.postgresjson.entity.Serializable
+import org.joda.time.DateTime
+import java.util.UUID
+
+class CitizenForCreate(
+ val name: Name,
+ val email: String,
+ val birthday: DateTime,
+ val voteAnonymous: Boolean = true,
+ val followAnonymous: Boolean = true,
+ override val user: UserForCreate,
+ id: UUID = UUID.randomUUID(),
+) : CitizenI,
+ CitizenRefWithUser(id, user),
+ CreatedAt by CreatedAt.Imp()
+
+class Citizen(
+ override val id: UUID = UUID.randomUUID(),
+ override val name: Name,
+ override val email: String,
+ val birthday: DateTime,
+ override val voteAnonymous: Boolean = true,
+ override val followAnonymous: Boolean = true,
+ override val user: User,
+ deletedAt: DateTime? = null
+) : CitizenWithEmail,
+ CitizenCreatorI,
+ CitizenWithUserI,
+ CitizenRef(id),
+ CitizenCartI,
+ CreatedAt by CreatedAt.Imp(),
+ DeletedAt by DeletedAt.Imp(deletedAt) {
+ var workgroups: List = emptyList()
+
+ class WorkgroupAndRoles(
+ val roles: List,
+ val workgroup: WorkgroupRef
+ )
+}
+
+data class CitizenCreator(
+ override var id: UUID = UUID.randomUUID(),
+ override var name: Name,
+ override var email: String,
+ override var voteAnonymous: Boolean = true,
+ override var followAnonymous: Boolean = true,
+ override val user: UserCreator,
+ override val deletedAt: DateTime? = null
+) : CitizenCreatorI,
+ CitizenRefWithUser(id, user),
+ DeletedAt by DeletedAt.Imp(deletedAt)
+
+interface CitizenCreatorI : CitizenWithUserI, CitizenWithEmail, CitizenCartI, DeletedAt {
+ override val id: UUID
+ override val name: Name
+ override val email: String
+ val voteAnonymous: Boolean
+ val followAnonymous: Boolean
+ override val user: UserWithUsername
+ override val deletedAt: DateTime?
+}
+
+class CitizenCart(
+ id: UUID = UUID.randomUUID(),
+ override val name: Name,
+ override val user: UserRef,
+ override val deletedAt: DateTime? = null,
+) : CitizenRef(id),
+ CitizenCartI,
+ DeletedAt by DeletedAt.Imp(deletedAt)
+
+interface CitizenCartI : CitizenI, CitizenWithUserI {
+ val name: Name
+}
+
+open class CitizenRefWithUser(
+ id: UUID = UUID.randomUUID(),
+ override val user: UserRef
+) : CitizenWithUserI,
+ CitizenRef(id)
+
+open class CitizenRef(
+ id: UUID = UUID.randomUUID()
+) : Entity(id),
+ CitizenI
+
+interface CitizenI : EntityI {
+ data class Name(
+ override val firstName: String,
+ override val lastName: String,
+ override val civility: String? = null
+ ) : NameI
+
+ interface NameI : Serializable {
+ val firstName: String
+ val lastName: String
+ val civility: String?
+ fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim()
+ }
+}
+
+interface CitizenWithUserI : CitizenI {
+ val user: UserI
+}
+
+interface CitizenWithEmail : CitizenI {
+ val email: String
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/database/CitizenRepository.kt b/src/main/kotlin/fr/dcproject/component/citizen/database/CitizenRepository.kt
new file mode 100644
index 0000000..16046c0
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/database/CitizenRepository.kt
@@ -0,0 +1,55 @@
+package fr.dcproject.component.citizen.database
+
+import fr.dcproject.component.auth.database.UserI
+import fr.dcproject.component.citizen.database.CitizenI.Name
+import fr.postgresjson.connexion.Paginated
+import fr.postgresjson.connexion.Requester
+import fr.postgresjson.repository.RepositoryI
+import net.pearx.kasechange.toSnakeCase
+import java.util.UUID
+
+class CitizenRepository(override var requester: Requester) : RepositoryI {
+ fun findById(id: UUID): Citizen? = requester
+ .getFunction("find_citizen_by_id_with_user_and_workgroups")
+ .selectOne("id" to id)
+
+ fun findByUser(user: UserI): Citizen? = requester
+ .getFunction("find_citizen_by_user_id")
+ .selectOne("user_id" to user.id)
+
+ fun findByUsername(username: String): Citizen? = requester
+ .getFunction("find_citizen_by_username")
+ .selectOne("username" to username)
+
+ fun findByName(name: Name): Citizen? = requester
+ .getFunction("find_citizen_by_name")
+ .selectOne("name" to name)
+
+ fun findByEmail(email: String): Citizen? = requester
+ .getFunction("find_citizen_by_email")
+ .selectOne("email" to email)
+
+ fun find(
+ page: Int = 1,
+ limit: Int = 50,
+ sort: String? = null,
+ direction: RepositoryI.Direction? = null,
+ search: String? = null
+ ): Paginated = requester
+ .getFunction("find_citizens")
+ .select(
+ page,
+ limit,
+ "sort" to sort?.toSnakeCase(),
+ "direction" to direction,
+ "search" to search
+ )
+
+ fun upsert(citizen: Citizen): Citizen? = requester
+ .getFunction("upsert_citizen")
+ .selectOne("resource" to citizen)
+
+ fun insertWithUser(citizen: CitizenForCreate): Citizen? = requester
+ .getFunction("insert_citizen_with_user")
+ .selectOne("resource" to citizen)
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/routes/ChangeMyPassword.kt b/src/main/kotlin/fr/dcproject/component/citizen/routes/ChangeMyPassword.kt
new file mode 100644
index 0000000..1c3e302
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/routes/ChangeMyPassword.kt
@@ -0,0 +1,45 @@
+package fr.dcproject.component.citizen.routes
+
+import fr.dcproject.common.security.assert
+import fr.dcproject.common.utils.receiveOrBadRequest
+import fr.dcproject.component.auth.citizen
+import fr.dcproject.component.auth.citizenOrNull
+import fr.dcproject.component.auth.database.UserRepository
+import fr.dcproject.component.auth.database.UserWithPassword
+import fr.dcproject.component.citizen.CitizenAccessControl
+import fr.dcproject.component.citizen.database.CitizenRef
+import io.ktor.application.call
+import io.ktor.auth.UserPasswordCredential
+import io.ktor.features.BadRequestException
+import io.ktor.http.HttpStatusCode
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.put
+import io.ktor.response.respond
+import io.ktor.routing.Route
+import java.util.UUID
+
+@KtorExperimentalLocationsAPI
+object ChangeMyPassword {
+ @Location("/citizens/{citizen}/password/change")
+ class ChangePasswordCitizenRequest(citizen: UUID) {
+ val citizen = CitizenRef(citizen)
+ data class Input(val oldPassword: String, val newPassword: String)
+ }
+
+ fun Route.changeMyPassword(ac: CitizenAccessControl, userRepository: UserRepository) {
+ put {
+ ac.assert { canChangePassword(it.citizen, citizenOrNull) }
+ val content = call.receiveOrBadRequest()
+ userRepository.findByCredentials(UserPasswordCredential(citizen.user.username, content.oldPassword)) ?: throw BadRequestException("Bad Password")
+ userRepository.changePassword(
+ UserWithPassword(
+ citizen.user.id,
+ content.newPassword,
+ )
+ )
+
+ call.respond(HttpStatusCode.Created)
+ }
+ }
+}
diff --git a/src/main/kotlin/fr/dcproject/component/citizen/routes/FindCitizens.kt b/src/main/kotlin/fr/dcproject/component/citizen/routes/FindCitizens.kt
new file mode 100644
index 0000000..d3b1ef4
--- /dev/null
+++ b/src/main/kotlin/fr/dcproject/component/citizen/routes/FindCitizens.kt
@@ -0,0 +1,49 @@
+package fr.dcproject.component.citizen.routes
+
+import fr.dcproject.common.response.toOutput
+import fr.dcproject.common.security.assert
+import fr.dcproject.component.auth.citizenOrNull
+import fr.dcproject.component.citizen.CitizenAccessControl
+import fr.dcproject.component.citizen.database.CitizenCreator
+import fr.dcproject.component.citizen.database.CitizenRepository
+import fr.dcproject.routes.PaginatedRequest
+import fr.dcproject.routes.PaginatedRequestI
+import fr.postgresjson.repository.RepositoryI
+import io.ktor.application.call
+import io.ktor.locations.KtorExperimentalLocationsAPI
+import io.ktor.locations.Location
+import io.ktor.locations.get
+import io.ktor.response.respond
+import io.ktor.routing.Route
+import java.util.UUID
+
+@KtorExperimentalLocationsAPI
+object FindCitizens {
+ @Location("/citizens")
+ class CitizensRequest(
+ page: Int = 1,
+ limit: Int = 50,
+ val sort: String? = null,
+ val direction: RepositoryI.Direction? = null,
+ val search: String? = null
+ ) : PaginatedRequestI by PaginatedRequest(page, limit)
+
+ fun Route.findCitizen(ac: CitizenAccessControl, repo: CitizenRepository) {
+ get