Move all file in fr.dcproject.

This commit is contained in:
2021-02-11 01:37:29 +01:00
parent c85401aa86
commit 066b01e86f
148 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
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.features.maxAge
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<String>): Unit = EngineMain.main(args)
enum class Env { PROD, TEST, CUCUMBER }
@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<NotificationConsumer>().run {
start()
environment.monitor.subscribe(ApplicationStopped) {
close()
}
}
install(Authentication, jwtInstallation(get()))
install(AutoHeadResponse)
install(ContentNegotiation) {
jackson {
propertyNamingStrategy = PropertyNamingStrategies.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.Feature) {
// trace { application.log.trace(it.buildText()) }
installArticleRoutes()
installAuthRoutes()
installCitizenRoutes()
installCommentArticleRoutes()
installCommentRoutes()
installFollowArticleRoutes()
installFollowConstitutionRoutes()
installWorkgroupRoutes()
installOpinionRoutes()
installVoteRoutes()
installConstitutionRoutes()
installCommentConstitutionRoutes()
installNotificationsRoutes()
installDocRoutes()
}
install(StatusPages) {
// TODO move to postgresJson lib
exception<CompletionException> { e ->
val parent = e.cause?.cause
if (parent is GenericDatabaseException) {
call.respond(HttpStatusCode.BadRequest, parent.errorMessage.message!!)
} else {
throw e
}
}
exception<NotFoundException> { e ->
call.respond(HttpStatusCode.NotFound, e.message!!)
}
exception<AccessDeniedException> {
if (call.user == null) call.respond(HttpStatusCode.Unauthorized)
else call.respond(HttpStatusCode.Forbidden)
}
exception<ForbiddenException> {
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<Migrations>().run()
}
}

View File

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

View File

@@ -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 <reified T> DataConversion.Configuration.get(
qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null
): T = GlobalContext.get().koin.rootScope.get(qualifier, parameters)
@KtorExperimentalAPI
val converters: ConverterDeclaration = {
convert<UUID> {
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")
}
}
}
}

View File

@@ -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.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.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<RedisClient> {
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<ConnectionFactory> {
val config: Configuration = get()
ConnectionFactory().apply { setUri(config.rabbitmq) }
}
// JsonSerializer
single<ObjectMapper> {
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 {
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<Mailer>(), config.domain, get(), get())
}
}

View File

@@ -0,0 +1,12 @@
package fr.dcproject.common.dto
import fr.postgresjson.entity.EntityCreatedAt
import org.joda.time.DateTime
interface CreatedAt {
val createdAt: DateTime
class Imp(parent: EntityCreatedAt) : CreatedAt {
override val createdAt: DateTime = parent.createdAt
}
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.common.dto
import fr.postgresjson.entity.EntityVersioning
import java.util.UUID
interface Versionable {
val versionId: UUID
val versionNumber: Int
class Imp(parent: EntityVersioning<UUID, Int>) : Versionable {
override val versionNumber: Int = parent.versionNumber
override val versionId: UUID = parent.versionId
}
}

View File

@@ -0,0 +1,27 @@
package fr.dcproject.common.email
import com.sendgrid.Method
import com.sendgrid.Request
import com.sendgrid.SendGrid
import com.sendgrid.helpers.mail.Mail
import java.io.IOException
class Mailer(
private val key: String
) {
fun sendEmail(action: () -> Mail): Boolean {
val mail = action()
val sg = SendGrid(key)
val request = Request()
try {
request.method = Method.POST
request.endpoint = "mail/send"
request.body = mail.build()
val response = sg.api(request)
return response.statusCode == 202
} catch (ex: IOException) {
throw ex
}
}
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.common.entity
import fr.dcproject.component.citizen.CitizenI
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityI
/**
* TODO remove EntityCreatedBy<EntityI>
*/
interface CreatedBy<T : CitizenI> : EntityCreatedBy<EntityI> {
override val createdBy: T
}
class CreatedByImp<T : CitizenI>(override val createdBy: T) : CreatedBy<T>

View File

@@ -0,0 +1,8 @@
package fr.dcproject.common.entity
import fr.postgresjson.entity.EntityI
import java.util.UUID
interface EntityI : EntityI {
val id: UUID
}

View File

@@ -0,0 +1,66 @@
package fr.dcproject.common.entity
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.comment.generic.CommentRef
import fr.dcproject.component.constitution.ConstitutionRef
import fr.dcproject.component.opinion.entity.OpinionRef
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.UuidEntity
import fr.postgresjson.entity.UuidEntityI
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf
interface ExtraI<T : TargetI, C : CitizenI> :
UuidEntityI,
HasTarget<T>,
EntityCreatedAt,
EntityCreatedBy<C>
interface HasTarget<T : TargetI> {
val target: T
}
open class TargetRef(id: UUID? = null, reference: String = "") : TargetI, UuidEntity(id) {
final override val reference: String
get() = if (field != "") field else TargetI.getReference(this)
init {
this.reference = reference
}
}
interface TargetI : UuidEntityI {
enum class TargetName(val targetReference: String) {
Article("article"),
Constitution("constitution"),
Comment("comment"),
Opinion("opinion")
}
companion object {
fun <T : TargetI> getReference(t: KClass<T>): String {
return when {
t.isSubclassOf(ArticleRef::class) -> TargetName.Article.targetReference
t.isSubclassOf(ConstitutionRef::class) -> TargetName.Constitution.targetReference
t.isSubclassOf(CommentRef::class) -> TargetName.Comment.targetReference
t.isSubclassOf(OpinionRef::class) -> TargetName.Opinion.targetReference
else -> throw error("target not implemented: ${t.qualifiedName} \nImplement it or return 'reference' from SQL")
}
}
fun getReference(t: TargetI): String {
val ref = this.getReference(t::class)
return if (t is ExtraI<*, *>) {
"${ref}_on_${t.target.reference}"
} else {
ref
}
}
}
val reference: String
}

View File

@@ -0,0 +1,17 @@
package fr.dcproject.common.entity
import fr.postgresjson.entity.EntityVersioning
import java.util.UUID
interface VersionableRef {
val versionId: UUID
}
class VersionableRefImp(
override val versionId: UUID
) : VersionableRef
interface Versionable : VersionableRef, EntityVersioning<UUID, Int> {
override val versionId: UUID
override val versionNumber: Int
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.routes
interface PaginatedRequestI {
val page: Int
val limit: Int
}
open class PaginatedRequest(
page: Int = 1,
limit: Int = 50
) : 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
}

View File

@@ -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 <S : List<T>, T> canAll(items: S, action: (T) -> AccessResponse): AccessResponse = items
.map { action(it) }
.getOneResponse()
}
/**
* Throw an Exception if AccessControl return a DENIED response
*/
fun <T : AccessControl> 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<String> = 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<AccessResponse>

View File

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

View File

@@ -0,0 +1,29 @@
package fr.dcproject.common.utils
import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException
import org.apache.http.util.EntityUtils
import org.elasticsearch.client.Response
import org.slf4j.LoggerFactory
fun Response.contentToString(): String {
return EntityUtils.toString(this.entity)
}
fun Response.getField(jsonPath: String): Int? {
return try {
JsonPath.read(this.contentToString(), jsonPath)
} catch (e: PathNotFoundException) {
null
}
}
fun String.getJsonField(jsonPath: String): Int? {
return try {
JsonPath.read(this, jsonPath)
} catch (e: PathNotFoundException) {
LoggerFactory.getLogger("fr.dcproject.utils.getJsonField")
.warn("No value for Json path ${JsonPath.compile(jsonPath).path}")
null
}
}

View File

@@ -0,0 +1,10 @@
package fr.dcproject.common.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
internal class LoggerDelegate<in R : Any> : ReadOnlyProperty<R, Logger> {
override fun getValue(thisRef: R, property: KProperty<*>): Logger = LoggerFactory.getLogger(thisRef.javaClass.packageName)
}

View File

@@ -0,0 +1,23 @@
package fr.dcproject.common.utils
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 <reified T : Any> ApplicationCall.receiveOrBadRequest(message: String = "Bad Request, wrong body request"): T {
return try {
receive<T>(typeOf<T>())
} catch (cause: MissingKotlinParameterException) {
application.log.debug("Conversion failed, throw bad exeption", cause)
throw BadRequestException(message, cause)
}
}

View File

@@ -0,0 +1,7 @@
package fr.dcproject.common.utils
fun String.readResource(callback: (String) -> Unit = {}): String {
val content = callback::class.java.getResource(this).readText()
callback(content)
return content
}

View File

@@ -0,0 +1,11 @@
package fr.dcproject.common.utils
import java.util.UUID
fun String.toUUID(): UUID = UUID.fromString(this.trim())
fun List<String?>.toUUID(): List<UUID> = this
.filterNotNull()
.map { it.trim() }
.filter { it.isNotBlank() }
.map { UUID.fromString(it) }

View File

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

View File

@@ -0,0 +1,113 @@
package fr.dcproject.component.article
import fr.dcproject.common.entity.CreatedBy
import fr.dcproject.common.entity.TargetI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.common.entity.VersionableRef
import fr.dcproject.component.citizen.CitizenCart
import fr.dcproject.component.citizen.CitizenCartI
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.opinion.entity.Opinionable
import fr.dcproject.component.opinion.entity.Opinions
import fr.dcproject.component.vote.entity.Votable
import fr.dcproject.component.vote.entity.VotableImp
import fr.dcproject.component.workgroup.WorkgroupCart
import fr.dcproject.component.workgroup.WorkgroupCartI
import fr.dcproject.component.workgroup.WorkgroupRef
import fr.dcproject.component.workgroup.WorkgroupSimple
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityDeletedAt
import fr.postgresjson.entity.EntityDeletedAtImp
import fr.postgresjson.entity.EntityVersioning
import fr.postgresjson.entity.UuidEntityI
import org.joda.time.DateTime
import java.util.UUID
data class ArticleForView(
override val id: UUID = UUID.randomUUID(),
override val title: String,
val anonymous: Boolean = true,
val content: String,
val description: String,
val tags: List<String> = emptyList(),
override val createdBy: CitizenRef,
override val versionNumber: Int = 0,
override val versionId: UUID = UUID.randomUUID(),
val workgroup: WorkgroupSimple<CitizenRef>? = null,
override val opinions: Opinions = emptyMap(),
override val draft: Boolean = false,
override val deletedAt: DateTime? = null
) : ArticleRef(id),
ArticleAuthI<CitizenRef>,
ArticleWithTitleI,
EntityVersioning<UUID, Int>,
EntityCreatedAt by EntityCreatedAtImp(),
EntityDeletedAt by EntityDeletedAtImp(deletedAt),
VersionableRef,
Opinionable,
Votable by VotableImp() {
val lastVersion: Boolean = false
}
interface ArticleForUpdateI<C : CitizenRef> : ArticleI, ArticleWithTitleI, VersionableRef, TargetI, CreatedBy<C> {
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<String> = 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<CitizenRef>,
ArticleAuthI<CitizenRef>,
VersionableRef {
val tags: List<String> = tags.distinct()
}
class ArticleForListing(
id: UUID? = null,
override val title: String,
override val createdBy: CitizenCart,
override val workgroup: WorkgroupCart?,
override val deletedAt: DateTime?,
override val draft: Boolean
) : ArticleForListingI,
ArticleRef(id),
ArticleAuthI<CitizenCartI>,
Votable by VotableImp(),
CreatedBy<CitizenCartI>
interface ArticleForListingI : ArticleWithTitleI, CreatedBy<CitizenCartI> {
val workgroup: WorkgroupCartI?
}
open class ArticleRef(
id: UUID? = null
) : ArticleI, TargetRef(id)
interface ArticleI : UuidEntityI, TargetI
interface ArticleWithTitleI : ArticleI {
val title: String
}
interface ArticleAuthI<U : CitizenI> :
ArticleI,
CreatedBy<U>,
EntityDeletedAt {
val draft: Boolean
}

View File

@@ -0,0 +1,49 @@
package fr.dcproject.component.article
import fr.dcproject.common.entity.CreatedBy
import fr.dcproject.common.entity.VersionableRef
import fr.dcproject.common.security.AccessControl
import fr.dcproject.common.security.AccessResponse
import fr.dcproject.component.citizen.CitizenI
class ArticleAccessControl(private val articleRepo: ArticleRepository) : AccessControl() {
fun <S : ArticleAuthI<*>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
canAll(subjects) { canView(it, citizen) }
fun <S : ArticleAuthI<*>> 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 <S : CreatedBy<*>> 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 <S> canUpsert(subject: S, citizen: CitizenI?): AccessResponse
where S : ArticleI,
S : CreatedBy<*>,
S : VersionableRef {
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")
}
}

View File

@@ -0,0 +1,58 @@
package fr.dcproject.component.article
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.Parameter
import fr.postgresjson.repository.RepositoryI
import net.pearx.kasechange.toSnakeCase
import java.util.UUID
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 findVersionsById(page: Int = 1, limit: Int = 50, id: UUID): Paginated<ArticleForView> {
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<ArticleForView> {
return requester
.getFunction("find_articles_versions_by_version_id")
.select(page, limit, "version_id" to versionId)
}
fun find(
page: Int = 1,
limit: Int = 50,
sort: String? = null,
direction: RepositoryI.Direction? = null,
search: String? = null,
filter: Filter = Filter()
): Paginated<ArticleForListing> {
return requester
.getFunction("find_articles")
.select(
page,
limit,
"sort" to sort?.toSnakeCase(),
"direction" to direction,
"search" to search,
"filter" to filter
)
}
fun upsert(article: ArticleForUpdate): ArticleForView? {
return requester
.getFunction("upsert_article")
.selectOne("resource" to article)
}
class Filter(
val createdById: String? = null,
val workgroupId: String? = null
) : Parameter
}

View File

@@ -0,0 +1,100 @@
package fr.dcproject.component.article
import fr.dcproject.common.entity.VersionableRef
import fr.dcproject.common.utils.contentToString
import fr.dcproject.common.utils.getJsonField
import fr.dcproject.common.utils.toIso
import fr.dcproject.component.citizen.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.UUID
/**
* Wrapper for manage views with elasticsearch
*/
class ArticleViewManager <A> (private val restClient: RestClient) : ViewManager<A> where A : VersionableRef, 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(
"POST",
"/views/_doc/"
).apply {
//language=JSON
setJsonEntity(
"""
{
"logged": $isLogged,
"type": "article",
"user_ref": "$ref",
"ip": "$ip",
"id": "${entity.id}",
"version_id": "${entity.versionId}",
"citizen_id": "${citizen?.id}",
"view_at": "${dateTime.toIso()}"
}
""".trimIndent()
)
}
return restClient.performRequest(request)
}
/**
* Get article views aggregations from elasticsearch
*/
override fun getViewsCount(entity: A): ViewAggregation {
val request = Request(
"GET",
"/views/_search"
).apply {
//language=JSON
setJsonEntity(
"""
{
"size": 0,
"query": {
"bool": {
"must": {
"term": {
"version_id": "${entity.versionId}"
}
}
}
},
"aggs" : {
"total": {
"composite" : {
"sources" : [
{ "version_id": { "terms": {"field": "version_id" } } }
]
}
},
"unique" : {
"cardinality" : {
"field" : "user_ref",
"precision_threshold": 1
}
}
}
}
""".trimIndent()
)
}
return restClient
.performRequest(request).contentToString().run {
ViewAggregation(
getJsonField("$.aggregations.total.buckets[0].doc_count") ?: 0,
getJsonField("$.aggregations.unique.value") ?: 0
)
}
}
}

