Merge http4k

This commit is contained in:
2020-08-13 19:37:39 +02:00
parent b41b2103f0
commit 24aabd494e
176 changed files with 4965 additions and 8607 deletions
+26
View File
@@ -0,0 +1,26 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.login.LoginUseCase
import be.simplenotes.domain.usecases.login.LoginUseCaseImpl
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
import be.simplenotes.domain.usecases.register.RegisterUseCase
import be.simplenotes.domain.usecases.register.RegisterUseCaseImpl
import org.koin.dsl.module
val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
single { UserService(get(), get()) }
single<PasswordHash> { BcryptPasswordHash() }
single { SimpleJwt(get()) }
single { JwtPayloadExtractor(get()) }
single { NoteService(get(), get()) }
single<MarkdownConverter> { MarkdownConverterImpl() }
}
+30
View File
@@ -0,0 +1,30 @@
package be.simplenotes.domain.model
import java.time.LocalDateTime
import java.util.*
data class NoteMetadata(
val title: String,
val tags: List<String>,
)
data class PersistedNoteMetadata(
val title: String,
val tags: List<String>,
val updatedAt: LocalDateTime,
val uuid: UUID,
)
data class Note(
val meta: NoteMetadata,
val markdown: String,
val html: String,
)
data class PersistedNote(
val meta: NoteMetadata,
val markdown: String,
val html: String,
val updatedAt: LocalDateTime,
val uuid: UUID,
)
+4
View File
@@ -0,0 +1,4 @@
package be.simplenotes.domain.model
data class User(val username: String, val password: String)
data class PersistedUser(val username: String, val password: String, val id: Int)
@@ -0,0 +1,18 @@
package be.simplenotes.domain.security
import org.owasp.html.HtmlPolicyBuilder
object HtmlSanitizer {
private val htmlPolicy = HtmlPolicyBuilder()
.allowElements("a")
.allowCommonBlockElements()
.allowCommonInlineFormattingElements()
.allowElements("pre")
.allowAttributes("class").onElements("code")
.allowUrlProtocols("http", "https")
.allowAttributes("href").onElements("a")
.requireRelNofollowOnLinks()
.toFactory()!!
fun sanitize(unsafeHtml: String) = htmlPolicy.sanitize(unsafeHtml)!!
}
@@ -0,0 +1,21 @@
package be.simplenotes.domain.security
import be.simplenotes.domain.model.PersistedUser
import com.auth0.jwt.exceptions.JWTVerificationException
data class JwtPayload(val userId: Int, val username: String) {
constructor(user: PersistedUser) : this(user.id, user.username)
}
class JwtPayloadExtractor(private val jwt: SimpleJwt) {
operator fun invoke(token: String): JwtPayload? = try {
val decodedJWT = jwt.verifier.verify(token)
val id = decodedJWT.getClaim("id").asInt() ?: null
val username = decodedJWT.getClaim("username").asString() ?: null
id?.let { username?.let { JwtPayload(id, username) } }
} catch (e: JWTVerificationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
@@ -0,0 +1,14 @@
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
internal interface PasswordHash {
fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean
}
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash {
private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
}
@@ -0,0 +1,22 @@
package be.simplenotes.domain.security
import be.simplenotes.shared.config.JwtConfig
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.*
import java.util.concurrent.TimeUnit
class SimpleJwt(jwtConfig: JwtConfig) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
private val algorithm = Algorithm.HMAC256(jwtConfig.secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(jwtPayload: JwtPayload): String = JWT.create()
.withClaim("id", jwtPayload.userId)
.withClaim("username", jwtPayload.username)
.withExpiresAt(getExpiration())
.sign(algorithm)
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}
@@ -0,0 +1,45 @@
package be.simplenotes.domain.usecases
import arrow.core.Either
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.repositories.NoteRepository
import java.util.*
class NoteService(
private val markdownConverter: MarkdownConverter,
private val noteRepository: NoteRepository,
) {
fun create(userId: Int, markdownText: String): Either<MarkdownParsingError, PersistedNote> =
markdownConverter
.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.create(userId, it) }
fun update(userId: Int, uuid: UUID, markdownText: String): Either<MarkdownParsingError, PersistedNote?> =
markdownConverter
.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.update(userId, uuid, it) }
fun paginatedNotes(userId: Int, page: Int, itemsPerPage: Int = 20): PaginatedNotes {
val count = noteRepository.count(userId)
val offset = (page - 1) * itemsPerPage
val numberOfPages = (count / itemsPerPage) + 1
val notes = if (count == 0) emptyList() else noteRepository.findAll(userId, itemsPerPage, offset)
return PaginatedNotes(numberOfPages, notes)
}
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun delete(userId: Int, uuid: UUID) = noteRepository.delete(userId, uuid)
}
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)
@@ -0,0 +1,9 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.login.LoginUseCase
import be.simplenotes.domain.usecases.register.RegisterUseCase
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase
) : LoginUseCase by loginUseCase, RegisterUseCase by registerUseCase
@@ -0,0 +1,25 @@
package be.simplenotes.domain.usecases.login
import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt
) : LoginUseCase {
override fun login(form: LoginForm) = Either.fx<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(JwtPayload(it)) }
}
}
@@ -0,0 +1,17 @@
package be.simplenotes.domain.usecases.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}
@@ -0,0 +1,74 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import arrow.core.Try
import arrow.core.extensions.fx
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.validation.NoteValidations
import com.auth0.jwt.JWT
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
typealias MetaMdPair = Pair<String, String>
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
internal class MarkdownConverterImpl : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return InvalidMeta.left()
} catch (e: ScannerException) {
return InvalidMeta.left()
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left()
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
}
private val parser = Parser.builder().build()
private val renderer = HtmlRenderer.builder().build()
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = Either.fx<MarkdownParsingError, Document> {
val (meta, md) = !splitMetaFromDocument(input)
val parsedMeta = !parseMeta(meta)
!NoteValidations.validateMetadata(parsedMeta).toEither { }.swap()
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.domain.usecases.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> {
return UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}
@@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.register
import arrow.core.Either
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.usecases.login.LoginForm
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}
@@ -0,0 +1,17 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import java.util.*
interface NoteRepository {
fun findAll(userId: Int, limit: Int = 20, offset: Int = 0): List<PersistedNoteMetadata>
fun exists(userId: Int, uuid: UUID): Boolean
fun create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun delete(userId: Int, uuid: UUID): Boolean
fun getTags(userId: Int): List<String>
fun count(userId: Int): Int
}
@@ -0,0 +1,13 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
interface UserRepository {
fun create(user: User): PersistedUser?
fun find(username: String): PersistedUser?
fun find(id: Int): PersistedUser?
fun exists(username: String): Boolean
fun exists(id: Int): Boolean
fun delete(id: Int): Boolean
}
@@ -0,0 +1,36 @@
package be.simplenotes.domain.validation
import arrow.core.*
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.login.InvalidLoginForm
import be.simplenotes.domain.usecases.markdown.ValidationError
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.uniqueItems
internal object NoteValidations {
private val metaValidator = Validation<NoteMetadata> {
NoteMetadata::title required {
addConstraint("must not be blank") { it.isNotBlank() }
maxLength(50)
}
NoteMetadata::tags required {
maxItems(5)
uniqueItems(true)
}
NoteMetadata::tags onEach {
maxLength(15)
addConstraint("must not be blank") { it.isNotBlank() }
}
}
fun validateMetadata(meta: NoteMetadata): Option<ValidationError> {
val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) none()
else return ValidationError(errors).some()
}
}
@@ -0,0 +1,38 @@
package be.simplenotes.domain.validation
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.login.InvalidLoginForm
import be.simplenotes.domain.usecases.login.LoginForm
import be.simplenotes.domain.usecases.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.register.RegisterForm
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
internal object UserValidations {
private val loginValidator = Validation<LoginForm> {
LoginForm::username required {
minLength(3)
maxLength(50)
}
LoginForm::password required {
minLength(8)
maxLength(72) // jbcrypt limit, see https://security.stackexchange.com/a/39851
}
}
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidLoginForm(errors).left()
}
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidRegisterForm(errors).left()
}
}