Prefix maven modules
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
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.export.ExportUseCase
|
||||
import be.simplenotes.domain.usecases.export.ExportUseCaseImpl
|
||||
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
|
||||
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteUseCaseImpl
|
||||
import be.simplenotes.domain.usecases.users.login.LoginUseCase
|
||||
import be.simplenotes.domain.usecases.users.login.LoginUseCaseImpl
|
||||
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
|
||||
import be.simplenotes.domain.usecases.users.register.RegisterUseCaseImpl
|
||||
import org.koin.dsl.module
|
||||
|
||||
val domainModule = module {
|
||||
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
|
||||
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
|
||||
single<DeleteUseCase> { DeleteUseCaseImpl(get(), get(), get()) }
|
||||
single { UserService(get(), get(), get(), get()) }
|
||||
single<PasswordHash> { BcryptPasswordHash() }
|
||||
single { SimpleJwt(get()) }
|
||||
single { JwtPayloadExtractor(get()) }
|
||||
single {
|
||||
NoteService(get(), get(), get(), get()).apply {
|
||||
dropAllIndexes()
|
||||
indexAll()
|
||||
}
|
||||
}
|
||||
single<MarkdownConverter> { MarkdownConverterImpl() }
|
||||
single<ExportUseCase> { ExportUseCaseImpl(get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package be.simplenotes.domain.model
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class NoteMetadata(
|
||||
val title: String,
|
||||
val tags: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PersistedNoteMetadata(
|
||||
val title: String,
|
||||
val tags: List<String>,
|
||||
@Contextual val updatedAt: LocalDateTime,
|
||||
@Contextual val uuid: UUID,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Note(
|
||||
val meta: NoteMetadata,
|
||||
val markdown: String,
|
||||
val html: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PersistedNote(
|
||||
val meta: NoteMetadata,
|
||||
val markdown: String,
|
||||
val html: String,
|
||||
@Contextual val updatedAt: LocalDateTime,
|
||||
@Contextual val uuid: UUID,
|
||||
val public: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExportedNote(
|
||||
val title: String,
|
||||
val tags: List<String>,
|
||||
val markdown: String,
|
||||
val html: String,
|
||||
@Contextual val updatedAt: LocalDateTime,
|
||||
val trash: Boolean,
|
||||
)
|
||||
@@ -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,20 @@
|
||||
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")
|
||||
.allowElements("input")
|
||||
.allowAttributes("type", "checked", "disabled", "readonly").onElements("input")
|
||||
.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(userIdField).asInt() ?: null
|
||||
val username = decodedJWT.getClaim(usernameField).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,25 @@
|
||||
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
|
||||
|
||||
internal const val userIdField = "i"
|
||||
internal const val usernameField = "u"
|
||||
|
||||
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(userIdField, jwtPayload.userId)
|
||||
.withClaim(usernameField, jwtPayload.username)
|
||||
.withExpiresAt(getExpiration())
|
||||
.sign(algorithm)
|
||||
|
||||
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package be.simplenotes.domain.usecases
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.extensions.fx
|
||||
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 be.simplenotes.domain.usecases.repositories.UserRepository
|
||||
import be.simplenotes.domain.usecases.search.NoteSearcher
|
||||
import be.simplenotes.domain.usecases.search.SearchTerms
|
||||
import java.util.*
|
||||
|
||||
class NoteService(
|
||||
private val markdownConverter: MarkdownConverter,
|
||||
private val noteRepository: NoteRepository,
|
||||
private val userRepository: UserRepository,
|
||||
private val searcher: NoteSearcher,
|
||||
) {
|
||||
|
||||
fun create(userId: Int, markdownText: String) = Either.fx<MarkdownParsingError, PersistedNote> {
|
||||
val 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) }
|
||||
|
||||
searcher.indexNote(userId, persistedNote)
|
||||
persistedNote
|
||||
}
|
||||
|
||||
fun update(userId: Int, uuid: UUID, markdownText: String) = Either.fx<MarkdownParsingError, PersistedNote?> {
|
||||
val 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) }
|
||||
|
||||
persistedNote?.let { searcher.updateIndex(userId, it) }
|
||||
persistedNote
|
||||
}
|
||||
|
||||
fun paginatedNotes(
|
||||
userId: Int,
|
||||
page: Int,
|
||||
itemsPerPage: Int = 20,
|
||||
tag: String? = null,
|
||||
deleted: Boolean = false,
|
||||
): PaginatedNotes {
|
||||
val count = noteRepository.count(userId, tag, deleted)
|
||||
val offset = (page - 1) * itemsPerPage
|
||||
val numberOfPages = (count / itemsPerPage) + 1
|
||||
val notes = if (count == 0) emptyList() else noteRepository.findAll(userId, itemsPerPage, offset, tag, deleted)
|
||||
return PaginatedNotes(numberOfPages, notes)
|
||||
}
|
||||
|
||||
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
|
||||
|
||||
fun trash(userId: Int, uuid: UUID): Boolean {
|
||||
val res = noteRepository.delete(userId, uuid, permanent = false)
|
||||
if (res) searcher.deleteIndex(userId, uuid)
|
||||
return res
|
||||
}
|
||||
|
||||
fun restore(userId: Int, uuid: UUID): Boolean {
|
||||
val res = noteRepository.restore(userId, uuid)
|
||||
if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) }
|
||||
return res
|
||||
}
|
||||
|
||||
fun delete(userId: Int, uuid: UUID): Boolean {
|
||||
val res = noteRepository.delete(userId, uuid, permanent = true)
|
||||
if (res) searcher.deleteIndex(userId, uuid)
|
||||
return res
|
||||
}
|
||||
|
||||
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
|
||||
|
||||
fun indexAll() {
|
||||
val userIds = userRepository.findAll()
|
||||
userIds.forEach { id ->
|
||||
val notes = noteRepository.findAllDetails(id)
|
||||
searcher.indexNotes(id, notes)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms)
|
||||
|
||||
fun dropAllIndexes() = searcher.dropAll()
|
||||
|
||||
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
|
||||
fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid)
|
||||
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
|
||||
}
|
||||
|
||||
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)
|
||||
@@ -0,0 +1,16 @@
|
||||
package be.simplenotes.domain.usecases
|
||||
|
||||
import be.simplenotes.domain.usecases.export.ExportUseCase
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
|
||||
import be.simplenotes.domain.usecases.users.login.LoginUseCase
|
||||
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
|
||||
|
||||
class UserService(
|
||||
loginUseCase: LoginUseCase,
|
||||
registerUseCase: RegisterUseCase,
|
||||
deleteUseCase: DeleteUseCase,
|
||||
exportUseCase: ExportUseCase,
|
||||
) : LoginUseCase by loginUseCase,
|
||||
RegisterUseCase by registerUseCase,
|
||||
DeleteUseCase by deleteUseCase,
|
||||
ExportUseCase by exportUseCase
|
||||
@@ -0,0 +1,8 @@
|
||||
package be.simplenotes.domain.usecases.export
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
interface ExportUseCase {
|
||||
fun exportAsJson(userId: Int): String
|
||||
fun exportAsZip(userId: Int): InputStream
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package be.simplenotes.domain.usecases.export
|
||||
|
||||
import be.simplenotes.domain.model.ExportedNote
|
||||
import be.simplenotes.domain.usecases.repositories.NoteRepository
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, private val json: Json) : ExportUseCase {
|
||||
override fun exportAsJson(userId: Int): String {
|
||||
val notes = noteRepository.export(userId)
|
||||
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
|
||||
|
||||
override fun exportAsZip(userId: Int): InputStream {
|
||||
val notes = noteRepository.export(userId)
|
||||
val zipOutput = ZipOutput()
|
||||
zipOutput.use { zip ->
|
||||
notes.forEach {
|
||||
val name = sanitizeFilename(it.title)
|
||||
zip.write("notes/$name.md", it.markdown)
|
||||
}
|
||||
}
|
||||
return ByteArrayInputStream(zipOutput.outputStream.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ZipOutput : AutoCloseable {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
private val zipOutputStream = ZipArchiveOutputStream(outputStream)
|
||||
|
||||
fun write(path: String, content: String) {
|
||||
val entry = ZipArchiveEntry(path)
|
||||
zipOutputStream.putArchiveEntry(entry)
|
||||
zipOutputStream.write(content.toByteArray())
|
||||
zipOutputStream.closeArchiveEntry()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipOutputStream.finish()
|
||||
zipOutputStream.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package be.simplenotes.domain.usecases.markdown
|
||||
|
||||
import arrow.core.Either
|
||||
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.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
|
||||
import com.vladsch.flexmark.html.HtmlRenderer
|
||||
import com.vladsch.flexmark.parser.Parser
|
||||
import com.vladsch.flexmark.util.data.MutableDataSet
|
||||
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
|
||||
private val renderer: HtmlRenderer
|
||||
|
||||
init {
|
||||
val options = MutableDataSet()
|
||||
options.set(Parser.EXTENSIONS, listOf(TaskListExtension.create()))
|
||||
options.set(HtmlRenderer.SOFT_BREAK, "<br>")
|
||||
parser = Parser.builder(options).build()
|
||||
renderer = HtmlRenderer.builder(options).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,35 @@
|
||||
package be.simplenotes.domain.usecases.repositories
|
||||
|
||||
import be.simplenotes.domain.model.ExportedNote
|
||||
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,
|
||||
tag: String? = null,
|
||||
deleted: Boolean = false
|
||||
): List<PersistedNoteMetadata>
|
||||
|
||||
fun count(userId: Int, tag: String? = null, deleted: Boolean = false): Int
|
||||
fun delete(userId: Int, uuid: UUID, permanent: Boolean = false): Boolean
|
||||
fun restore(userId: Int, uuid: UUID): Boolean
|
||||
|
||||
// These methods only access notes where `Notes.deleted = false`
|
||||
fun getTags(userId: Int): List<String>
|
||||
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 export(userId: Int): List<ExportedNote>
|
||||
fun findAllDetails(userId: Int): List<PersistedNote>
|
||||
|
||||
fun makePublic(userId: Int, uuid: UUID): Boolean
|
||||
fun makePrivate(userId: Int, uuid: UUID): Boolean
|
||||
fun findPublic(uuid: UUID): PersistedNote?
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
fun findAll(): List<Int>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package be.simplenotes.domain.usecases.search
|
||||
|
||||
import be.simplenotes.domain.model.PersistedNote
|
||||
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||
import java.util.*
|
||||
|
||||
data class SearchTerms(val title: String?, val tag: String?, val content: String?, val all: String?)
|
||||
|
||||
interface NoteSearcher {
|
||||
fun indexNote(userId: Int, note: PersistedNote)
|
||||
fun indexNotes(userId: Int, notes: List<PersistedNote>)
|
||||
fun deleteIndex(userId: Int, uuid: UUID)
|
||||
fun updateIndex(userId: Int, note: PersistedNote)
|
||||
fun search(userId: Int, terms: SearchTerms): List<PersistedNoteMetadata>
|
||||
fun dropIndex(userId: Int)
|
||||
fun dropAll()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package be.simplenotes.domain.usecases.users.delete
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.extensions.fx
|
||||
import arrow.core.rightIfNotNull
|
||||
import be.simplenotes.domain.security.PasswordHash
|
||||
import be.simplenotes.domain.usecases.repositories.UserRepository
|
||||
import be.simplenotes.domain.usecases.search.NoteSearcher
|
||||
import be.simplenotes.domain.validation.UserValidations
|
||||
|
||||
internal class DeleteUseCaseImpl(
|
||||
private val userRepository: UserRepository,
|
||||
private val passwordHash: PasswordHash,
|
||||
private val searcher: NoteSearcher,
|
||||
) : DeleteUseCase {
|
||||
override fun delete(form: DeleteForm) = Either.fx<DeleteError, Unit> {
|
||||
val user = !UserValidations.validateDelete(form)
|
||||
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
|
||||
!Either.cond(
|
||||
passwordHash.verify(user.password, persistedUser.password),
|
||||
{ Unit },
|
||||
{ DeleteError.WrongPassword }
|
||||
)
|
||||
!Either.cond(userRepository.delete(persistedUser.id), { Unit }, { DeleteError.Unregistered })
|
||||
searcher.dropIndex(persistedUser.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package be.simplenotes.domain.usecases.users.delete
|
||||
|
||||
import arrow.core.Either
|
||||
import io.konform.validation.ValidationErrors
|
||||
|
||||
sealed class DeleteError {
|
||||
object Unregistered : DeleteError()
|
||||
object WrongPassword : DeleteError()
|
||||
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
|
||||
}
|
||||
|
||||
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
|
||||
|
||||
interface DeleteUseCase {
|
||||
fun delete(form: DeleteForm): Either<DeleteError, Unit>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package be.simplenotes.domain.usecases.users.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,19 @@
|
||||
package be.simplenotes.domain.usecases.users.login
|
||||
|
||||
import arrow.core.Either
|
||||
import io.konform.validation.ValidationErrors
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed class LoginError
|
||||
object Unregistered : LoginError()
|
||||
object WrongPassword : LoginError()
|
||||
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
|
||||
|
||||
typealias Token = String
|
||||
|
||||
@Serializable
|
||||
data class LoginForm(val username: String?, val password: String?)
|
||||
|
||||
interface LoginUseCase {
|
||||
fun login(form: LoginForm): Either<LoginError, Token>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package be.simplenotes.domain.usecases.users.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.users.register
|
||||
|
||||
import arrow.core.Either
|
||||
import be.simplenotes.domain.model.PersistedUser
|
||||
import be.simplenotes.domain.usecases.users.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,36 @@
|
||||
package be.simplenotes.domain.validation
|
||||
|
||||
import arrow.core.*
|
||||
import be.simplenotes.domain.model.NoteMetadata
|
||||
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() }
|
||||
addConstraint("must only contain alphanumeric characters, `-` and `_`") {
|
||||
it.matches("^[a-zA-Z0-9-_]+\$".toRegex())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,60 @@
|
||||
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.users.delete.DeleteError
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteForm
|
||||
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
|
||||
import be.simplenotes.domain.usecases.users.login.LoginForm
|
||||
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
|
||||
import be.simplenotes.domain.usecases.users.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()
|
||||
}
|
||||
|
||||
private val deleteValidator = Validation<DeleteForm> {
|
||||
DeleteForm::username required {
|
||||
minLength(3)
|
||||
maxLength(50)
|
||||
}
|
||||
DeleteForm::password required {
|
||||
minLength(8)
|
||||
maxLength(72)
|
||||
}
|
||||
DeleteForm::checked required {
|
||||
addConstraint("Should be checked") { it }
|
||||
}
|
||||
}
|
||||
|
||||
fun validateDelete(form: DeleteForm): Either<DeleteError.InvalidForm, User> {
|
||||
val errors = deleteValidator.validate(form).errors
|
||||
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
|
||||
else return DeleteError.InvalidForm(errors).left()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package be.simplenotes.domain
|
||||
|
||||
/**
|
||||
* Empty file @see [root-package-declaration](https://discuss.kotlinlang.org/t/root-package-declaration-to-reduce-folder-clutter/2247/4)
|
||||
*/
|
||||
@@ -0,0 +1,50 @@
|
||||
package be.simplenotes.domain.security
|
||||
|
||||
import be.simplenotes.domain.usecases.users.login.Token
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import com.natpryce.hamkrest.absent
|
||||
import com.natpryce.hamkrest.assertion.assertThat
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.Stream
|
||||
|
||||
internal class JwtPayloadExtractorTest {
|
||||
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
|
||||
private val simpleJwt = SimpleJwt(jwtConfig)
|
||||
private val jwtPayloadExtractor = JwtPayloadExtractor(simpleJwt)
|
||||
|
||||
private fun createToken(username: String? = null, id: Int? = null, secret: String = jwtConfig.secret): Token {
|
||||
val algo = Algorithm.HMAC256(secret)
|
||||
return JWT.create().apply {
|
||||
username?.let { withClaim(usernameField, it) }
|
||||
id?.let { withClaim(userIdField, it) }
|
||||
}.sign(algo)
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
private fun invalidTokens() = Stream.of(
|
||||
createToken(id = 1),
|
||||
createToken(username = "user"),
|
||||
createToken(),
|
||||
createToken(username = "user", id = 1, secret = "not the correct secret"),
|
||||
createToken(username = "user", id = 1) + "\"efesfsef",
|
||||
"something that is not even a token"
|
||||
)
|
||||
|
||||
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid")
|
||||
@MethodSource("invalidTokens")
|
||||
fun `parse invalid tokens`(token: String) {
|
||||
assertThat(jwtPayloadExtractor(token), absent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse valid token`() {
|
||||
val token = createToken(username = "someone", id = 1)
|
||||
assertThat(jwtPayloadExtractor(token), equalTo(JwtPayload(1, "someone")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package be.simplenotes.domain.usecases.users.login
|
||||
|
||||
import be.simplenotes.domain.model.PersistedUser
|
||||
import be.simplenotes.domain.security.BcryptPasswordHash
|
||||
import be.simplenotes.domain.security.SimpleJwt
|
||||
import be.simplenotes.domain.usecases.repositories.UserRepository
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
import be.simplenotes.shared.testutils.assertions.isLeftOfType
|
||||
import be.simplenotes.shared.testutils.assertions.isRight
|
||||
import com.natpryce.hamkrest.assertion.assertThat
|
||||
import io.mockk.*
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal class LoginUseCaseImplTest {
|
||||
// region setup
|
||||
private val mockUserRepository = mockk<UserRepository>()
|
||||
private val passwordHash = BcryptPasswordHash(test = true)
|
||||
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
|
||||
private val simpleJwt = SimpleJwt(jwtConfig)
|
||||
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(mockUserRepository)
|
||||
}
|
||||
// endregion
|
||||
|
||||
@Test
|
||||
fun `Login should fail with invalid form`() {
|
||||
val form = LoginForm("", "a")
|
||||
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
|
||||
verify { mockUserRepository wasNot called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Login should fail with non existing user`() {
|
||||
val form = LoginForm("someusername", "somepassword")
|
||||
every { mockUserRepository.find(form.username!!) } returns null
|
||||
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Login should fail with wrong password`() {
|
||||
val form = LoginForm("someusername", "wrongpassword")
|
||||
|
||||
every { mockUserRepository.find(form.username!!) } returns
|
||||
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
|
||||
|
||||
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Login should succeed with existing user and correct password`() {
|
||||
val loginForm = LoginForm("someusername", "somepassword")
|
||||
|
||||
every { mockUserRepository.find(loginForm.username!!) } returns
|
||||
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
|
||||
|
||||
val res = loginUseCase.login(loginForm)
|
||||
assertThat(res, isRight())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package be.simplenotes.domain.usecases.users.register
|
||||
|
||||
import be.simplenotes.domain.model.PersistedUser
|
||||
import be.simplenotes.domain.security.BcryptPasswordHash
|
||||
import be.simplenotes.domain.usecases.repositories.UserRepository
|
||||
import be.simplenotes.shared.testutils.assertions.isLeftOfType
|
||||
import be.simplenotes.shared.testutils.assertions.isRight
|
||||
import com.natpryce.hamkrest.assertion.assertThat
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import io.mockk.*
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class RegisterUseCaseImplTest {
|
||||
|
||||
// region setup
|
||||
private val mockUserRepository = mockk<UserRepository>()
|
||||
private val passwordHash = BcryptPasswordHash(test = true)
|
||||
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(mockUserRepository)
|
||||
}
|
||||
// endregion
|
||||
|
||||
@Test
|
||||
fun `register should fail with invalid form`() {
|
||||
val form = RegisterForm("", "a".repeat(10))
|
||||
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
|
||||
verify { mockUserRepository wasNot called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Register should fail with existing username`() {
|
||||
val form = RegisterForm("someuser", "somepassword")
|
||||
every { mockUserRepository.exists(form.username!!) } returns true
|
||||
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Register should succeed with new user`() {
|
||||
val form = RegisterForm("someuser", "somepassword")
|
||||
every { mockUserRepository.exists(form.username!!) } returns false
|
||||
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
|
||||
val res = registerUseCase.register(form)
|
||||
assertThat(res, isRight())
|
||||
res.map { assertThat(it.username, equalTo(form.username)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package be.simplenotes.domain.validation
|
||||
|
||||
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
|
||||
import be.simplenotes.domain.usecases.users.login.LoginForm
|
||||
import be.simplenotes.domain.usecases.users.register.RegisterForm
|
||||
import be.simplenotes.shared.testutils.assertions.isLeftOfType
|
||||
import be.simplenotes.shared.testutils.assertions.isRight
|
||||
import com.natpryce.hamkrest.assertion.assertThat
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.util.stream.Stream
|
||||
|
||||
internal class UserValidationsTest {
|
||||
|
||||
@Nested
|
||||
inner class Login {
|
||||
|
||||
@Suppress("Unused")
|
||||
fun invalidLoginForms(): Stream<LoginForm> = Stream.of(
|
||||
LoginForm(username = null, password = null),
|
||||
LoginForm(username = "", password = ""),
|
||||
LoginForm(username = "a", password = "aaaa"),
|
||||
LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
|
||||
LoginForm(username = "a".repeat(10), password = "a".repeat(7))
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidLoginForms")
|
||||
fun `validate invalid logins`(form: LoginForm) {
|
||||
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
fun validLoginForms(): Stream<LoginForm> = Stream.of(
|
||||
LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
|
||||
LoginForm(username = "a".repeat(3), password = "a".repeat(8))
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("validLoginForms")
|
||||
fun `validate valid logins`(form: LoginForm) {
|
||||
assertThat(UserValidations.validateLogin(form), isRight())
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Register {
|
||||
|
||||
@Suppress("Unused")
|
||||
fun invalidRegisterForms(): Stream<RegisterForm> = Stream.of(
|
||||
RegisterForm(username = null, password = null),
|
||||
RegisterForm(username = "", password = ""),
|
||||
RegisterForm(username = "a", password = "aaaa"),
|
||||
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
|
||||
RegisterForm(username = "a".repeat(10), password = "a".repeat(7))
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidRegisterForms")
|
||||
fun `validate invalid register`(form: LoginForm) {
|
||||
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
|
||||
}
|
||||
|
||||
@Suppress("Unused")
|
||||
fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
|
||||
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
|
||||
RegisterForm(username = "a".repeat(3), password = "a".repeat(8))
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("validRegisterForms")
|
||||
fun `validate valid register`(form: LoginForm) {
|
||||
assertThat(UserValidations.validateLogin(form), isRight())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user