View File

@@ -0,0 +1,8 @@
package fr.dcproject.component.article
import org.koin.dsl.module
val articleKoinModule = module {
single { ArticleRepository(get()) }
single { ArticleAccessControl(get()) }
}

View File

@@ -0,0 +1,43 @@
package fr.dcproject.component.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.article.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<ArticleVersionsRequest> {
repo.findVersions(it)
.apply { ac.assert { canView(result, citizenOrNull) } }
.let { call.respond(it) }
}
}
}

View File

@@ -0,0 +1,50 @@
package fr.dcproject.component.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.ArticleForListing
import fr.dcproject.component.article.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<ArticleForListing> {
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<ArticlesRequest> {
repo.findArticles(it)
.apply { ac.assert { canView(result, citizenOrNull) } }
.let { call.respond(it) }
}
}
}

View File

@@ -0,0 +1,70 @@
package fr.dcproject.component.article.routes
import fr.dcproject.common.dto.CreatedAt
import fr.dcproject.common.dto.Versionable
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleAccessControl
import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.article.ArticleRepository
import fr.dcproject.component.article.ArticleViewManager
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.opinion.dto.Opinionable
import fr.dcproject.component.views.dto.Viewable
import fr.dcproject.component.views.entity.ViewAggregation
import fr.dcproject.component.vote.dto.Votable
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)
}
class Output(
article: ArticleForView,
views: ViewAggregation = ViewAggregation()
) : CreatedAt by CreatedAt.Imp(article),
Opinionable by Opinionable.Imp(article),
Votable by Votable.Imp(article),
Versionable by Versionable.Imp(article),
Viewable by Viewable.Imp(views) {
val id = article.id
val title = article.title
val anonymous = article.anonymous
val content = article.content
val description = article.description
val tags = article.tags
val draft = article.draft
val lastVersion = article.lastVersion
val createdBy = article.createdBy
val workgroup = article.workgroup // TODO change to workgroup DTO
}
fun Route.getOneArticle(viewManager: ArticleViewManager<ArticleForView>, ac: ArticleAccessControl, repo: ArticleRepository) {
get<ArticleRequest> {
val article: ArticleForView = repo.findById(it.article.id) ?: throw NotFoundException("Article ${it.article.id} not found")
ac.assert { canView(article, citizenOrNull) }
Output(
article,
viewManager.getViewsCount(article)
).also { out ->
call.respond(out)
}
launch {
viewManager.addView(call.request.local.remoteHost, article, citizenOrNull)
}
}
}
}

View File

@@ -0,0 +1,65 @@
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.ArticleForUpdate
import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.article.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.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<String> = 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<Input>().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<UpsertArticleRequest> {
val article = call.convertRequestToEntity()
ac.assert { canUpsert(article, citizenOrNull) }
val newArticle: ArticleForView = repo.upsert(article) ?: error("Article not updated")
call.respond(newArticle)
publisher.publish(ArticleUpdateNotification(newArticle))
}
}
}

View File

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

View File

@@ -0,0 +1,30 @@
package fr.dcproject.component.auth
import fr.dcproject.component.citizen.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.component.citizen.Citizen as CitizenEntity
class ForbiddenException(message: String) : Exception(message)
private val citizenAttributeKey = AttributeKey<CitizenEntity>("CitizenContext")
val ApplicationCall.citizen: CitizenEntity
get() = attributes.computeIfAbsent(citizenAttributeKey) {
val user = authentication.principal<UserI>() ?: throw ForbiddenException("No User Connected")
GlobalContext.get().koin.get<CitizenRepository>().findByUser(user)
?: throw ForbiddenException("Citizen not found for this user id \"${user.id}\"")
}
val ApplicationCall.citizenOrNull: CitizenEntity?
get() = authentication.principal<UserI>()?.let {
GlobalContext.get().koin.get<CitizenRepository>().findByUser(it)
}
val PipelineContext<Unit, ApplicationCall>.citizen get() = context.citizen
val PipelineContext<Unit, ApplicationCall>.citizenOrNull get() = context.citizenOrNull
val ApplicationCall.user get() = authentication.principal<User>()

View File

@@ -0,0 +1,14 @@
package fr.dcproject.component.auth
import fr.dcproject.application.Configuration
import fr.dcproject.common.email.Mailer
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<Mailer>(), config.domain, get())
}
}

View File

@@ -0,0 +1,57 @@
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.common.email.Mailer
import fr.dcproject.component.auth.jwt.makeToken
import fr.dcproject.component.citizen.CitizenRepository
import fr.dcproject.component.citizen.CitizenWithEmail
import fr.dcproject.component.citizen.CitizenWithUserI
import io.ktor.http.URLBuilder
/**
* Send a connexion link by email
*/
class PasswordlessAuth(
private val mailer: Mailer,
private val domain: String,
private val citizenRepo: CitizenRepository
) {
fun sendEmail(email: String, url: String) {
val citizen = citizenRepo.findByEmail(email) ?: noEmail(email)
sendEmail(citizen, url)
}
fun <C> sendEmail(citizen: C, url: String) where C : CitizenWithEmail, C : CitizenWithUserI {
mailer.sendEmail {
val token = citizen.user.makeToken()
Mail(
Email("passwordless-auth@$domain"),
"Connection",
Email(citizen.email),
Content("text/plain", generateContent(token, url))
).apply {
addContent(Content("text/html", generateHtmlContent(token, url)))
}
}
}
private fun generateHtmlContent(token: String, url: String): String? {
val urlObject = URLBuilder(url)
urlObject.parameters.append("token", token)
return "Click <a href=\"${urlObject.buildString()}\">here</a> for connect to $domain"
}
private fun generateContent(token: String, url: String): String {
val urlObject = URLBuilder(url)
urlObject.parameters.append("token", token)
return "Copy this link into your browser for connect to $domain: \n${urlObject.buildString()}"
}
class EmailNotFound(val email: String) : Exception() {
override val message: String = "No Citizen with this email : $email"
}
private fun noEmail(email: String): Nothing = throw EmailNotFound(email)
}

View File

@@ -0,0 +1,54 @@
package fr.dcproject.component.auth
import fr.dcproject.component.auth.UserI.Roles
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityUpdatedAt
import fr.postgresjson.entity.EntityUpdatedAtImp
import fr.postgresjson.entity.UuidEntity
import fr.postgresjson.entity.UuidEntityI
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<Roles> = emptyList()
) : User(id, username, blockedAt, roles),
UserWithPasswordI
open class User(
id: UUID = UUID.randomUUID(),
var username: String,
var blockedAt: DateTime? = null,
var roles: List<Roles> = emptyList()
) : UserRef(id),
EntityCreatedAt by EntityCreatedAtImp(),
EntityUpdatedAt by EntityUpdatedAtImp()
interface UserWithPasswordI {
val id: UUID
val password: String
}
class UserWithPassword(
id: UUID,
override val password: String,
) : UserWithPasswordI,
UserRef(id)
open class UserRef(
id: UUID = UUID.randomUUID()
) : UserI, UuidEntity(id)
interface UserI : UuidEntityI, Principal {
enum class Roles { ROLE_USER, ROLE_ADMIN }
}
interface UserForAuthI : UserI {
var roles: List<Roles>
var blockedAt: DateTime?
}

View File

@@ -0,0 +1,41 @@
package fr.dcproject.component.auth
import fr.postgresjson.connexion.Requester
import fr.postgresjson.repository.RepositoryI
import io.ktor.auth.UserPasswordCredential
import java.util.UUID
class UserRepository(override var requester: Requester) : RepositoryI {
fun findByCredentials(credentials: UserPasswordCredential): User? {
return requester
.getFunction("check_user")
.selectOne(
"username" to credentials.name,
"password" to credentials.password
)
}
fun findById(id: UUID): User {
return requester
.getFunction("find_user_by_id")
.selectOne(
"id" to id
) ?: throw UserNotFound(id)
}
fun insert(user: User): User? {
return requester
.getFunction("insert_user")
.selectOne("resource" to user)
}
fun changePassword(user: UserWithPassword) {
requester
.getFunction("change_user_password")
.sendQuery("resource" to user)
}
class UserNotFound(override val message: String?, override val cause: Throwable?) : Throwable(message, cause) {
constructor(id: UUID) : this("No User with ID $id", null)
}
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.component.auth.jwt
import com.auth0.jwt.JWT
import fr.dcproject.component.auth.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)

View File

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

View File

@@ -0,0 +1,43 @@
package fr.dcproject.component.auth.jwt
import fr.dcproject.component.auth.User
import fr.dcproject.component.auth.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))
}
}
}
}

View File

@@ -0,0 +1,43 @@
package fr.dcproject.component.auth.routes
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.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.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.post
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<LoginRequest> {
try {
val credentials = call.receiveOrBadRequest<Input>().run {
UserPasswordCredential(username, password)
}
userRepo.findByCredentials(credentials)?.let { user ->
call.respondText(user.makeToken())
} ?: 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")
}
}
}
}

View File

@@ -0,0 +1,72 @@
package fr.dcproject.component.auth.routes
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.auth.User
import fr.dcproject.component.auth.UserForCreate
import fr.dcproject.component.auth.UserI
import fr.dcproject.component.auth.jwt.makeToken
import fr.dcproject.component.auth.routes.Register.RegisterRequest.Input
import fr.dcproject.component.citizen.CitizenForCreate
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRepository
import io.ktor.application.call
import io.ktor.features.BadRequestException
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.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<RegisterRequest> {
try {
val citizen = call.receiveOrBadRequest<Input>().toCitizen()
val createdCitizen = citizenRepo.insertWithUser(citizen)?.user ?: throw BadRequestException("Bad request")
call.respondText(createdCitizen.makeToken())
} catch (e: MissingKotlinParameterException) {
call.respond(HttpStatusCode.BadRequest)
}
}
}
}

View File

@@ -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<PasswordlessRequest> {
call.receiveOrBadRequest<Input>().run {
try {
passwordlessAuth.sendEmail(email, url)
} catch (e: PasswordlessAuth.EmailNotFound) {
call.respond(HttpStatusCode.NotFound)
}
call.respond(HttpStatusCode.NoContent)
}
}
}
}

View File

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

View File

@@ -0,0 +1,130 @@
package fr.dcproject.component.citizen
import fr.dcproject.component.auth.User
import fr.dcproject.component.auth.UserForCreate
import fr.dcproject.component.auth.UserI
import fr.dcproject.component.auth.UserRef
import fr.dcproject.component.citizen.CitizenI.Name
import fr.dcproject.component.workgroup.WorkgroupSimple
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityDeletedAt
import fr.postgresjson.entity.EntityDeletedAtImp
import fr.postgresjson.entity.UuidEntity
import fr.postgresjson.entity.UuidEntityI
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),
EntityCreatedAt by EntityCreatedAtImp()
class Citizen(
override val id: UUID = UUID.randomUUID(),
override val name: Name,
override val email: String,
override val birthday: DateTime,
override val voteAnonymous: Boolean = true,
override val followAnonymous: Boolean = true,
override val user: User,
deletedAt: DateTime? = null
) : CitizenFull,
CitizenBasicI,
CitizenRef(id),
CitizenCartI,
EntityCreatedAt by EntityCreatedAtImp(),
EntityDeletedAt by EntityDeletedAtImp(deletedAt) {
var workgroups: List<WorkgroupAndRoles> = emptyList()
class WorkgroupAndRoles(
val roles: List<String>,
val workgroup: WorkgroupSimple<CitizenRef>
)
}
@Deprecated("")
data class CitizenBasic(
override var id: UUID = UUID.randomUUID(),
override var name: Name,
override var email: String,
override var birthday: DateTime,
override var voteAnonymous: Boolean = true,
override var followAnonymous: Boolean = true,
override val user: User,
override val deletedAt: DateTime? = null
) : CitizenBasicI,
CitizenRefWithUser(id, user),
EntityDeletedAt by EntityDeletedAtImp(deletedAt)
@Deprecated("")
open class CitizenSimple(
id: UUID = UUID.randomUUID(),
var name: Name,
user: UserRef
) : CitizenRefWithUser(id, user)
class CitizenCart(
id: UUID = UUID.randomUUID(),
override val name: Name,
override val user: UserRef
) : CitizenRef(id),
CitizenCartI
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()
) : UuidEntity(id),
CitizenI
interface CitizenI : UuidEntityI {
data class Name(
override val firstName: String,
override val lastName: String,
override val civility: String? = null
) : NameI
interface NameI {
val firstName: String
val lastName: String
val civility: String?
fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim()
}
}
@Deprecated("")
interface CitizenBasicI : CitizenWithUserI, CitizenWithEmail, EntityDeletedAt {
val name: Name
val birthday: DateTime
val voteAnonymous: Boolean
val followAnonymous: Boolean
}
@Deprecated("")
interface CitizenFull : CitizenBasicI {
override val user: User
}
interface CitizenWithUserI : CitizenI {
val user: UserI
}
interface CitizenWithEmail : CitizenI {
val email: String
}

View File

@@ -0,0 +1,26 @@
package fr.dcproject.component.citizen
import fr.dcproject.common.security.AccessControl
import fr.dcproject.common.security.AccessResponse
import fr.postgresjson.entity.EntityDeletedAt
class CitizenAccessControl : AccessControl() {
fun <S> canView(subjects: List<S>, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : EntityDeletedAt =
canAll(subjects) { canView(it, connectedCitizen) }
fun <S> canView(subject: S, connectedCitizen: CitizenI?): AccessResponse where S : CitizenI, S : EntityDeletedAt {
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 <S : CitizenI> 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 <S : CitizenI> 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")
}
}

View File

@@ -0,0 +1,50 @@
package fr.dcproject.component.citizen
import fr.dcproject.component.auth.UserI
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(unsername: String): Citizen? = requester
.getFunction("find_citizen_by_username")
.selectOne("username" to unsername)
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<CitizenBasic> = 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)
}

View File

@@ -0,0 +1,8 @@
package fr.dcproject.component.citizen
import org.koin.dsl.module
val citizenKoinModule = module {
single { CitizenRepository(get()) }
single { CitizenAccessControl() }
}

View File

@@ -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.UserRepository
import fr.dcproject.component.auth.UserWithPassword
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.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<ChangePasswordCitizenRequest> {
ac.assert { canChangePassword(it.citizen, citizenOrNull) }
val content = call.receiveOrBadRequest<ChangePasswordCitizenRequest.Input>()
userRepository.findByCredentials(UserPasswordCredential(citizen.user.username, content.oldPassword)) ?: throw BadRequestException("Bad Password")
userRepository.changePassword(
UserWithPassword(
citizen.user.id,
content.newPassword,
)
)
call.respond(HttpStatusCode.Created)
}
}
}

View File

@@ -0,0 +1,35 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.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
@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<CitizensRequest> {
val citizens = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
ac.assert { canView(citizens.result, citizenOrNull) }
call.respond(citizens)
}
}
}

View File

@@ -0,0 +1,31 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenAccessControl
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetCurrentCitizen {
@Location("/citizens/current")
class CurrentCitizenRequest
fun Route.getCurrentCitizen(ac: CitizenAccessControl) {
get<CurrentCitizenRequest> {
val currentUser = citizenOrNull
if (currentUser === null) {
call.respond(HttpStatusCode.Unauthorized)
} else {
ac.assert { canView(currentUser, citizenOrNull) }
call.respond(citizen)
}
}
}
}

View File

@@ -0,0 +1,32 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenAccessControl
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.citizen.CitizenRepository
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 java.util.UUID
@KtorExperimentalLocationsAPI
object GetOneCitizen {
@Location("/citizens/{citizen}")
class CitizenRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getOneCitizen(ac: CitizenAccessControl, citizenRepository: CitizenRepository) {
get<CitizenRequest> {
val citizen = citizenRepository.findById(it.citizen.id) ?: throw NotFoundException("Citizen not found ${it.citizen.id}")
ac.assert { canView(citizen, citizenOrNull) }
call.respond(it.citizen)
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.citizen.routes
import fr.dcproject.component.citizen.routes.ChangeMyPassword.changeMyPassword
import fr.dcproject.component.citizen.routes.FindCitizens.findCitizen
import fr.dcproject.component.citizen.routes.GetCurrentCitizen.getCurrentCitizen
import fr.dcproject.component.citizen.routes.GetOneCitizen.getOneCitizen
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installCitizenRoutes() {
authenticate(optional = true) {
findCitizen(get(), get())
getOneCitizen(get(), get())
getCurrentCitizen(get())
changeMyPassword(get(), get())
}
}

View File

@@ -0,0 +1,14 @@
package fr.dcproject.component.comment
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentRepository
import org.koin.dsl.module
val commentKoinModule = module {
single { CommentRepository(get()) }
single { CommentArticleRepository(get()) }
single { CommentConstitutionRepository(get()) }
single { CommentAccessControl() }
}

View File

@@ -0,0 +1,62 @@
package fr.dcproject.component.comment.article
import fr.dcproject.common.entity.TargetI
import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.comment.generic.CommentForView
import fr.dcproject.component.comment.generic.CommentRepositoryAbs
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.UuidEntityI
import java.util.UUID
class CommentArticleRepository(requester: Requester) : CommentRepositoryAbs<ArticleForView>(requester) {
override fun findById(id: UUID): CommentForView<ArticleForView, CitizenRef>? {
return requester
.getFunction("find_comment_by_id")
.selectOne(mapOf("id" to id))
}
override fun findByCitizen(
citizen: CitizenI,
page: Int,
limit: Int
): Paginated<CommentForView<ArticleForView, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_citizen")
.select(
page,
limit,
"created_by_id" to citizen.id,
"reference" to TargetI.getReference(ArticleRef::class)
)
}
}
override fun findByTarget(
target: UuidEntityI,
page: Int,
limit: Int,
sort: Sort
): Paginated<CommentForView<ArticleForView, CitizenRef>> = requester
.getFunction("find_comments_by_target")
.select(
page,
limit,
"target_id" to target.id,
"sort" to sort.sql
)
enum class Sort(val sql: String) {
CREATED_AT("created_at"),
VOTES("votes");
companion object {
fun fromString(string: String): Sort? {
return values().firstOrNull { it.sql == string }
}
}
}
}

View File

@@ -0,0 +1,47 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.dcproject.component.comment.article.routes.CreateCommentArticle.PostArticleCommentRequest.Input
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentForUpdate
import io.ktor.application.ApplicationCall
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
import java.util.UUID
@KtorExperimentalLocationsAPI
object CreateCommentArticle {
@Location("/articles/{article}/comments")
class PostArticleCommentRequest(article: UUID) {
val article = ArticleRef(article)
class Input(val content: String)
}
suspend fun PostArticleCommentRequest.getComment(call: ApplicationCall) = call.receiveOrBadRequest<Input>().run {
CommentForUpdate(
target = article,
createdBy = call.citizen,
content = content
)
}
fun Route.createCommentArticle(repo: CommentArticleRepository, ac: CommentAccessControl) {
post<PostArticleCommentRequest> {
it.getComment(call).let { comment ->
ac.assert { canCreate(comment, citizenOrNull) }
repo.comment(comment)
call.respond(HttpStatusCode.Created, comment)
}
}
}
}

View File

@@ -0,0 +1,42 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetArticleComments {
@Location("/articles/{article}/comments")
class ArticleCommentsRequest(
article: UUID,
page: Int = 1,
limit: Int = 50,
val search: String? = null,
sort: String = CommentArticleRepository.Sort.CREATED_AT.sql
) : PaginatedRequestI by PaginatedRequest(page, limit) {
val article = ArticleRef(article)
val sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.fromString(sort) ?: CommentArticleRepository.Sort.CREATED_AT
}
fun Route.getArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
get<ArticleCommentsRequest> {
val comment = repo.findByTarget(it.article, it.page, it.limit, it.sort)
if (comment.result.isNotEmpty()) {
ac.assert { canView(comment.result, citizenOrNull) }
}
call.respond(HttpStatusCode.OK, comment)
}
}
}

View File

@@ -0,0 +1,31 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
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 GetCitizenArticleComments {
@Location("/citizens/{citizen}/comments/articles")
class CitizenCommentArticleRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getCitizenArticleComments(repo: CommentArticleRepository, ac: CommentAccessControl) {
get<CitizenCommentArticleRequest> {
repo.findByCitizen(it.citizen).let { comments ->
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(comments)
}
}
}
}

View File

@@ -0,0 +1,18 @@
package fr.dcproject.component.comment.article.routes
import fr.dcproject.component.comment.article.routes.CreateCommentArticle.createCommentArticle
import fr.dcproject.component.comment.article.routes.GetArticleComments.getArticleComments
import fr.dcproject.component.comment.article.routes.GetCitizenArticleComments.getCitizenArticleComments
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installCommentArticleRoutes() {
authenticate(optional = true) {
getArticleComments(get(), get())
createCommentArticle(get(), get())
getCitizenArticleComments(get(), get())
}
}

View File

@@ -0,0 +1,54 @@
package fr.dcproject.component.comment.constitution
import fr.dcproject.common.entity.TargetI
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.dcproject.component.comment.generic.CommentForView
import fr.dcproject.component.comment.generic.CommentRepositoryAbs
import fr.dcproject.component.constitution.ConstitutionRef
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.UuidEntityI
import java.util.UUID
class CommentConstitutionRepository(requester: Requester) : CommentRepositoryAbs<ConstitutionRef>(requester) {
override fun findById(id: UUID): CommentForView<ConstitutionRef, CitizenRef>? {
return requester
.getFunction("find_comment_by_id")
.selectOne(mapOf("id" to id))
}
override fun findByCitizen(
citizen: CitizenI,
page: Int,
limit: Int
): Paginated<CommentForView<ConstitutionRef, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_citizen")
.select(
page,
limit,
"created_by_id" to citizen.id,
"reference" to TargetI.getReference(ConstitutionRef::class)
)
}
}
override fun findByTarget(
target: UuidEntityI,
page: Int,
limit: Int,
sort: CommentArticleRepository.Sort
): Paginated<CommentForView<ConstitutionRef, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_target")
.select(
page,
limit,
"target_id" to target.id,
"sort" to sort.sql
)
}
}
}

View File

@@ -0,0 +1,41 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentForUpdate
import fr.dcproject.component.constitution.ConstitutionRef
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.request.receiveText
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object CreateConstitutionComment {
@Location("/constitutions/{constitution}/comments")
class CreateConstitutionCommentRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.createConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
post<CreateConstitutionCommentRequest> {
val content = call.receiveText()
val comment = CommentForUpdate(
target = it.constitution,
createdBy = citizen,
content = content
)
ac.assert { canCreate(comment, citizenOrNull) }
repo.comment(comment)
call.respond(HttpStatusCode.Created, comment)
}
}
}

View File

@@ -0,0 +1,30 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
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 GetCitizenCommentConstitution {
@Location("/citizens/{citizen}/comments/constitutions")
class GetCitizenCommentConstitutionRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getCitizenCommentConstitution(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
get<GetCitizenCommentConstitutionRequest> {
val comments = repo.findByCitizen(it.citizen)
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(comments)
}
}
}

View File

@@ -0,0 +1,31 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.constitution.CommentConstitutionRepository
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.constitution.ConstitutionRef
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetConstitutionComment {
@Location("/constitutions/{constitution}/comments")
class GetConstitutionCommentRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.getConstitutionComment(repo: CommentConstitutionRepository, ac: CommentAccessControl) {
get<GetConstitutionCommentRequest> {
val comments = repo.findByTarget(it.constitution)
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(HttpStatusCode.OK, comments)
}
}
}

View File

@@ -0,0 +1,18 @@
package fr.dcproject.component.comment.constitution.routes
import fr.dcproject.component.comment.constitution.routes.CreateConstitutionComment.createConstitutionComment
import fr.dcproject.component.comment.constitution.routes.GetCitizenCommentConstitution.getCitizenCommentConstitution
import fr.dcproject.component.comment.constitution.routes.GetConstitutionComment.getConstitutionComment
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installCommentConstitutionRoutes() {
authenticate(optional = true) {
createConstitutionComment(get(), get())
getCitizenCommentConstitution(get(), get())
getConstitutionComment(get(), get())
}
}

View File

@@ -0,0 +1,95 @@
package fr.dcproject.component.comment.generic
import fr.dcproject.common.entity.EntityI
import fr.dcproject.common.entity.ExtraI
import fr.dcproject.common.entity.HasTarget
import fr.dcproject.common.entity.TargetI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.vote.entity.Votable
import fr.dcproject.component.vote.entity.VotableImp
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityCreatedByImp
import fr.postgresjson.entity.EntityDeletedAt
import fr.postgresjson.entity.EntityDeletedAtImp
import fr.postgresjson.entity.EntityUpdatedAt
import fr.postgresjson.entity.EntityUpdatedAtImp
import org.joda.time.DateTime
import java.util.UUID
class CommentForView<T : TargetI, C : CitizenRef>(
id: UUID = UUID.randomUUID(),
override val createdBy: C,
override val target: T,
override var content: String,
override val parent: CommentParent<T>? = null,
val childrenCount: Int? = null,
override val deletedAt: DateTime? = null
) : ExtraI<T, C>,
CommentWithParentI<T>,
CommentForUpdate<T, C>(id, createdBy, target, content, parent, deletedAt),
CommentWithTargetI<T>,
EntityCreatedBy<C> by EntityCreatedByImp(createdBy),
EntityUpdatedAt by EntityUpdatedAtImp(),
EntityDeletedAt by EntityDeletedAtImp(),
Votable by VotableImp(),
TargetI {
constructor(
createdBy: C,
parent: CommentParent<T>,
content: String
) : this(
createdBy = createdBy,
parent = parent,
target = parent.target,
content = content
)
}
open class CommentForUpdate<T : TargetI, C : CitizenRef>(
override val id: UUID = UUID.randomUUID(),
override val createdBy: C,
override val target: T,
open var content: String,
override val parent: CommentParent<T>? = null,
override val deletedAt: DateTime? = null
) : CommentParent<T>(id, deletedAt, target),
CommentWithParentI<T>,
ExtraI<T, C>,
CommentWithTargetI<T>,
EntityCreatedAt by EntityCreatedAtImp(),
EntityCreatedBy<C>,
EntityDeletedAt,
TargetI {
constructor(
createdBy: C,
parent: CommentParent<T>,
content: String
) : this(
createdBy = createdBy,
parent = parent,
target = parent.target,
content = content
)
}
open class CommentParent<T : TargetI>(
override val id: UUID,
override val deletedAt: DateTime?,
override val target: T
) : CommentRef(id),
CommentParentI<T>
interface CommentParentI<T : TargetI> : CommentI, EntityDeletedAt, CommentWithTargetI<T>
interface CommentWithTargetI<T : TargetI> : CommentI, TargetI, HasTarget<T>
interface CommentWithParentI<T : TargetI> {
val parent: CommentParent<T>?
}
open class CommentRef(id: UUID = UUID.randomUUID()) : CommentI, TargetRef(id)
interface CommentI : EntityI

View File

@@ -0,0 +1,41 @@
package fr.dcproject.component.comment.generic
import fr.dcproject.common.entity.HasTarget
import fr.dcproject.common.security.AccessControl
import fr.dcproject.common.security.AccessResponse
import fr.dcproject.component.citizen.CitizenI
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityDeletedAt
class CommentAccessControl : AccessControl() {
fun <S> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse
where S : CommentI,
S : EntityDeletedAt = canAll(subjects) { canView(it, citizen) }
fun <S> canView(subject: S, citizen: CitizenI?): AccessResponse
where S : CommentI,
S : EntityDeletedAt = when {
subject.isDeleted() -> denied("Your cannot view a deleted comment", "comment.view.deleted")
else -> granted()
}
fun <S, CR : CitizenI> canCreate(subject: S, citizen: CitizenI?): AccessResponse
where S : CommentI,
S : EntityCreatedBy<CR>,
S : CommentWithParentI<*>,
S : HasTarget<*> = when {
citizen == null -> denied("You must be connected to create user", "comment.create.notConnected")
subject.createdBy.id != citizen.id -> denied("You cannot create a comment with other user than yours", "comment.create.wrongUser")
subject.parent?.isDeleted() ?: false -> denied("You cannot create a comment on deleted parent", "comment.create.deletedParent")
subject.target.let { it is EntityDeletedAt && it.isDeleted() } -> denied("You cannot create a comment on deleted target", "comment.create.deletedTarget")
else -> granted()
}
fun <S, CR : CitizenI> canUpdate(subject: S, citizen: CitizenI?): AccessResponse
where S : CommentI,
S : EntityCreatedBy<CR> = when {
citizen == null -> denied("You must be connected to update comment", "comment.update.notConnected")
citizen.id != subject.createdBy.id -> denied("You cannot update another user of yours", "comment.update.notYours")
else -> granted()
}
}

View File

@@ -0,0 +1,127 @@
package fr.dcproject.component.comment.generic
import fr.dcproject.common.entity.TargetI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.comment.article.CommentArticleRepository
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.UuidEntityI
import fr.postgresjson.repository.RepositoryI
import java.util.UUID
abstract class CommentRepositoryAbs<T : TargetI>(override var requester: Requester) : RepositoryI {
abstract fun findById(id: UUID): CommentForView<T, CitizenRef>?
abstract fun findByCitizen(
citizen: CitizenI,
page: Int = 1,
limit: Int = 50
): Paginated<CommentForView<T, CitizenRef>>
open fun findByParent(
parent: CommentForView<T, CitizenRef>,
page: Int = 1,
limit: Int = 50
): Paginated<CommentForView<T, CitizenRef>> {
return findByParent(parent.id, page, limit)
}
open fun findByParent(
parentId: UUID,
page: Int = 1,
limit: Int = 50
): Paginated<CommentForView<T, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_parent")
.select(
page,
limit,
"parent_id" to parentId
)
}
}
open fun findByTarget(
target: UuidEntityI,
page: Int = 1,
limit: Int = 50,
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
): Paginated<CommentForView<T, CitizenRef>> {
return findByTarget(target.id, page, limit, sort)
}
open fun findByTarget(
targetId: UUID,
page: Int = 1,
limit: Int = 50,
sort: CommentArticleRepository.Sort = CommentArticleRepository.Sort.CREATED_AT
): Paginated<CommentForView<T, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_target")
.select(
page,
limit,
"target_id" to targetId,
"sort" to sort.sql
)
}
}
fun <I : TargetI, C : CitizenRef> comment(comment: CommentForUpdate<I, C>) {
requester
.getFunction("comment")
.sendQuery(
"reference" to comment.target.reference,
"resource" to comment
)
}
fun <I : T> edit(comment: CommentForUpdate<I, CitizenRef>) {
requester
.getFunction("edit_comment")
.sendQuery(
"id" to comment.id,
"content" to comment.content
)
}
}
class CommentRepository(requester: Requester) : CommentRepositoryAbs<TargetRef>(requester) {
override fun findById(id: UUID): CommentForView<TargetRef, CitizenRef>? {
return requester
.getFunction("find_comment_by_id")
.selectOne(mapOf("id" to id))
}
override fun findByCitizen(
citizen: CitizenI,
page: Int,
limit: Int
): Paginated<CommentForView<TargetRef, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_citizen")
.select(
page,
limit,
"created_by_id" to citizen.id
)
}
}
override fun findByParent(
parentId: UUID,
page: Int,
limit: Int
): Paginated<CommentForView<TargetRef, CitizenRef>> {
return requester.run {
getFunction("find_comments_by_parent")
.select(
page,
limit,
"parent_id" to parentId
)
}
}
}

View File

@@ -0,0 +1,44 @@
package fr.dcproject.component.comment.generic.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.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentForUpdate
import fr.dcproject.component.comment.generic.CommentRef
import fr.dcproject.component.comment.generic.CommentRepository
import io.ktor.application.call
import io.ktor.features.NotFoundException
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
import java.util.UUID
@KtorExperimentalLocationsAPI
object CreateCommentChildren {
@Location("/comments/{comment}/children")
class CreateCommentChildrenRequest(comment: UUID) {
val comment = CommentRef(comment)
class Input(val content: String)
}
fun Route.createCommentChildren(repo: CommentRepository, ac: CommentAccessControl) {
post<CreateCommentChildrenRequest> {
val parent = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
val newComment = CommentForUpdate(
content = call.receiveOrBadRequest<CreateCommentChildrenRequest.Input>().content,
createdBy = citizen,
parent = parent
)
ac.assert { canCreate(newComment, citizenOrNull) }
repo.comment(newComment)
call.respond(HttpStatusCode.Created, newComment)
}
}
}

View File

@@ -0,0 +1,37 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentRef
import fr.dcproject.component.comment.generic.CommentRepository
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.put
import io.ktor.request.receiveText
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object EditComment {
@Location("/comments/{comment}")
class EditCommentRequest(comment: UUID) {
val comment = CommentRef(comment)
}
fun Route.editComment(repo: CommentRepository, ac: CommentAccessControl) {
put<EditCommentRequest> {
val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment not found")
ac.assert { canUpdate(comment, citizenOrNull) }
comment.content = call.receiveText()
repo.edit(comment)
call.respond(HttpStatusCode.OK, comment)
}
}
}

View File

@@ -0,0 +1,42 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentRepository
import fr.dcproject.routes.PaginatedRequest
import fr.dcproject.routes.PaginatedRequestI
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetCommentChildren {
@Location("/comments/{comment}/children")
class CommentChildrenRequest(
val comment: UUID,
page: Int = 1,
limit: Int = 50,
val search: String? = null
) : PaginatedRequestI by PaginatedRequest(page, limit)
fun Route.getChildrenComments(repo: CommentRepository, ac: CommentAccessControl) {
get<CommentChildrenRequest> {
val comments =
repo.findByParent(
it.comment,
it.page,
it.limit
)
ac.assert { canView(comments.result, citizenOrNull) }
call.respond(HttpStatusCode.OK, comments)
}
}
}

View File

@@ -0,0 +1,33 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.comment.generic.CommentAccessControl
import fr.dcproject.component.comment.generic.CommentRef
import fr.dcproject.component.comment.generic.CommentRepository
import io.ktor.application.call
import io.ktor.features.NotFoundException
import io.ktor.http.HttpStatusCode
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 GetOneComment {
@Location("/comments/{comment}")
class CommentRequest(comment: UUID) {
val comment = CommentRef(comment)
}
fun Route.getOneComment(repo: CommentRepository, ac: CommentAccessControl) {
get<CommentRequest> {
val comment = repo.findById(it.comment.id) ?: throw NotFoundException("Comment ${it.comment.id} not found")
ac.assert { canView(comment, citizenOrNull) }
call.respond(HttpStatusCode.OK, comment)
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.comment.generic.routes
import fr.dcproject.component.comment.generic.routes.CreateCommentChildren.createCommentChildren
import fr.dcproject.component.comment.generic.routes.EditComment.editComment
import fr.dcproject.component.comment.generic.routes.GetCommentChildren.getChildrenComments
import fr.dcproject.component.comment.generic.routes.GetOneComment.getOneComment
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installCommentRoutes() {
authenticate(optional = true) {
editComment(get(), get())
getOneComment(get(), get())
createCommentChildren(get(), get())
getChildrenComments(get(), get())
}
}

View File

@@ -0,0 +1,82 @@
package fr.dcproject.component.constitution
import fr.dcproject.common.entity.TargetI
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.ArticleForListing
import fr.dcproject.component.article.ArticleI
import fr.dcproject.component.citizen.CitizenSimple
import fr.dcproject.component.citizen.CitizenWithUserI
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityCreatedByImp
import fr.postgresjson.entity.EntityDeletedAt
import fr.postgresjson.entity.EntityDeletedAtImp
import fr.postgresjson.entity.EntityVersioning
import fr.postgresjson.entity.UuidEntity
import fr.postgresjson.entity.UuidEntityVersioning
import java.util.UUID
class Constitution(
id: UUID = UUID.randomUUID(),
title: String,
anonymous: Boolean = true,
titles: MutableList<TitleSimple<ArticleForListing>> = mutableListOf(),
draft: Boolean = false,
lastVersion: Boolean = false,
override val createdBy: CitizenSimple
) : ConstitutionSimple<CitizenSimple, TitleSimple<ArticleForListing>>(
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<ArticleForListing> = mutableListOf()
) : TitleSimple<ArticleForListing>(id, name, rank)
}
open class ConstitutionSimple<Cr : CitizenWithUserI, T : TitleSimple<*>>(
id: UUID = UUID.randomUUID(),
val title: String,
val anonymous: Boolean = true,
val titles: MutableList<T> = mutableListOf(),
val draft: Boolean = false,
val lastVersion: Boolean = false,
override val createdBy: Cr,
versionId: UUID = UUID.randomUUID()
) : ConstitutionRef(id),
EntityVersioning<UUID, Int> by UuidEntityVersioning(versionId = versionId, versionNumber = 0),
EntityCreatedAt by EntityCreatedAtImp(),
EntityCreatedBy<Cr> by EntityCreatedByImp(createdBy),
EntityDeletedAt by EntityDeletedAtImp() {
init {
titles.forEachIndexed { index, title ->
title.rank = index
}
}
open class TitleSimple<A : ArticleI>(
id: UUID = UUID.randomUUID(),
var name: String,
var rank: Int? = null,
open val articles: MutableList<A> = 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

View File

@@ -0,0 +1,34 @@
package fr.dcproject.component.constitution
import fr.dcproject.common.security.AccessControl
import fr.dcproject.common.security.AccessResponse
import fr.dcproject.component.citizen.CitizenI
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityDeletedAt
class ConstitutionAccessControl : AccessControl() {
fun canCreate(subject: ConstitutionS, citizen: CitizenI?): AccessResponse = when {
citizen == null -> denied("You must be connected to create constitution", "constitution.create.notConnected")
else -> granted()
}
fun <S : ConstitutionSimple<*, *>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
canAll(subjects) { canView(it, citizen) }
fun <S> canView(subject: S, citizen: CitizenI?): AccessResponse where S : EntityDeletedAt, S : ConstitutionS = when {
subject.isDeleted() -> denied("You cannot view a deleted constitution", "constitution.view.deleted")
else -> granted()
}
fun <S> canDelete(subject: S, citizen: CitizenI?): AccessResponse where S : EntityCreatedBy<CitizenI>, S : ConstitutionRef = when {
citizen == null -> denied("You must be connected to delete constitution", "constitution.delete.notConnected")
subject.createdBy.id != citizen.id -> denied("You cannot delete the constitution of other citizen", "constitution.delete.otherCitizen")
else -> granted()
}
fun <S> canUpdate(subject: S, citizen: CitizenI?): AccessResponse where S : EntityCreatedBy<CitizenI>, S : ConstitutionRef = when {
citizen == null -> denied("You must be connected to update constitution", "constitution.update.notConnected")
subject.createdBy.id != citizen.id -> denied("You cannot update the constitution of other citizen", "constitution.update.otherCitizen")
else -> granted()
}
}

View File

@@ -0,0 +1,43 @@
package fr.dcproject.component.constitution
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.citizen.CitizenWithUserI
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.repository.RepositoryI
import fr.postgresjson.repository.RepositoryI.Direction
import net.pearx.kasechange.toSnakeCase
import java.util.UUID
import fr.dcproject.component.constitution.Constitution as ConstitutionEntity
class ConstitutionRepository(override var requester: Requester) : RepositoryI {
fun findById(id: UUID): ConstitutionEntity? {
val function = requester.getFunction("find_constitution_by_id")
return function.selectOne("id" to id)
}
fun find(
page: Int = 1,
limit: Int = 50,
sort: String? = null,
direction: Direction? = null,
search: String? = null
): Paginated<ConstitutionEntity> {
return requester
.getFunction("find_constitutions")
.select(
page,
limit,
"sort" to sort?.toSnakeCase(),
"direction" to direction,
"search" to search
)
}
fun upsert(constitution: ConstitutionSimple<CitizenWithUserI, TitleSimple<ArticleRef>>): ConstitutionEntity? {
return requester
.getFunction("upsert_constitution")
.selectOne("resource" to constitution)
}
}

View File

@@ -0,0 +1,8 @@
package fr.dcproject.component.constitution
import org.koin.dsl.module
val constitutionKoinModule = module {
single { ConstitutionRepository(get()) }
single { ConstitutionAccessControl() }
}

View File

@@ -0,0 +1,82 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.common.utils.receiveOrBadRequest
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.Citizen
import fr.dcproject.component.citizen.CitizenWithUserI
import fr.dcproject.component.constitution.ConstitutionAccessControl
import fr.dcproject.component.constitution.ConstitutionRepository
import fr.dcproject.component.constitution.ConstitutionSimple
import fr.dcproject.component.constitution.ConstitutionSimple.TitleSimple
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input
import fr.dcproject.component.constitution.routes.CreateConstitution.PostConstitutionRequest.Input.Title
import fr.postgresjson.entity.UuidEntity
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 CreateConstitution {
@Location("/constitutions")
class PostConstitutionRequest {
class Input(
var title: String,
var anonymous: Boolean = true,
var titles: MutableList<Title> = mutableListOf(),
var draft: Boolean = false,
var lastVersion: Boolean = false,
var versionId: UUID = UUID.randomUUID()
) {
init {
titles.forEachIndexed { index, title ->
title.rank = index
}
}
class Title(
id: UUID = UUID.randomUUID(),
var name: String,
var rank: Int? = null,
var articles: MutableList<ArticleRef> = mutableListOf()
) : UuidEntity(id)
}
}
private fun getNewConstitution(input: Input, citizen: Citizen) = input.run {
ConstitutionSimple<CitizenWithUserI, TitleSimple<ArticleRef>>(
id = UUID.randomUUID(),
title = title,
titles = titles.create(),
createdBy = citizen,
versionId = versionId
)
}
private fun List<Title>.create(): MutableList<TitleSimple<ArticleRef>> =
map { it.create() }.toMutableList()
private fun Title.create(): TitleSimple<ArticleRef> =
TitleSimple(
id,
name,
rank,
articles
)
fun Route.createConstitution(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
post<PostConstitutionRequest> {
getNewConstitution(call.receiveOrBadRequest(), citizen).let {
ac.assert { canCreate(it, citizenOrNull) }
repo.upsert(it)
call.respond(it)
}
}
}
}

View File

@@ -0,0 +1,35 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.ConstitutionAccessControl
import fr.dcproject.component.constitution.ConstitutionRepository
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
@KtorExperimentalLocationsAPI
object FindConstitutions {
@Location("/constitutions")
class FindConstitutionsRequest(
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.findConstitutions(repo: ConstitutionRepository, ac: ConstitutionAccessControl) {
get<FindConstitutionsRequest> {
val constitutions = repo.find(it.page, it.limit, it.sort, it.direction, it.search)
ac.assert { canView(constitutions.result, citizenOrNull) }
call.respond(constitutions)
}
}
}

View File

@@ -0,0 +1,31 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.ConstitutionAccessControl
import fr.dcproject.component.constitution.ConstitutionRef
import fr.dcproject.component.constitution.ConstitutionRepository
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 java.util.UUID
@KtorExperimentalLocationsAPI
object GetConstitution {
@Location("/constitutions/{constitution}")
class GetConstitutionRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.getConstitution(ac: ConstitutionAccessControl, constitutionRepo: ConstitutionRepository) {
get<GetConstitutionRequest> {
val constitution = constitutionRepo.findById(it.constitution.id) ?: throw NotFoundException("Unable to find constitution ${it.constitution.id}")
ac.assert { canView(constitution, citizenOrNull) }
call.respond(constitution)
}
}
}

View File

@@ -0,0 +1,18 @@
package fr.dcproject.component.constitution.routes
import fr.dcproject.component.constitution.routes.CreateConstitution.createConstitution
import fr.dcproject.component.constitution.routes.FindConstitutions.findConstitutions
import fr.dcproject.component.constitution.routes.GetConstitution.getConstitution
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installConstitutionRoutes() {
authenticate(optional = true) {
getConstitution(get(), get())
findConstitutions(get(), get())
createConstitution(get(), get())
}
}

View File

@@ -0,0 +1,18 @@
package fr.dcproject.component.doc.routes
import fr.dcproject.common.utils.readResource
import io.ktor.application.call
import io.ktor.http.ContentType
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.response.respondText
import io.ktor.routing.Route
import io.ktor.routing.get
import io.ktor.util.KtorExperimentalAPI
@KtorExperimentalLocationsAPI
@KtorExperimentalAPI
fun Route.definition() {
get("/") {
call.respondText("/openapi.yaml".readResource(), ContentType("text", "yaml"))
}
}

View File

@@ -0,0 +1,16 @@
package fr.dcproject.component.doc.routes
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import io.ktor.util.KtorExperimentalAPI
import kotlinx.coroutines.ExperimentalCoroutinesApi
@KtorExperimentalAPI
@ExperimentalCoroutinesApi
@KtorExperimentalLocationsAPI
fun Routing.installDocRoutes() {
authenticate(optional = true) {
definition()
}
}

View File

@@ -0,0 +1,46 @@
package fr.dcproject.component.follow
import fr.dcproject.common.entity.ExtraI
import fr.dcproject.common.entity.HasTarget
import fr.dcproject.common.entity.TargetI
import fr.dcproject.component.citizen.CitizenBasic
import fr.dcproject.component.citizen.CitizenBasicI
import fr.dcproject.component.citizen.CitizenI
import fr.postgresjson.entity.EntityCreatedAt
import fr.postgresjson.entity.EntityCreatedAtImp
import fr.postgresjson.entity.EntityCreatedBy
import fr.postgresjson.entity.EntityCreatedByImp
import fr.postgresjson.entity.UuidEntityI
import java.util.UUID
@Deprecated("")
class Follow<T : TargetI>(
id: UUID = UUID.randomUUID(),
override val createdBy: CitizenBasic,
override var target: T
) : ExtraI<T, CitizenBasicI>,
FollowSimple<T, CitizenBasicI>(id, createdBy, target)
@Deprecated("")
open class FollowSimple<T : TargetI, C : CitizenI>(
id: UUID = UUID.randomUUID(),
override val createdBy: C,
override var target: T
) : ExtraI<T, C>,
FollowRef(id),
EntityCreatedAt by EntityCreatedAtImp(),
EntityCreatedBy<C> by EntityCreatedByImp(createdBy)
class FollowForUpdate<T : TargetI, C : CitizenI>(
id: UUID = UUID.randomUUID(),
override val target: T,
override val createdBy: C
) : FollowRef(id),
HasTarget<T>,
EntityCreatedBy<C> by EntityCreatedByImp<C>(createdBy)
open class FollowRef(
override val id: UUID
) : FollowI
interface FollowI : UuidEntityI

View File

@@ -0,0 +1,26 @@
package fr.dcproject.component.follow
import fr.dcproject.common.security.AccessControl
import fr.dcproject.common.security.AccessResponse
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.follow.Follow as FollowEntity
class FollowAccessControl : AccessControl() {
fun canCreate(subject: FollowI, citizen: CitizenI?): AccessResponse {
return if (citizen == null) denied("You must be connected to follow", "follow.create.notConnected")
else granted()
}
fun canDelete(subject: FollowI, citizen: CitizenI?): AccessResponse {
return if (citizen == null) denied("You must be connected to unfollow", "follow.delete.notConnected")
else granted()
}
fun <S : FollowEntity<*>> canView(subjects: List<S>, citizen: CitizenI?): AccessResponse =
canAll(subjects) { canView(it, citizen) }
fun canView(subject: FollowEntity<*>, citizen: CitizenI?): AccessResponse {
return if ((citizen != null && subject.createdBy.id == citizen.id) || !subject.createdBy.followAnonymous) granted()
else denied("You cannot view an anonymous follow", "follow.view.anonymous")
}
}

View File

@@ -0,0 +1,148 @@
package fr.dcproject.component.follow
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.ArticleForView
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.citizen.CitizenI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.constitution.ConstitutionRef
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.entity.UuidEntity
import fr.postgresjson.repository.RepositoryI
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.util.UUID
import fr.dcproject.component.constitution.Constitution as ConstitutionEntity
import fr.dcproject.component.follow.Follow as FollowEntity
sealed class FollowRepository<IN : TargetRef, OUT : TargetRef>(override var requester: Requester) : RepositoryI {
open fun findByCitizen(
citizen: CitizenI,
page: Int = 1,
limit: Int = 50
): Paginated<FollowEntity<OUT>> =
findByCitizen(citizen.id, page, limit)
open fun findByCitizen(
citizenId: UUID,
page: Int = 1,
limit: Int = 50
): Paginated<FollowEntity<OUT>> {
return requester
.getFunction("find_follows_by_citizen")
.select(
page,
limit,
"created_by_id" to citizenId
)
}
fun follow(follow: FollowForUpdate<IN, *>) {
requester
.getFunction("follow")
.sendQuery(
"reference" to follow.target.reference,
"target_id" to follow.target.id,
"created_by_id" to follow.createdBy.id
)
}
fun unfollow(follow: FollowForUpdate<IN, *>) {
requester
.getFunction("unfollow")
.sendQuery(
"reference" to follow.target.reference,
"target_id" to follow.target.id,
"created_by_id" to follow.createdBy.id
)
}
open fun findFollow(
citizen: CitizenI,
target: TargetRef
): FollowEntity<OUT>? =
requester
.getFunction("find_follow")
.selectOne(
"citizen_id" to citizen.id,
"target_id" to target.id,
"target_reference" to target.reference
)
fun findFollowsByTarget(
target: UuidEntity,
bulkSize: Int = 300
): Flow<FollowSimple<IN, CitizenRef>> = flow {
var nextPage = 1
do {
val paginate = findFollowsByTarget(target, nextPage, bulkSize)
paginate.result.forEach {
emit(it)
}
nextPage = paginate.currentPage + 1
} while (!paginate.isLastPage())
}
abstract fun findFollowsByTarget(
target: UuidEntity,
page: Int = 1,
limit: Int = 300
): Paginated<FollowSimple<IN, CitizenRef>>
}
class FollowArticleRepository(requester: Requester) : FollowRepository<ArticleRef, ArticleForView>(requester) {
override fun findByCitizen(
citizenId: UUID,
page: Int,
limit: Int
): Paginated<FollowEntity<ArticleForView>> {
return requester.run {
getFunction("find_follows_article_by_citizen")
.select(
page,
limit,
"created_by_id" to citizenId
)
}
}
override fun findFollowsByTarget(
target: UuidEntity,
page: Int,
limit: Int
): Paginated<FollowSimple<ArticleRef, CitizenRef>> {
return requester
.getFunction("find_follows_article_by_target")
.select(
page,
limit,
"target_id" to target.id
)
}
}
class FollowConstitutionRepository(requester: Requester) : FollowRepository<ConstitutionRef, ConstitutionEntity>(requester) {
override fun findByCitizen(
citizenId: UUID,
page: Int,
limit: Int
): Paginated<FollowEntity<ConstitutionEntity>> {
return requester.run {
getFunction("find_follows_constitution_by_citizen")
.select(
page,
limit,
"created_by_id" to citizenId
)
}
}
override fun findFollowsByTarget(
target: UuidEntity,
page: Int,
limit: Int
): Paginated<FollowSimple<ConstitutionRef, CitizenRef>> {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,9 @@
package fr.dcproject.component.follow
import org.koin.dsl.module
val followKoinModule = module {
single { FollowArticleRepository(get()) }
single { FollowConstitutionRepository(get()) }
single { FollowAccessControl() }
}

View File

@@ -0,0 +1,34 @@
package fr.dcproject.component.follow.routes.article
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowArticleRepository
import fr.dcproject.component.follow.FollowForUpdate
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
import java.util.UUID
@KtorExperimentalLocationsAPI
object FollowArticle {
@Location("/articles/{article}/follows")
class ArticleFollowRequest(article: UUID) {
val article = ArticleRef(article)
}
fun Route.followArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
post<ArticleFollowRequest> {
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
ac.assert { canCreate(follow, citizenOrNull) }
repo.follow(follow)
call.respond(HttpStatusCode.Created)
}
}
}

View File

@@ -0,0 +1,33 @@
package fr.dcproject.component.follow.routes.article
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowArticleRepository
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetFollowArticle {
@Location("/articles/{article}/follows")
class ArticleFollowRequest(article: UUID) {
val article = ArticleRef(article)
}
fun Route.getFollowArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
get<ArticleFollowRequest> {
repo.findFollow(citizen, it.article)?.let { follow ->
ac.assert { canView(follow, citizenOrNull) }
call.respond(follow)
} ?: call.respond(HttpStatusCode.NoContent)
}
}
}

View File

@@ -0,0 +1,30 @@
package fr.dcproject.component.follow.routes.article
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowArticleRepository
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 GetMyFollowsArticle {
@Location("/citizens/{citizen}/follows/articles")
class CitizenFollowArticleRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getMyFollowsArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
get<CitizenFollowArticleRequest> {
val follows = repo.findByCitizen(it.citizen)
ac.assert { canView(follows.result, citizenOrNull) }
call.respond(follows)
}
}
}

View File

@@ -0,0 +1,34 @@
package fr.dcproject.component.follow.routes.article
import fr.dcproject.common.security.assert
import fr.dcproject.component.article.ArticleRef
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowArticleRepository
import fr.dcproject.component.follow.FollowForUpdate
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.delete
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object UnfollowArticle {
@Location("/articles/{article}/follows")
class ArticleFollowRequest(article: UUID) {
val article = ArticleRef(article)
}
fun Route.unfollowArticle(repo: FollowArticleRepository, ac: FollowAccessControl) {
delete<ArticleFollowRequest> {
val follow = FollowForUpdate(target = it.article, createdBy = this.citizen)
ac.assert { canDelete(follow, citizenOrNull) }
repo.unfollow(follow)
call.respond(HttpStatusCode.NoContent)
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.follow.routes.article
import fr.dcproject.component.follow.routes.article.FollowArticle.followArticle
import fr.dcproject.component.follow.routes.article.GetFollowArticle.getFollowArticle
import fr.dcproject.component.follow.routes.article.GetMyFollowsArticle.getMyFollowsArticle
import fr.dcproject.component.follow.routes.article.UnfollowArticle.unfollowArticle
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installFollowArticleRoutes() {
authenticate(optional = true) {
followArticle(get(), get())
unfollowArticle(get(), get())
getFollowArticle(get(), get())
getMyFollowsArticle(get(), get())
}
}

View File

@@ -0,0 +1,34 @@
package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowConstitutionRepository
import fr.dcproject.component.follow.FollowForUpdate
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
import java.util.UUID
@KtorExperimentalLocationsAPI
object FollowConstitution {
@Location("/constitutions/{constitution}/follows")
class ConstitutionFollowRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.followConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
post<ConstitutionFollowRequest> {
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
ac.assert { canCreate(follow, citizenOrNull) }
repo.follow(follow)
call.respond(HttpStatusCode.Created)
}
}
}

View File

@@ -0,0 +1,33 @@
package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowConstitutionRepository
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
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 GetFollowConstitution {
@Location("/constitutions/{constitution}/follows")
class ConstitutionFollowRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.getFollowConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
get<ConstitutionFollowRequest> {
repo.findFollow(citizen, it.constitution)?.let { follow ->
ac.assert { canView(follow, citizenOrNull) }
call.respond(follow)
} ?: call.respond(HttpStatusCode.NotFound)
}
}
}

View File

@@ -0,0 +1,30 @@
package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowConstitutionRepository
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 GetMyFollowsConstitution {
@Location("/citizens/{citizen}/follows/constitutions")
class CitizenFollowConstitutionRequest(citizen: UUID) {
val citizen = CitizenRef(citizen)
}
fun Route.getMyFollowsConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
get<CitizenFollowConstitutionRequest> {
val follows = repo.findByCitizen(it.citizen)
ac.assert { canView(follows.result, citizenOrNull) }
call.respond(follows)
}
}
}

View File

@@ -0,0 +1,34 @@
package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.common.security.assert
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.auth.citizenOrNull
import fr.dcproject.component.constitution.ConstitutionRef
import fr.dcproject.component.follow.FollowAccessControl
import fr.dcproject.component.follow.FollowConstitutionRepository
import fr.dcproject.component.follow.FollowForUpdate
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.delete
import io.ktor.response.respond
import io.ktor.routing.Route
import java.util.UUID
@KtorExperimentalLocationsAPI
object UnfollowConstitution {
@Location("/constitutions/{constitution}/follows")
class ConstitutionUnfollowRequest(constitution: UUID) {
val constitution = ConstitutionRef(constitution)
}
fun Route.unfollowConstitution(repo: FollowConstitutionRepository, ac: FollowAccessControl) {
delete<ConstitutionUnfollowRequest> {
val follow = FollowForUpdate(target = it.constitution, createdBy = this.citizen)
ac.assert { canDelete(follow, citizenOrNull) }
repo.unfollow(follow)
call.respond(HttpStatusCode.NoContent)
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.follow.routes.constitution
import fr.dcproject.component.follow.routes.constitution.FollowConstitution.followConstitution
import fr.dcproject.component.follow.routes.constitution.GetFollowConstitution.getFollowConstitution
import fr.dcproject.component.follow.routes.constitution.GetMyFollowsConstitution.getMyFollowsConstitution
import fr.dcproject.component.follow.routes.constitution.UnfollowConstitution.unfollowConstitution
import io.ktor.auth.authenticate
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Routing
import org.koin.ktor.ext.get
@KtorExperimentalLocationsAPI
fun Routing.installFollowConstitutionRoutes() {
authenticate(optional = true) {
followConstitution(get(), get())
unfollowConstitution(get(), get())
getFollowConstitution(get(), get())
getMyFollowsConstitution(get(), get())
}
}

View File

@@ -0,0 +1,57 @@
package fr.dcproject.component.notification
import com.fasterxml.jackson.databind.DeserializationFeature
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.fasterxml.jackson.module.kotlin.readValue
import fr.dcproject.component.article.ArticleForView
import fr.postgresjson.entity.UuidEntity
import org.joda.time.DateTime
import java.util.concurrent.atomic.AtomicInteger
open class Notification(
val type: String,
val createdAt: DateTime = DateTime.now()
) {
val id: Double = nextId()
private fun nextId(): Double {
return (createdAt.millis.toString() + nextInt().toString()).toDouble()
}
override fun toString(): String = mapper.writeValueAsString(this) ?: error("Unable to serialize notification")
fun toByteArray() = toString().toByteArray()
companion object {
private val counter: AtomicInteger = AtomicInteger(1000)
fun nextInt(): Int {
counter.compareAndSet(9999, 1000)
return counter.incrementAndGet()
}
val mapper = jacksonObjectMapper().apply {
registerModule(SimpleModule())
propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE
registerModule(JodaModule())
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
inline fun <reified T : Notification> fromString(raw: String): T = mapper.readValue(raw)
}
}
open class EntityNotification(
val target: UuidEntity,
type: String,
val action: String
) : Notification(type)
class ArticleUpdateNotification(
target: ArticleForView
) : EntityNotification(target, "article", "update")

View File

@@ -0,0 +1,113 @@
package fr.dcproject.component.notification
import com.rabbitmq.client.AMQP.BasicProperties
import com.rabbitmq.client.BuiltinExchangeType.DIRECT
import com.rabbitmq.client.ConnectionFactory
import com.rabbitmq.client.Consumer
import com.rabbitmq.client.DefaultConsumer
import com.rabbitmq.client.Envelope
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.follow.FollowArticleRepository
import fr.dcproject.component.follow.FollowConstitutionRepository
import fr.dcproject.component.follow.FollowSimple
import io.ktor.utils.io.errors.IOException
import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class NotificationConsumer(
private val rabbitFactory: ConnectionFactory,
private val redisClient: RedisClient,
private val followConstitutionRepo: FollowConstitutionRepository,
private val followArticleRepo: FollowArticleRepository,
private val notificationEmailSender: NotificationEmailSender,
private val exchangeName: String,
) {
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis")
private val redis: RedisAsyncCommands<String, String> = redisConnection.async() ?: error("Unable to connect to redis")
private val rabbitConnection = rabbitFactory.newConnection()
private val rabbitChannel = rabbitConnection.createChannel()
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName)
fun close() {
rabbitChannel.close()
rabbitConnection.close()
}
fun start() {
/* Config Rabbit */
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, "")
}
}
/* Define Consumer */
val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) {
@Throws(IOException::class)
override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: BasicProperties,
body: ByteArray
) = runBlocking {
followersFromMessage(body) {
redis.zadd(
"notification:${it.follow.createdBy.id}",
it.event.id,
it.rawMessage
)
}
rabbitChannel.basicAck(envelope.deliveryTag, false)
}
}
val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) {
@Throws(IOException::class)
override fun handleDelivery(
consumerTag: String,
envelope: Envelope,
properties: BasicProperties,
body: ByteArray
) {
runBlocking {
followersFromMessage(body) {
notificationEmailSender.sendEmail(it.follow)
logger.debug("EmailSend to: ${it.follow.createdBy.id}")
}
}
rabbitChannel.basicAck(envelope.deliveryTag, false)
}
}
/* Launch Consumer */
rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket
rabbitChannel.basicConsume("email", false, consumerEmail)
}
private suspend fun followersFromMessage(body: ByteArray, action: suspend (DecodedMessage) -> Unit) {
val rawMessage: String = body.toString(Charsets.UTF_8)
val notification: EntityNotification = Notification.fromString<EntityNotification>(rawMessage) ?: error("Unable to deserialize notification message from rabbit")
val follows = when (notification.type) {
"article" -> followArticleRepo.findFollowsByTarget(notification.target)
"constitution" -> followConstitutionRepo.findFollowsByTarget(notification.target)
else -> error("event '${notification.type}' not implemented")
}
follows.collect { action(DecodedMessage(notification, rawMessage, it)) }
}
private class DecodedMessage(
val event: EntityNotification,
val rawMessage: String,
val follow: FollowSimple<out TargetRef, CitizenRef>
)
}

View File

@@ -0,0 +1,71 @@
package fr.dcproject.component.notification
import com.sendgrid.helpers.mail.Mail
import com.sendgrid.helpers.mail.objects.Content
import com.sendgrid.helpers.mail.objects.Email
import fr.dcproject.common.email.Mailer
import fr.dcproject.common.entity.TargetRef
import fr.dcproject.component.article.ArticleRepository
import fr.dcproject.component.article.ArticleWithTitleI
import fr.dcproject.component.citizen.CitizenBasicI
import fr.dcproject.component.citizen.CitizenRef
import fr.dcproject.component.citizen.CitizenRepository
import fr.dcproject.component.follow.FollowSimple
import fr.postgresjson.entity.UuidEntityI
import java.util.UUID
class NotificationEmailSender(
private val mailer: Mailer,
private val domain: String,
private val citizenRepo: CitizenRepository,
private val articleRepo: ArticleRepository
) {
fun sendEmail(follow: FollowSimple<out TargetRef, CitizenRef>) {
val citizen = citizenRepo.findById(follow.createdBy.id) ?: noCitizen(follow.createdBy.id)
val target = when (follow.target.reference) {
"article" ->
articleRepo.findById(follow.target.id) ?: noTarget(follow.target.id)
else -> noTarget(follow.target.id)
}
val subject = when (follow.target.reference) {
"article" -> """New version for article "${target.title}""""
else -> "Notification"
}
mailer.sendEmail {
Mail(
Email("notification@$domain"),
subject,
Email(citizen.email),
Content("text/plain", generateContent(citizen, target))
).apply {
addContent(Content("text/html", generateHtmlContent(citizen, target)))
}
}
}
private fun generateHtmlContent(citizen: CitizenBasicI, target: UuidEntityI): String? {
return when (target) {
is ArticleWithTitleI -> """
Hello ${citizen.name.getFullName()},<br/>
The article "${target.title}" was updated, check it <a href="http://$domain/articles/${target.id}">here</a>
""".trimIndent()
else -> noTarget(target.id)
}
}
private fun generateContent(citizen: CitizenBasicI, target: UuidEntityI): String {
return when (target) {
is ArticleWithTitleI -> """
Hello ${citizen.name.getFullName()},
The article "${target.title}" was updated, check it here: http://$domain/articles/${target.id}
""".trimIndent()
else -> noTarget(target.id)
}
}
class NoCitizen(message: String) : Exception(message)
class NoTarget(message: String) : Exception(message)
private fun noCitizen(id: UUID): Nothing = throw NoCitizen("No Citizen with this id : $id")
private fun noTarget(id: UUID): Nothing = throw NoTarget("No Target with this id : $id")
}

View File

@@ -0,0 +1,135 @@
package fr.dcproject.component.notification
import com.fasterxml.jackson.core.JsonProcessingException
import fr.dcproject.component.auth.citizen
import fr.dcproject.component.citizen.CitizenI
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.Frame.Text
import io.ktor.http.cio.websocket.readText
import io.ktor.routing.Route
import io.ktor.websocket.DefaultWebSocketServerSession
import io.lettuce.core.Limit
import io.lettuce.core.Range
import io.lettuce.core.Range.Boundary
import io.lettuce.core.RedisClient
import io.lettuce.core.api.async.RedisAsyncCommands
import io.lettuce.core.pubsub.RedisPubSubAdapter
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
class NotificationsPush private constructor(
private val redis: RedisAsyncCommands<String, String>,
private val redisConnectionPubSub: StatefulRedisPubSubConnection<String, String>,
citizen: CitizenI,
incoming: Flow<Notification>,
onRecieve: suspend (Notification) -> Unit,
) {
class Builder(val redisClient: RedisClient) {
private val redisConnection = redisClient.connect() ?: error("Unable to connect to redis")
private val redisConnectionPubSub = redisClient.connectPubSub() ?: error("Unable to connect to redis")
private val redis: RedisAsyncCommands<String, String> = redisConnection.async() ?: error("Unable to connect to redis")
fun build(
citizen: CitizenI,
incoming: Flow<Notification>,
onRecieve: suspend (Notification) -> Unit,
): NotificationsPush = NotificationsPush(redis, redisConnectionPubSub, citizen, incoming, onRecieve)
@ExperimentalCoroutinesApi
fun build(ws: DefaultWebSocketServerSession): NotificationsPush {
/* Convert channel of string from websocket, to a flow of Notification object */
val incomingFlow: Flow<Notification> = ws.incoming.consumeAsFlow()
.mapNotNull<Frame, Text> { it as? Frame.Text }
.map { it.readText() }
.map { Notification.fromString(it) }
return build(ws.call.citizen, incomingFlow) {
ws.outgoing.send(Text(it.toString()))
}.apply {
ws.outgoing.invokeOnClose { close() }
}
}
}
private val key = "notification:${citizen.id}"
private var score: Double = 0.0
private val listener = object : RedisPubSubAdapter<String, String>() {
/* On new key publish */
override fun message(pattern: String?, channel: String?, message: String?) {
runBlocking {
getNotifications().collect {
onRecieve(it)
}
}
}
}
init {
/* Mark as read all incoming notifications */
GlobalScope.launch {
incoming.collect {
markAsRead(it)
}
}
/* Get old notification and sent it to websocket */
runBlocking {
getNotifications().collect { onRecieve(it) }
}
/* Lisen redis event, and sent the new notification into websocket */
redisConnectionPubSub.run {
addListener(listener)
/* Register to the events */
async()?.psubscribe("__key*__:$key") ?: error("Unable to connect to redis")
}
}
fun close() {
redisConnectionPubSub.removeListener(listener)
}
/* Return flow with all new notifications */
private fun getNotifications() = flow<Notification> {
redis
.zrangebyscoreWithScores(
key,
Range.from(
Boundary.excluding(score),
Boundary.including(Double.POSITIVE_INFINITY)
),
Limit.from(100)
)
.get().forEach {
emit(Notification.fromString(it.value))
if (it.score > score) score = it.score
}
}
private suspend fun markAsRead(notificationMessage: Notification) = coroutineScope {
try {
redis.zremrangebyscore(
key,
Range.from(
Boundary.including(notificationMessage.id),
Boundary.including(notificationMessage.id)
)
)
} catch (e: JsonProcessingException) {
LoggerFactory.getLogger(Route::class.qualifiedName)
.error("Unable to deserialize notification")
}
}
}

View File

@@ -0,0 +1,25 @@
package fr.dcproject.component.notification
import com.rabbitmq.client.ConnectionFactory
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.slf4j.Logger
import org.slf4j.LoggerFactory
class Publisher(
private val factory: ConnectionFactory,
private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName),
private val exchangeName: String,
) {
suspend fun <T : EntityNotification> publish(it: T): Deferred<Unit> = coroutineScope {
async {
factory.newConnection().use { connection ->
connection.createChannel().use { channel ->
channel.basicPublish(exchangeName, "", null, it.toString().toByteArray())
logger.debug("Publish message ${it.target.id}")
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package fr.dcproject.component.notification.routes
import fr.dcproject.component.notification.NotificationsPush
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.routing.Route
import io.ktor.websocket.webSocket
import kotlinx.coroutines.ExperimentalCoroutinesApi
/**
* Consume Websocket, then remove notification in redis.
*
* Sent all notification to websocket.
*/
@ExperimentalCoroutinesApi
@KtorExperimentalLocationsAPI
fun Route.notificationArticle(pushBuilder: NotificationsPush.Builder) {
webSocket("/notifications") {
pushBuilder.build(this)
}
}

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